Init
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
spring:
|
||||
application:
|
||||
name: pcp-ui-service
|
||||
profiles:
|
||||
default: local
|
||||
config:
|
||||
import: "configserver:"
|
||||
cloud:
|
||||
config:
|
||||
uri: ${CONFIG_SERVER_URI:http://localhost:8888}
|
||||
fail-fast: ${CONFIG_SERVER_FAIL_FAST:true}
|
||||
profile: ${SPRING_CLOUD_CONFIG_PROFILE:${SPRING_PROFILES_ACTIVE:${spring.profiles.default}}}
|
||||
label: ${SPRING_CLOUD_CONFIG_LABEL:dev}
|
||||
@@ -0,0 +1,827 @@
|
||||
(function () {
|
||||
const pageRoot = document.getElementById('catalog-page');
|
||||
if (!pageRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const page = pageRoot.dataset.page;
|
||||
const apiBase = '/api/catalog';
|
||||
|
||||
function showAlert(containerId, message, type = 'danger') {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
if (!message) {
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = `<div class="alert alert-${type} mb-3" role="alert">${message}</div>`;
|
||||
}
|
||||
|
||||
async function requestJson(url, options = {}) {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
...options
|
||||
});
|
||||
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isJson = response.headers.get('content-type')?.includes('application/json');
|
||||
const payload = isJson ? await response.json() : await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
if (payload && typeof payload === 'object') {
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
throw new Error(Object.values(payload).join('; '));
|
||||
}
|
||||
throw new Error(payload || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function toNullableNumber(value) {
|
||||
return value === '' || value === null || value === undefined ? null : Number(value);
|
||||
}
|
||||
|
||||
function formatDateTimeInput(value) {
|
||||
if (!value) return '';
|
||||
const match = String(value).match(
|
||||
/^(\d{4})-(\d{2})-(\d{2})[T\s](\d{2}):(\d{2})(?::(\d{2})(?:[.,](\d{1,9}))?)?/
|
||||
);
|
||||
if (!match) return value;
|
||||
|
||||
const [, year, month, day, hour, minute, second = '00', millis = '000'] = match;
|
||||
return `${day}.${month}.${year} ${hour}:${minute}:${second}.${millis.slice(0, 3).padEnd(3, '0')}`;
|
||||
}
|
||||
|
||||
function parseDateTimeInput(value, fieldName, required = false) {
|
||||
const text = value?.trim() ?? '';
|
||||
if (!text) {
|
||||
if (required) {
|
||||
throw new Error(`${fieldName}: укажите время в формате dd.mm.yyyy hh:mm:ss.zzz`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const displayMatch = text.match(/^(\d{2})\.(\d{2})\.(\d{4})\s+(\d{2}):(\d{2}):(\d{2})[.,](\d{1,3})$/);
|
||||
if (displayMatch) {
|
||||
const [, day, month, year, hour, minute, second, millis] = displayMatch;
|
||||
validateDateTimeParts(day, month, year, hour, minute, second, fieldName);
|
||||
return `${year}-${month}-${day}T${hour}:${minute}:${second}.${millis.padEnd(3, '0')}`;
|
||||
}
|
||||
|
||||
const isoMatch = text.match(/^(\d{4})-(\d{2})-(\d{2})[T\s](\d{2}):(\d{2})(?::(\d{2})(?:[.,](\d{1,3}))?)?$/);
|
||||
if (isoMatch) {
|
||||
const [, year, month, day, hour, minute, second = '00', millis = '000'] = isoMatch;
|
||||
validateDateTimeParts(day, month, year, hour, minute, second, fieldName);
|
||||
return `${year}-${month}-${day}T${hour}:${minute}:${second}.${millis.padEnd(3, '0')}`;
|
||||
}
|
||||
|
||||
throw new Error(`${fieldName}: укажите время в формате dd.mm.yyyy hh:mm:ss.zzz`);
|
||||
}
|
||||
|
||||
function validateDateTimeParts(day, month, year, hour, minute, second, fieldName) {
|
||||
const date = new Date(
|
||||
Number(year),
|
||||
Number(month) - 1,
|
||||
Number(day),
|
||||
Number(hour),
|
||||
Number(minute),
|
||||
Number(second)
|
||||
);
|
||||
const isValid = date.getFullYear() === Number(year) &&
|
||||
date.getMonth() === Number(month) - 1 &&
|
||||
date.getDate() === Number(day) &&
|
||||
date.getHours() === Number(hour) &&
|
||||
date.getMinutes() === Number(minute) &&
|
||||
date.getSeconds() === Number(second);
|
||||
|
||||
if (!isValid) {
|
||||
throw new Error(`${fieldName}: некорректная дата или время`);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDisplayDateTime(value) {
|
||||
if (!value) return '—';
|
||||
return formatDateTimeInput(value);
|
||||
}
|
||||
|
||||
function buildRepeatedQueryParam(name, values) {
|
||||
const params = new URLSearchParams();
|
||||
values.forEach(value => params.append(name, String(value)));
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
function parseAngles(text) {
|
||||
const lines = text
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return lines.map((line, index) => {
|
||||
const parts = line.split(',').map(part => part.trim());
|
||||
if (parts.length !== 2) {
|
||||
throw new Error(`Строка ${index + 1} должна содержать два числа через запятую`);
|
||||
}
|
||||
const angleBegin = Number(parts[0]);
|
||||
const angleEnd = Number(parts[1]);
|
||||
if (Number.isNaN(angleBegin) || Number.isNaN(angleEnd)) {
|
||||
throw new Error(`Строка ${index + 1} содержит некорректный диапазон углов`);
|
||||
}
|
||||
return { angleBegin, angleEnd };
|
||||
});
|
||||
}
|
||||
|
||||
function renderChips(items) {
|
||||
if (!items.length) {
|
||||
return '<span class="text-muted">Пусто</span>';
|
||||
}
|
||||
return items.map(item => `<span class="catalog-chip">${item}</span>`).join('');
|
||||
}
|
||||
|
||||
function initGroupsPage() {
|
||||
const state = {
|
||||
groups: [],
|
||||
satellites: [],
|
||||
selectedGroupId: null
|
||||
};
|
||||
|
||||
const groupTableBody = document.getElementById('groups-table-body');
|
||||
const groupForm = document.getElementById('group-form');
|
||||
const satelliteChecklist = document.getElementById('group-satellite-checklist');
|
||||
const title = document.getElementById('group-form-title');
|
||||
|
||||
function renderSatelliteChecklist() {
|
||||
satelliteChecklist.innerHTML = '';
|
||||
state.satellites.forEach(satellite => {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'form-check';
|
||||
wrapper.innerHTML = `
|
||||
<input class="form-check-input" type="checkbox" value="${satellite.id}" id="group-satellite-${satellite.id}">
|
||||
<label class="form-check-label" for="group-satellite-${satellite.id}">
|
||||
${satellite.name} (${satellite.code}, ${satellite.id})
|
||||
</label>
|
||||
`;
|
||||
satelliteChecklist.appendChild(wrapper);
|
||||
});
|
||||
}
|
||||
|
||||
function selectedSatelliteIds() {
|
||||
return Array.from(satelliteChecklist.querySelectorAll('input:checked'))
|
||||
.map(input => Number(input.value));
|
||||
}
|
||||
|
||||
function setSelectedSatelliteIds(ids) {
|
||||
const selected = new Set(ids);
|
||||
satelliteChecklist.querySelectorAll('input').forEach(input => {
|
||||
input.checked = selected.has(Number(input.value));
|
||||
});
|
||||
}
|
||||
|
||||
function renderGroups() {
|
||||
groupTableBody.innerHTML = '';
|
||||
if (!state.groups.length) {
|
||||
groupTableBody.innerHTML = '<tr><td colspan="4" class="text-center text-muted py-4">Группировки не найдены</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
state.groups.forEach(group => {
|
||||
const tr = document.createElement('tr');
|
||||
if (group.id === state.selectedGroupId) {
|
||||
tr.classList.add('table-active');
|
||||
}
|
||||
tr.innerHTML = `
|
||||
<td>${group.id}</td>
|
||||
<td>${group.name}</td>
|
||||
<td>${group.satelliteIds.length}</td>
|
||||
<td>${renderChips(group.satelliteIds.map(String))}</td>
|
||||
`;
|
||||
tr.addEventListener('click', () => selectGroup(group.id));
|
||||
groupTableBody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
groupForm.reset();
|
||||
document.getElementById('group-id').value = '';
|
||||
state.selectedGroupId = null;
|
||||
setSelectedSatelliteIds([]);
|
||||
title.textContent = 'Новая группировка';
|
||||
renderGroups();
|
||||
showAlert('groups-alert', '');
|
||||
}
|
||||
|
||||
function fillForm(group) {
|
||||
document.getElementById('group-id').value = String(group.id);
|
||||
document.getElementById('group-name').value = group.name;
|
||||
setSelectedSatelliteIds(group.satelliteIds);
|
||||
title.textContent = `Группировка #${group.id}`;
|
||||
}
|
||||
|
||||
function selectGroup(groupId) {
|
||||
const group = state.groups.find(item => item.id === groupId);
|
||||
if (!group) return;
|
||||
state.selectedGroupId = groupId;
|
||||
fillForm(group);
|
||||
renderGroups();
|
||||
showAlert('groups-alert', '');
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
showAlert('groups-alert', '');
|
||||
const [satellites, groups] = await Promise.all([
|
||||
requestJson(`${apiBase}/satellites`),
|
||||
requestJson(`${apiBase}/groups`)
|
||||
]);
|
||||
state.satellites = satellites;
|
||||
state.groups = groups;
|
||||
renderSatelliteChecklist();
|
||||
renderGroups();
|
||||
if (state.selectedGroupId) {
|
||||
const selected = state.groups.find(item => item.id === state.selectedGroupId);
|
||||
if (selected) {
|
||||
fillForm(selected);
|
||||
} else {
|
||||
resetForm();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('groups-refresh').addEventListener('click', async () => {
|
||||
try {
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
showAlert('groups-alert', error.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('groups-new').addEventListener('click', resetForm);
|
||||
|
||||
groupForm.addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
const groupId = document.getElementById('group-id').value;
|
||||
const payload = {
|
||||
name: document.getElementById('group-name').value.trim(),
|
||||
satelliteIds: selectedSatelliteIds()
|
||||
};
|
||||
|
||||
const result = groupId
|
||||
? await requestJson(`${apiBase}/groups/${groupId}`, { method: 'PUT', body: JSON.stringify(payload) })
|
||||
: await requestJson(`${apiBase}/groups`, { method: 'POST', body: JSON.stringify(payload) });
|
||||
|
||||
state.selectedGroupId = result.id;
|
||||
await loadData();
|
||||
showAlert('groups-alert', 'Группировка сохранена', 'success');
|
||||
} catch (error) {
|
||||
showAlert('groups-alert', error.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('groups-delete').addEventListener('click', async () => {
|
||||
const groupId = document.getElementById('group-id').value;
|
||||
if (!groupId) {
|
||||
showAlert('groups-alert', 'Сначала выберите группировку для удаления');
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`Удалить группировку #${groupId}?`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await requestJson(`${apiBase}/groups/${groupId}`, { method: 'DELETE' });
|
||||
resetForm();
|
||||
await loadData();
|
||||
showAlert('groups-alert', 'Группировка удалена', 'success');
|
||||
} catch (error) {
|
||||
showAlert('groups-alert', error.message);
|
||||
}
|
||||
});
|
||||
|
||||
loadData().catch(error => showAlert('groups-alert', error.message));
|
||||
}
|
||||
|
||||
function initSatellitesPage() {
|
||||
const state = {
|
||||
summaries: [],
|
||||
selectedSatelliteId: null,
|
||||
selectedSatellite: null,
|
||||
selectedSatelliteInitialConditions: null,
|
||||
selectedSlotCalculationSummary: null,
|
||||
createDraftMode: false,
|
||||
slotSummariesBySatelliteId: new Map()
|
||||
};
|
||||
|
||||
const tableBody = document.getElementById('satellites-table-body');
|
||||
const searchInput = document.getElementById('satellite-search');
|
||||
|
||||
function updateColorPreview() {
|
||||
const red = Number(document.getElementById('satellite-red').value || 0);
|
||||
const green = Number(document.getElementById('satellite-green').value || 0);
|
||||
const blue = Number(document.getElementById('satellite-blue').value || 0);
|
||||
document.getElementById('satellite-color-preview').style.background = `rgb(${red}, ${green}, ${blue})`;
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
const query = searchInput.value.trim().toLowerCase();
|
||||
const items = state.summaries.filter(item =>
|
||||
!query ||
|
||||
String(item.id).includes(query) ||
|
||||
item.name.toLowerCase().includes(query) ||
|
||||
item.code.toLowerCase().includes(query)
|
||||
);
|
||||
|
||||
tableBody.innerHTML = '';
|
||||
if (!items.length) {
|
||||
tableBody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-4">Спутники не найдены</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
items.forEach(satellite => {
|
||||
const slotSummary = state.slotSummariesBySatelliteId.get(Number(satellite.id));
|
||||
const tr = document.createElement('tr');
|
||||
if (satellite.id === state.selectedSatelliteId) {
|
||||
tr.classList.add('table-active');
|
||||
}
|
||||
tr.innerHTML = `
|
||||
<td>${satellite.id}</td>
|
||||
<td>${satellite.code}</td>
|
||||
<td>${satellite.name}</td>
|
||||
<td>${satellite.typeCode}</td>
|
||||
<td>${renderSlotIndicator(slotSummary)}</td>
|
||||
`;
|
||||
tr.addEventListener('click', () => selectSatellite(satellite.id));
|
||||
tableBody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function renderSlotIndicator(summary) {
|
||||
const calculated = Boolean(summary?.calculated);
|
||||
const title = calculated
|
||||
? 'Рассчитанные слоты есть'
|
||||
: 'Рассчитанные слоты отсутствуют';
|
||||
return `<span class="catalog-slot-indicator ${calculated ? 'catalog-slot-indicator-active' : ''}" title="${title}"></span>`;
|
||||
}
|
||||
|
||||
function renderSlotCalculationSummary(summary) {
|
||||
const container = document.getElementById('slot-calculation-summary');
|
||||
if (!container) return;
|
||||
|
||||
if (!summary?.calculated) {
|
||||
container.innerHTML = `
|
||||
<div class="catalog-empty mb-0">
|
||||
Рассчитанные слоты отсутствуют.
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="catalog-section-title mb-3">Рассчитанные слоты</div>
|
||||
<div class="catalog-slot-summary-grid">
|
||||
<div>
|
||||
<div class="catalog-summary-label">Минимальное время</div>
|
||||
<div class="catalog-summary-value">${formatDisplayDateTime(summary.minTime)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="catalog-summary-label">Максимальное время</div>
|
||||
<div class="catalog-summary-value">${formatDisplayDateTime(summary.maxTime)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="catalog-summary-label">Интервал витков</div>
|
||||
<div class="catalog-summary-value">${summary.revolutionInterval ?? '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function resetBaseForm() {
|
||||
document.getElementById('satellite-form').reset();
|
||||
document.getElementById('satellite-id').disabled = false;
|
||||
document.getElementById('satellite-active').checked = true;
|
||||
document.getElementById('satellite-scan-tle').checked = false;
|
||||
document.getElementById('satellite-red').value = 255;
|
||||
document.getElementById('satellite-green').value = 0;
|
||||
document.getElementById('satellite-blue').value = 0;
|
||||
document.getElementById('satellite-form-title').textContent = 'Новый спутник';
|
||||
state.selectedSatelliteId = null;
|
||||
state.selectedSatellite = null;
|
||||
state.selectedSatelliteInitialConditions = null;
|
||||
state.selectedSlotCalculationSummary = null;
|
||||
state.createDraftMode = false;
|
||||
clearProfiles();
|
||||
updateColorPreview();
|
||||
renderList();
|
||||
}
|
||||
|
||||
function clearProfiles() {
|
||||
document.getElementById('observation-form').reset();
|
||||
document.getElementById('slot-form').reset();
|
||||
document.getElementById('slot-ic-form').reset();
|
||||
document.getElementById('slot-default-angles').value = '';
|
||||
document.getElementById('slot-tn-calc').value = '';
|
||||
document.getElementById('slot-ic-time').value = '';
|
||||
document.getElementById('slot-ic-status').textContent = 'Начальные условия не заданы.';
|
||||
renderSlotCalculationSummary(null);
|
||||
}
|
||||
|
||||
function fillProfiles(satellite, initialConditions, slotCalculationSummary) {
|
||||
const observation = satellite.observationProfile;
|
||||
document.getElementById('observation-capture-angle').value = observation?.captureAngle ?? '';
|
||||
document.getElementById('observation-sun-angle-min').value = observation?.sunAngleMin ?? '';
|
||||
document.getElementById('observation-duration-min').value = observation?.durationMinSeconds ?? '';
|
||||
document.getElementById('observation-duration-max').value = observation?.durationMaxSeconds ?? '';
|
||||
document.getElementById('observation-mmi').value = observation?.mmiSeconds ?? '';
|
||||
document.getElementById('observation-daily-max').value = observation?.dailyMaxDurationSeconds ?? '';
|
||||
document.getElementById('observation-revolution-max').value = observation?.revolutionMaxDurationSeconds ?? '';
|
||||
|
||||
const slotProfile = satellite.slotProfile;
|
||||
document.getElementById('slot-cycle-revs').value = slotProfile?.cycleRevs ?? '';
|
||||
document.getElementById('slot-tn-calc').value = formatDateTimeInput(slotProfile?.tnCalc);
|
||||
document.getElementById('slot-duration').value = slotProfile?.slotDuration ?? '';
|
||||
document.getElementById('slot-duration-calc-days').value = slotProfile?.durationCalcDays ?? '';
|
||||
document.getElementById('slot-max-chain-length').value = slotProfile?.maxChainLength ?? '';
|
||||
document.getElementById('slot-default-angles').value = slotProfile?.defaultAngles?.map(angle => `${angle.angleBegin}, ${angle.angleEnd}`).join('\n') ?? '';
|
||||
|
||||
document.getElementById('slot-ic-time').value = formatDateTimeInput(initialConditions?.ic?.orbPoint?.time);
|
||||
document.getElementById('slot-ic-revolution').value = initialConditions?.ic?.orbPoint?.revolution ?? '';
|
||||
document.getElementById('slot-ic-s-ball').value = initialConditions?.ic?.sBall ?? '';
|
||||
document.getElementById('slot-ic-f81').value = initialConditions?.ic?.f81 ?? '';
|
||||
document.getElementById('slot-ic-x').value = initialConditions?.ic?.orbPoint?.x ?? '';
|
||||
document.getElementById('slot-ic-y').value = initialConditions?.ic?.orbPoint?.y ?? '';
|
||||
document.getElementById('slot-ic-z').value = initialConditions?.ic?.orbPoint?.z ?? '';
|
||||
document.getElementById('slot-ic-vx').value = initialConditions?.ic?.orbPoint?.vx ?? '';
|
||||
document.getElementById('slot-ic-vy').value = initialConditions?.ic?.orbPoint?.vy ?? '';
|
||||
document.getElementById('slot-ic-vz').value = initialConditions?.ic?.orbPoint?.vz ?? '';
|
||||
document.getElementById('slot-ic-status').textContent = initialConditions
|
||||
? 'Начальные условия загружены из slot-service.'
|
||||
: 'Начальные условия не заданы.';
|
||||
renderSlotCalculationSummary(slotCalculationSummary);
|
||||
}
|
||||
|
||||
function fillSatelliteForm(satellite, createMode = false, initialConditions = state.selectedSatelliteInitialConditions, slotCalculationSummary = state.selectedSlotCalculationSummary) {
|
||||
document.getElementById('satellite-id').value = createMode ? '' : satellite.id;
|
||||
document.getElementById('satellite-id').disabled = !createMode;
|
||||
document.getElementById('satellite-norad-id').value = satellite.noradId ?? '';
|
||||
document.getElementById('satellite-code').value = satellite.code;
|
||||
document.getElementById('satellite-name').value = satellite.name;
|
||||
document.getElementById('satellite-type-code').value = satellite.typeCode;
|
||||
document.getElementById('satellite-active').checked = satellite.active;
|
||||
document.getElementById('satellite-scan-tle').checked = satellite.scanTle;
|
||||
document.getElementById('satellite-red').value = satellite.visualization.red;
|
||||
document.getElementById('satellite-green').value = satellite.visualization.green;
|
||||
document.getElementById('satellite-blue').value = satellite.visualization.blue;
|
||||
document.getElementById('satellite-form-title').textContent = createMode ? 'Новый спутник' : `Спутник #${satellite.id}`;
|
||||
fillProfiles(
|
||||
satellite,
|
||||
initialConditions,
|
||||
slotCalculationSummary
|
||||
);
|
||||
updateColorPreview();
|
||||
}
|
||||
|
||||
function newSatelliteFromSelected() {
|
||||
if (!state.selectedSatellite) {
|
||||
resetBaseForm();
|
||||
return;
|
||||
}
|
||||
|
||||
state.selectedSatelliteId = null;
|
||||
state.selectedSatellite = null;
|
||||
state.selectedSatelliteInitialConditions = null;
|
||||
state.selectedSlotCalculationSummary = null;
|
||||
state.createDraftMode = true;
|
||||
document.getElementById('satellite-id').value = '';
|
||||
document.getElementById('satellite-id').disabled = false;
|
||||
document.getElementById('satellite-form-title').textContent = 'Новый спутник';
|
||||
document.getElementById('slot-ic-status').textContent = 'Начальные условия скопированы из выбранного спутника.';
|
||||
renderSlotCalculationSummary(null);
|
||||
renderList();
|
||||
}
|
||||
|
||||
async function loadSummaries() {
|
||||
state.summaries = await requestJson(`${apiBase}/satellites`);
|
||||
const satelliteIds = state.summaries.map(satellite => satellite.id);
|
||||
renderList();
|
||||
|
||||
try {
|
||||
const slotSummaries = satelliteIds.length
|
||||
? await requestJson(`${apiBase}/satellites/slot-calculation-summaries?${buildRepeatedQueryParam('satelliteIds', satelliteIds)}`)
|
||||
: [];
|
||||
state.slotSummariesBySatelliteId = new Map(
|
||||
slotSummaries.map(summary => [Number(summary.satelliteId), summary])
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('Slot calculation summaries are unavailable', error);
|
||||
state.slotSummariesBySatelliteId = new Map();
|
||||
}
|
||||
renderList();
|
||||
}
|
||||
|
||||
async function selectSatellite(id) {
|
||||
try {
|
||||
const [satellite, initialConditions] = await Promise.all([
|
||||
requestJson(`${apiBase}/satellites/${id}`),
|
||||
requestJson(`${apiBase}/satellites/${id}/initial-conditions`)
|
||||
]);
|
||||
let slotCalculationSummary = null;
|
||||
try {
|
||||
slotCalculationSummary = await requestJson(`${apiBase}/satellites/${id}/slot-calculation-summary`);
|
||||
} catch (error) {
|
||||
console.warn(`Slot calculation summary is unavailable for satellite ${id}`, error);
|
||||
}
|
||||
state.selectedSatelliteId = id;
|
||||
state.selectedSatellite = satellite;
|
||||
state.selectedSatelliteInitialConditions = initialConditions;
|
||||
state.selectedSlotCalculationSummary = slotCalculationSummary;
|
||||
state.createDraftMode = false;
|
||||
state.slotSummariesBySatelliteId.set(Number(id), slotCalculationSummary);
|
||||
fillSatelliteForm(satellite);
|
||||
renderList();
|
||||
showAlert('satellites-alert', '');
|
||||
} catch (error) {
|
||||
showAlert('satellites-alert', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function basePayload() {
|
||||
return {
|
||||
noradId: toNullableNumber(document.getElementById('satellite-norad-id').value),
|
||||
code: document.getElementById('satellite-code').value.trim(),
|
||||
name: document.getElementById('satellite-name').value.trim(),
|
||||
typeCode: document.getElementById('satellite-type-code').value.trim(),
|
||||
active: document.getElementById('satellite-active').checked,
|
||||
scanTle: document.getElementById('satellite-scan-tle').checked,
|
||||
visualization: {
|
||||
red: Number(document.getElementById('satellite-red').value),
|
||||
green: Number(document.getElementById('satellite-green').value),
|
||||
blue: Number(document.getElementById('satellite-blue').value)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function observationPayload() {
|
||||
return {
|
||||
captureAngle: Number(document.getElementById('observation-capture-angle').value),
|
||||
sunAngleMin: Number(document.getElementById('observation-sun-angle-min').value),
|
||||
durationMinSeconds: Number(document.getElementById('observation-duration-min').value),
|
||||
durationMaxSeconds: Number(document.getElementById('observation-duration-max').value),
|
||||
mmiSeconds: Number(document.getElementById('observation-mmi').value),
|
||||
dailyMaxDurationSeconds: Number(document.getElementById('observation-daily-max').value),
|
||||
revolutionMaxDurationSeconds: Number(document.getElementById('observation-revolution-max').value)
|
||||
};
|
||||
}
|
||||
|
||||
function slotPayload() {
|
||||
return {
|
||||
cycleRevs: Number(document.getElementById('slot-cycle-revs').value),
|
||||
tnCalc: parseDateTimeInput(document.getElementById('slot-tn-calc').value, 'TN calc'),
|
||||
slotDuration: Number(document.getElementById('slot-duration').value),
|
||||
durationCalcDays: Number(document.getElementById('slot-duration-calc-days').value),
|
||||
maxChainLength: Number(document.getElementById('slot-max-chain-length').value),
|
||||
defaultAngles: parseAngles(document.getElementById('slot-default-angles').value)
|
||||
};
|
||||
}
|
||||
|
||||
function initialConditionsPayload() {
|
||||
return {
|
||||
orbPoint: {
|
||||
time: parseDateTimeInput(document.getElementById('slot-ic-time').value, 'Time', true),
|
||||
revolution: Number(document.getElementById('slot-ic-revolution').value),
|
||||
vx: Number(document.getElementById('slot-ic-vx').value),
|
||||
vy: Number(document.getElementById('slot-ic-vy').value),
|
||||
vz: Number(document.getElementById('slot-ic-vz').value),
|
||||
x: Number(document.getElementById('slot-ic-x').value),
|
||||
y: Number(document.getElementById('slot-ic-y').value),
|
||||
z: Number(document.getElementById('slot-ic-z').value)
|
||||
},
|
||||
sBall: Number(document.getElementById('slot-ic-s-ball').value),
|
||||
f81: Number(document.getElementById('slot-ic-f81').value)
|
||||
};
|
||||
}
|
||||
|
||||
document.getElementById('satellites-refresh').addEventListener('click', async () => {
|
||||
try {
|
||||
await loadSummaries();
|
||||
if (state.selectedSatelliteId) {
|
||||
await selectSatellite(state.selectedSatelliteId);
|
||||
}
|
||||
} catch (error) {
|
||||
showAlert('satellites-alert', error.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('satellites-new').addEventListener('click', () => {
|
||||
newSatelliteFromSelected();
|
||||
showAlert('satellites-alert', '');
|
||||
});
|
||||
|
||||
searchInput.addEventListener('input', renderList);
|
||||
['satellite-red', 'satellite-green', 'satellite-blue'].forEach(id => {
|
||||
document.getElementById(id).addEventListener('input', updateColorPreview);
|
||||
});
|
||||
|
||||
document.getElementById('satellite-form').addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
const idField = document.getElementById('satellite-id');
|
||||
const isCreate = !state.selectedSatelliteId;
|
||||
const satelliteId = Number(idField.value);
|
||||
|
||||
const result = isCreate
|
||||
? await requestJson(`${apiBase}/satellites`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ id: satelliteId, ...basePayload() })
|
||||
})
|
||||
: await requestJson(`${apiBase}/satellites/${satelliteId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(basePayload())
|
||||
});
|
||||
|
||||
await loadSummaries();
|
||||
if (isCreate && state.createDraftMode) {
|
||||
state.selectedSatelliteId = result.id;
|
||||
state.selectedSatellite = result;
|
||||
state.selectedSatelliteInitialConditions = null;
|
||||
state.selectedSlotCalculationSummary = null;
|
||||
document.getElementById('satellite-id').value = result.id;
|
||||
document.getElementById('satellite-id').disabled = true;
|
||||
document.getElementById('satellite-form-title').textContent = `Спутник #${result.id}`;
|
||||
renderList();
|
||||
} else {
|
||||
await selectSatellite(result.id);
|
||||
}
|
||||
showAlert('satellites-alert', 'Спутник сохранён', 'success');
|
||||
} catch (error) {
|
||||
showAlert('satellites-alert', error.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('satellite-delete').addEventListener('click', async () => {
|
||||
if (!state.selectedSatelliteId) {
|
||||
showAlert('satellites-alert', 'Сначала выберите спутник для удаления');
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`Удалить спутник #${state.selectedSatelliteId}?`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await requestJson(`${apiBase}/satellites/${state.selectedSatelliteId}`, { method: 'DELETE' });
|
||||
await loadSummaries();
|
||||
resetBaseForm();
|
||||
showAlert('satellites-alert', 'Спутник удалён', 'success');
|
||||
} catch (error) {
|
||||
showAlert('satellites-alert', error.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('observation-form').addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
if (!state.selectedSatelliteId) {
|
||||
showAlert('satellites-alert', 'Сначала сохраните спутник');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const create = !state.selectedSatellite?.observationProfile;
|
||||
await requestJson(`${apiBase}/satellites/${state.selectedSatelliteId}/observation-profile?create=${create}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(observationPayload())
|
||||
});
|
||||
if (state.createDraftMode) {
|
||||
state.selectedSatellite = {
|
||||
...state.selectedSatellite,
|
||||
observationProfile: observationPayload()
|
||||
};
|
||||
} else {
|
||||
await selectSatellite(state.selectedSatelliteId);
|
||||
}
|
||||
showAlert('satellites-alert', 'Observation profile сохранён', 'success');
|
||||
} catch (error) {
|
||||
showAlert('satellites-alert', error.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('observation-delete').addEventListener('click', async () => {
|
||||
if (!state.selectedSatelliteId || !state.selectedSatellite?.observationProfile) {
|
||||
showAlert('satellites-alert', 'Observation profile отсутствует');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await requestJson(`${apiBase}/satellites/${state.selectedSatelliteId}/observation-profile`, { method: 'DELETE' });
|
||||
await selectSatellite(state.selectedSatelliteId);
|
||||
showAlert('satellites-alert', 'Observation profile удалён', 'success');
|
||||
} catch (error) {
|
||||
showAlert('satellites-alert', error.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('slot-form').addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
if (!state.selectedSatelliteId) {
|
||||
showAlert('satellites-alert', 'Сначала сохраните спутник');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const create = !state.selectedSatellite?.slotProfile;
|
||||
await requestJson(`${apiBase}/satellites/${state.selectedSatelliteId}/slot-profile?create=${create}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(slotPayload())
|
||||
});
|
||||
if (state.createDraftMode) {
|
||||
state.selectedSatellite = {
|
||||
...state.selectedSatellite,
|
||||
slotProfile: slotPayload()
|
||||
};
|
||||
} else {
|
||||
await selectSatellite(state.selectedSatelliteId);
|
||||
}
|
||||
showAlert('satellites-alert', 'Slot profile сохранён', 'success');
|
||||
} catch (error) {
|
||||
showAlert('satellites-alert', error.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('slot-delete').addEventListener('click', async () => {
|
||||
if (!state.selectedSatelliteId || !state.selectedSatellite?.slotProfile) {
|
||||
showAlert('satellites-alert', 'Slot profile отсутствует');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await requestJson(`${apiBase}/satellites/${state.selectedSatelliteId}/slot-profile`, { method: 'DELETE' });
|
||||
await selectSatellite(state.selectedSatelliteId);
|
||||
showAlert('satellites-alert', 'Slot profile удалён', 'success');
|
||||
} catch (error) {
|
||||
showAlert('satellites-alert', error.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('slot-calculate').addEventListener('click', async () => {
|
||||
if (!state.selectedSatelliteId) {
|
||||
showAlert('satellites-alert', 'Сначала сохраните спутник');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await requestJson(`${apiBase}/satellites/${state.selectedSatelliteId}/slot-calculation`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(slotPayload())
|
||||
});
|
||||
await loadSummaries();
|
||||
await selectSatellite(state.selectedSatelliteId);
|
||||
showAlert('satellites-alert', 'Расчет слотов запущен', 'success');
|
||||
} catch (error) {
|
||||
showAlert('satellites-alert', error.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('slot-ic-form').addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
if (!state.selectedSatelliteId) {
|
||||
showAlert('satellites-alert', 'Сначала сохраните спутник');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const create = !state.selectedSatelliteInitialConditions;
|
||||
await requestJson(`${apiBase}/satellites/${state.selectedSatelliteId}/initial-conditions?create=${create}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(initialConditionsPayload())
|
||||
});
|
||||
if (state.createDraftMode) {
|
||||
state.selectedSatelliteInitialConditions = {
|
||||
satelliteId: state.selectedSatelliteId,
|
||||
ic: initialConditionsPayload()
|
||||
};
|
||||
document.getElementById('slot-ic-status').textContent = 'Начальные условия сохранены в slot-service.';
|
||||
} else {
|
||||
await selectSatellite(state.selectedSatelliteId);
|
||||
}
|
||||
showAlert('satellites-alert', 'Начальные условия сохранены', 'success');
|
||||
} catch (error) {
|
||||
showAlert('satellites-alert', error.message);
|
||||
}
|
||||
});
|
||||
|
||||
loadSummaries()
|
||||
.then(() => {
|
||||
updateColorPreview();
|
||||
if (state.summaries.length) {
|
||||
return selectSatellite(state.summaries[0].id);
|
||||
}
|
||||
resetBaseForm();
|
||||
return null;
|
||||
})
|
||||
.catch(error => showAlert('satellites-alert', error.message));
|
||||
}
|
||||
|
||||
if (page === 'groups') {
|
||||
initGroupsPage();
|
||||
}
|
||||
|
||||
if (page === 'satellites') {
|
||||
initSatellitesPage();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,654 @@
|
||||
|
||||
var MNTH = ["Янв","Фев","Мар","Апр","Май","Июн","Июл","Авг","Сен","Окт","Ноя","Дек"];
|
||||
var pcpCesiumToolsState = null;
|
||||
/** преобразование целого числа к строке заданного формата
|
||||
s - число (25)
|
||||
count - размер строки (4)
|
||||
результат - строка (0025)
|
||||
*/
|
||||
function STRTOFORMAT(s, count)
|
||||
{
|
||||
var s2 = String(s);
|
||||
var len = s2.length;
|
||||
if (len < count)
|
||||
{
|
||||
for (iSTRTOFORMAT=0; iSTRTOFORMAT<count-len; iSTRTOFORMAT++)
|
||||
s2 = "0"+s2;
|
||||
}
|
||||
return s2;
|
||||
}
|
||||
|
||||
|
||||
/** получение строки декретного московского времени
|
||||
*/
|
||||
var TDMT = function GetDMTime(e,t)
|
||||
{
|
||||
var d = new Cesium.JulianDate();
|
||||
Cesium.JulianDate.addHours(e,3,d)
|
||||
var gregorian = new Cesium.JulianDate.toGregorianDate(d);
|
||||
//var s = new String();
|
||||
if (Math.abs(t._clockViewModel.multiplier)<1)
|
||||
s = STRTOFORMAT(gregorian.hour,2) + ':' + STRTOFORMAT(gregorian.minute,2) + ':' + STRTOFORMAT(gregorian.second,2)+'.'+STRTOFORMAT(Math.round(gregorian.millisecond))
|
||||
else
|
||||
s = STRTOFORMAT(gregorian.hour,2) + ':' + STRTOFORMAT(gregorian.minute,2) + ':' + STRTOFORMAT(gregorian.second,2)+' ДМВ';
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
/** получение строки декретной московской даты
|
||||
*/
|
||||
var TDMD = function GetDMDate(e)
|
||||
{
|
||||
var d = new Cesium.JulianDate();
|
||||
Cesium.JulianDate.addHours(e,3,d)
|
||||
var gregorian = new Cesium.JulianDate.toGregorianDate(d);
|
||||
|
||||
s = STRTOFORMAT(gregorian.day,2)+ ' ' + MNTH[gregorian.month-1] + ' ' + STRTOFORMAT(gregorian.year,4);
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
/** получение строки декретного московского даты и времени
|
||||
*/
|
||||
var TDMDT = function GetDMDdateTime(e)
|
||||
{
|
||||
s = "";
|
||||
var d = new Cesium.JulianDate();
|
||||
Cesium.JulianDate.addHours(e,3,d);
|
||||
var gregorian = new Cesium.JulianDate.toGregorianDate(d);
|
||||
s = gregorian.day+" "+ MNTH[gregorian.month-1]+" "+ gregorian.year+" "+STRTOFORMAT(gregorian.hour,2)+":"+STRTOFORMAT(gregorian.minute,2)+":"+STRTOFORMAT(gregorian.second,2)+" ДМВ";
|
||||
return s;
|
||||
}
|
||||
|
||||
|
||||
|
||||
function formatDistanceMeters(distanceMeters) {
|
||||
if (!Cesium.defined(distanceMeters) || !isFinite(distanceMeters)) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
if (distanceMeters < 1000) {
|
||||
return distanceMeters.toFixed(1) + " м";
|
||||
}
|
||||
|
||||
return (distanceMeters / 1000).toFixed(3) + " км";
|
||||
}
|
||||
|
||||
function getMapCartesianPosition(viewer, screenPosition) {
|
||||
if (!viewer || !screenPosition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var scene = viewer.scene;
|
||||
var ray = viewer.camera.getPickRay(screenPosition);
|
||||
if (ray) {
|
||||
var globePoint = scene.globe.pick(ray, scene);
|
||||
if (Cesium.defined(globePoint)) {
|
||||
return globePoint;
|
||||
}
|
||||
}
|
||||
|
||||
var ellipsoidPoint = viewer.camera.pickEllipsoid(screenPosition, scene.globe.ellipsoid);
|
||||
return Cesium.defined(ellipsoidPoint) ? ellipsoidPoint : null;
|
||||
}
|
||||
|
||||
function computeSurfaceDistanceMeters(viewer, pointA, pointB) {
|
||||
if (!viewer || !Cesium.defined(pointA) || !Cesium.defined(pointB)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
var ellipsoid = viewer.scene.globe.ellipsoid;
|
||||
var cartographicA = ellipsoid.cartesianToCartographic(pointA);
|
||||
var cartographicB = ellipsoid.cartesianToCartographic(pointB);
|
||||
if (!Cesium.defined(cartographicA) || !Cesium.defined(cartographicB)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
var geodesic = new Cesium.EllipsoidGeodesic(cartographicA, cartographicB);
|
||||
return geodesic.surfaceDistance || 0;
|
||||
}
|
||||
|
||||
function getMidpoint(pointA, pointB) {
|
||||
if (!Cesium.defined(pointA) || !Cesium.defined(pointB)) {
|
||||
return Cesium.Cartesian3.ZERO;
|
||||
}
|
||||
|
||||
return Cesium.Cartesian3.midpoint(pointA, pointB, new Cesium.Cartesian3());
|
||||
}
|
||||
|
||||
function formatDegree(value, positiveSuffix, negativeSuffix) {
|
||||
if (!isFinite(value)) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
var suffix = value >= 0 ? positiveSuffix : negativeSuffix;
|
||||
return Math.abs(value).toFixed(6) + '° ' + suffix;
|
||||
}
|
||||
|
||||
function formatCursorCoordinates(viewer, cartesian) {
|
||||
if (!viewer || !Cesium.defined(cartesian)) {
|
||||
return 'Координаты: -';
|
||||
}
|
||||
|
||||
var ellipsoid = viewer.scene.globe.ellipsoid;
|
||||
var cartographic = ellipsoid.cartesianToCartographic(cartesian);
|
||||
if (!Cesium.defined(cartographic)) {
|
||||
return 'Координаты: -';
|
||||
}
|
||||
|
||||
var lon = Cesium.Math.toDegrees(cartographic.longitude);
|
||||
var lat = Cesium.Math.toDegrees(cartographic.latitude);
|
||||
return 'Lon: ' + formatDegree(lon, 'E', 'W') + ' | Lat: ' + formatDegree(lat, 'N', 'S');
|
||||
}
|
||||
|
||||
function setRulerInfoText(text) {
|
||||
return;
|
||||
}
|
||||
|
||||
function getRulerDisplayPositions(ruler) {
|
||||
var positions = [];
|
||||
if (!ruler || !ruler.points) {
|
||||
return positions;
|
||||
}
|
||||
|
||||
for (var i = 0; i < ruler.points.length; i++) {
|
||||
positions.push(ruler.points[i]);
|
||||
}
|
||||
|
||||
if (ruler.points.length > 0 && ruler.tempPoint) {
|
||||
var lastFixed = ruler.points[ruler.points.length - 1];
|
||||
if (!Cesium.Cartesian3.equalsEpsilon(lastFixed, ruler.tempPoint, Cesium.Math.EPSILON7, Cesium.Math.EPSILON7)) {
|
||||
positions.push(ruler.tempPoint);
|
||||
}
|
||||
}
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
function computePolylineDistanceMeters(viewer, positions) {
|
||||
if (!viewer || !positions || positions.length < 2) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
var total = 0;
|
||||
for (var i = 1; i < positions.length; i++) {
|
||||
total += computeSurfaceDistanceMeters(viewer, positions[i - 1], positions[i]);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function addRulerVertexMarker(viewer, position, isFirstPoint) {
|
||||
return viewer.entities.add({
|
||||
name: 'pcp-ruler-vertex',
|
||||
position: position,
|
||||
point: {
|
||||
pixelSize: 8,
|
||||
color: isFirstPoint ? Cesium.Color.LIME : Cesium.Color.RED,
|
||||
outlineColor: Cesium.Color.WHITE,
|
||||
outlineWidth: 2,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateCursorCoordinatesText(viewer, screenPosition) {
|
||||
if (!pcpCesiumToolsState || !pcpCesiumToolsState.cursorCoordsPanel) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pcpCesiumToolsState.cursorCoordsEnabled) {
|
||||
pcpCesiumToolsState.cursorCoordsPanel.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
var point = getMapCartesianPosition(viewer, screenPosition);
|
||||
pcpCesiumToolsState.cursorCoordsPanel.textContent = formatCursorCoordinates(viewer, point);
|
||||
}
|
||||
|
||||
function updateButtonActiveState(button, active) {
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (active) {
|
||||
button.classList.add('active');
|
||||
button.style.background = '#0d6efd';
|
||||
button.style.borderColor = '#0d6efd';
|
||||
button.style.color = '#ffffff';
|
||||
} else {
|
||||
button.classList.remove('active');
|
||||
button.style.background = '';
|
||||
button.style.borderColor = '';
|
||||
button.style.color = '';
|
||||
}
|
||||
}
|
||||
|
||||
function clearRulerMeasurement(viewer) {
|
||||
if (!pcpCesiumToolsState || !pcpCesiumToolsState.ruler) {
|
||||
return;
|
||||
}
|
||||
|
||||
var ruler = pcpCesiumToolsState.ruler;
|
||||
if (ruler.lineEntity) {
|
||||
viewer.entities.remove(ruler.lineEntity);
|
||||
ruler.lineEntity = null;
|
||||
}
|
||||
if (ruler.labelEntity) {
|
||||
viewer.entities.remove(ruler.labelEntity);
|
||||
ruler.labelEntity = null;
|
||||
}
|
||||
if (ruler.pointEntities && ruler.pointEntities.length) {
|
||||
for (var i = 0; i < ruler.pointEntities.length; i++) {
|
||||
viewer.entities.remove(ruler.pointEntities[i]);
|
||||
}
|
||||
}
|
||||
|
||||
ruler.pointEntities = [];
|
||||
ruler.points = [];
|
||||
ruler.tempPoint = null;
|
||||
ruler.completed = false;
|
||||
}
|
||||
|
||||
function ensureRulerEntities(viewer) {
|
||||
var ruler = pcpCesiumToolsState.ruler;
|
||||
if (ruler.lineEntity) {
|
||||
return;
|
||||
}
|
||||
|
||||
ruler.lineEntity = viewer.entities.add({
|
||||
name: 'pcp-ruler-line',
|
||||
polyline: {
|
||||
positions: new Cesium.CallbackProperty(function () {
|
||||
return getRulerDisplayPositions(ruler);
|
||||
}, false),
|
||||
width: 3,
|
||||
material: Cesium.Color.CYAN
|
||||
}
|
||||
});
|
||||
|
||||
ruler.labelEntity = viewer.entities.add({
|
||||
name: 'pcp-ruler-label',
|
||||
position: new Cesium.CallbackProperty(function () {
|
||||
var positions = getRulerDisplayPositions(ruler);
|
||||
if (!positions.length) {
|
||||
return Cesium.Cartesian3.ZERO;
|
||||
}
|
||||
return positions[positions.length - 1];
|
||||
}, false),
|
||||
label: {
|
||||
text: new Cesium.CallbackProperty(function () {
|
||||
var positions = getRulerDisplayPositions(ruler);
|
||||
if (positions.length < 2) {
|
||||
return '';
|
||||
}
|
||||
var distance = computePolylineDistanceMeters(viewer, positions);
|
||||
return 'Σ ' + formatDistanceMeters(distance);
|
||||
}, false),
|
||||
showBackground: true,
|
||||
backgroundColor: Cesium.Color.BLACK.withAlpha(0.75),
|
||||
fillColor: Cesium.Color.WHITE,
|
||||
font: '14px sans-serif',
|
||||
pixelOffset: new Cesium.Cartesian2(10, -10),
|
||||
horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
|
||||
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateRulerDistanceInfo(viewer) {
|
||||
return;
|
||||
}
|
||||
|
||||
function startRulerMeasurement(viewer, point) {
|
||||
var ruler = pcpCesiumToolsState.ruler;
|
||||
|
||||
clearRulerMeasurement(viewer);
|
||||
ensureRulerEntities(viewer);
|
||||
|
||||
ruler.points = [Cesium.Cartesian3.clone(point)];
|
||||
ruler.tempPoint = Cesium.Cartesian3.clone(point);
|
||||
ruler.completed = false;
|
||||
ruler.pointEntities = [addRulerVertexMarker(viewer, ruler.points[0], true)];
|
||||
|
||||
updateRulerDistanceInfo(viewer);
|
||||
}
|
||||
|
||||
function completeRulerMeasurement(viewer, point) {
|
||||
var ruler = pcpCesiumToolsState.ruler;
|
||||
|
||||
var fixedPoint = Cesium.Cartesian3.clone(point);
|
||||
ruler.points.push(fixedPoint);
|
||||
ruler.tempPoint = Cesium.Cartesian3.clone(point);
|
||||
ruler.completed = false;
|
||||
if (!ruler.pointEntities) {
|
||||
ruler.pointEntities = [];
|
||||
}
|
||||
ruler.pointEntities.push(addRulerVertexMarker(viewer, fixedPoint, false));
|
||||
|
||||
updateRulerDistanceInfo(viewer);
|
||||
}
|
||||
|
||||
function disableRuler(viewer) {
|
||||
if (!pcpCesiumToolsState || !pcpCesiumToolsState.ruler) {
|
||||
return;
|
||||
}
|
||||
|
||||
var ruler = pcpCesiumToolsState.ruler;
|
||||
ruler.enabled = false;
|
||||
|
||||
if (ruler.handler) {
|
||||
ruler.handler.destroy();
|
||||
ruler.handler = null;
|
||||
}
|
||||
|
||||
clearRulerMeasurement(viewer);
|
||||
updateButtonActiveState(pcpCesiumToolsState.rulerButton, false);
|
||||
viewer.container.style.cursor = '';
|
||||
viewer.scene.requestRender();
|
||||
}
|
||||
|
||||
function enableRuler(viewer) {
|
||||
if (!pcpCesiumToolsState || !pcpCesiumToolsState.ruler) {
|
||||
return;
|
||||
}
|
||||
|
||||
var ruler = pcpCesiumToolsState.ruler;
|
||||
if (ruler.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
ruler.enabled = true;
|
||||
clearRulerMeasurement(viewer);
|
||||
|
||||
ruler.handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
|
||||
ruler.handler.setInputAction(function (movement) {
|
||||
var point = getMapCartesianPosition(viewer, movement.position);
|
||||
if (!point) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ruler.points || ruler.points.length === 0) {
|
||||
startRulerMeasurement(viewer, point);
|
||||
return;
|
||||
}
|
||||
|
||||
completeRulerMeasurement(viewer, point);
|
||||
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||
|
||||
ruler.handler.setInputAction(function (movement) {
|
||||
if (!ruler.points || ruler.points.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
var point = getMapCartesianPosition(viewer, movement.endPosition);
|
||||
if (!point) {
|
||||
return;
|
||||
}
|
||||
|
||||
ruler.tempPoint = Cesium.Cartesian3.clone(point);
|
||||
updateRulerDistanceInfo(viewer);
|
||||
viewer.scene.requestRender();
|
||||
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
|
||||
|
||||
ruler.handler.setInputAction(function () {
|
||||
clearRulerMeasurement(viewer);
|
||||
updateRulerDistanceInfo(viewer);
|
||||
viewer.scene.requestRender();
|
||||
}, Cesium.ScreenSpaceEventType.RIGHT_CLICK);
|
||||
|
||||
updateButtonActiveState(pcpCesiumToolsState.rulerButton, true);
|
||||
viewer.container.style.cursor = 'crosshair';
|
||||
viewer.scene.requestRender();
|
||||
}
|
||||
|
||||
function toggleRuler(viewer) {
|
||||
if (!pcpCesiumToolsState || !pcpCesiumToolsState.ruler) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pcpCesiumToolsState.ruler.enabled) {
|
||||
disableRuler(viewer);
|
||||
} else {
|
||||
enableRuler(viewer);
|
||||
}
|
||||
}
|
||||
|
||||
function setSunLightingEnabled(viewer, enabled) {
|
||||
if (!pcpCesiumToolsState) {
|
||||
return;
|
||||
}
|
||||
|
||||
pcpCesiumToolsState.sunLightingEnabled = enabled;
|
||||
viewer.scene.globe.enableLighting = enabled;
|
||||
viewer.scene.globe.dynamicAtmosphereLighting = enabled;
|
||||
viewer.scene.globe.dynamicAtmosphereLightingFromSun = enabled;
|
||||
|
||||
updateButtonActiveState(pcpCesiumToolsState.sunButton, enabled);
|
||||
if (pcpCesiumToolsState.sunButton) {
|
||||
pcpCesiumToolsState.sunButton.title = enabled
|
||||
? 'Освещенность Солнцем: включена'
|
||||
: 'Освещенность Солнцем: выключена';
|
||||
}
|
||||
|
||||
viewer.scene.requestRender();
|
||||
}
|
||||
|
||||
function toggleSunLighting(viewer) {
|
||||
if (!pcpCesiumToolsState) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSunLightingEnabled(viewer, !pcpCesiumToolsState.sunLightingEnabled);
|
||||
}
|
||||
|
||||
function setCursorCoordinatesEnabled(viewer, enabled) {
|
||||
if (!pcpCesiumToolsState) {
|
||||
return;
|
||||
}
|
||||
|
||||
pcpCesiumToolsState.cursorCoordsEnabled = enabled;
|
||||
updateButtonActiveState(pcpCesiumToolsState.cursorCoordsButton, enabled);
|
||||
|
||||
if (pcpCesiumToolsState.cursorCoordsPanel) {
|
||||
pcpCesiumToolsState.cursorCoordsPanel.style.display = enabled ? 'block' : 'none';
|
||||
pcpCesiumToolsState.cursorCoordsPanel.textContent = enabled
|
||||
? 'Координаты: наведите курсор на карту'
|
||||
: '';
|
||||
}
|
||||
|
||||
viewer.scene.requestRender();
|
||||
}
|
||||
|
||||
function toggleCursorCoordinates(viewer) {
|
||||
if (!pcpCesiumToolsState) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCursorCoordinatesEnabled(viewer, !pcpCesiumToolsState.cursorCoordsEnabled);
|
||||
}
|
||||
|
||||
function createCesiumToolsWidget(viewer) {
|
||||
if (pcpCesiumToolsState && pcpCesiumToolsState.toolbarEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
var viewerToolbar = viewer.container.querySelector('.cesium-viewer-toolbar');
|
||||
if (!viewerToolbar) {
|
||||
return;
|
||||
}
|
||||
|
||||
var rulerButton = document.createElement('button');
|
||||
rulerButton.type = 'button';
|
||||
rulerButton.className = 'cesium-button cesium-toolbar-button';
|
||||
rulerButton.title = 'Включить/выключить линейку';
|
||||
rulerButton.style.marginLeft = '4px';
|
||||
rulerButton.style.width = '32px';
|
||||
rulerButton.style.height = '32px';
|
||||
rulerButton.style.display = 'inline-flex';
|
||||
rulerButton.style.alignItems = 'center';
|
||||
rulerButton.style.justifyContent = 'center';
|
||||
rulerButton.innerHTML = '<i class="bi bi-rulers"></i>';
|
||||
|
||||
var coordsButton = document.createElement('button');
|
||||
coordsButton.type = 'button';
|
||||
coordsButton.className = 'cesium-button cesium-toolbar-button';
|
||||
coordsButton.title = 'Включить/выключить отображение координат под курсором';
|
||||
coordsButton.style.marginLeft = '4px';
|
||||
coordsButton.style.width = '32px';
|
||||
coordsButton.style.height = '32px';
|
||||
coordsButton.style.display = 'inline-flex';
|
||||
coordsButton.style.alignItems = 'center';
|
||||
coordsButton.style.justifyContent = 'center';
|
||||
coordsButton.innerHTML = '<img alt="Координаты" ' +
|
||||
'src="data:image/svg+xml;utf8,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 24 24%27 fill=%27none%27 stroke=%27%23ffffff%27 stroke-width=%272%27 stroke-linecap=%27round%27 stroke-linejoin=%27round%27%3E%3Ccircle cx=%2712%27 cy=%2712%27 r=%274%27/%3E%3Cpath d=%27M12 2v4M12 18v4M2 12h4M18 12h4%27/%3E%3C/svg%3E" ' +
|
||||
'style="width:16px;height:16px;display:block;">';
|
||||
|
||||
var sunButton = document.createElement('button');
|
||||
sunButton.type = 'button';
|
||||
sunButton.className = 'cesium-button cesium-toolbar-button';
|
||||
sunButton.title = 'Включить/выключить освещенность Солнцем';
|
||||
sunButton.style.marginLeft = '4px';
|
||||
sunButton.style.width = '32px';
|
||||
sunButton.style.height = '32px';
|
||||
sunButton.style.display = 'inline-flex';
|
||||
sunButton.style.alignItems = 'center';
|
||||
sunButton.style.justifyContent = 'center';
|
||||
sunButton.innerHTML = '<i class="bi bi-sun"></i>';
|
||||
|
||||
var cursorCoordsPanel = document.createElement('div');
|
||||
cursorCoordsPanel.className = 'pcp-cursor-coords-panel';
|
||||
cursorCoordsPanel.style.position = 'absolute';
|
||||
cursorCoordsPanel.style.top = '50px';
|
||||
cursorCoordsPanel.style.right = '10px';
|
||||
cursorCoordsPanel.style.zIndex = '1000';
|
||||
cursorCoordsPanel.style.maxWidth = '520px';
|
||||
cursorCoordsPanel.style.padding = '6px 10px';
|
||||
cursorCoordsPanel.style.background = 'rgba(0, 0, 0, 0.75)';
|
||||
cursorCoordsPanel.style.color = '#ffffff';
|
||||
cursorCoordsPanel.style.borderRadius = '4px';
|
||||
cursorCoordsPanel.style.fontSize = '12px';
|
||||
cursorCoordsPanel.style.lineHeight = '1.3';
|
||||
cursorCoordsPanel.style.display = 'none';
|
||||
cursorCoordsPanel.style.pointerEvents = 'none';
|
||||
cursorCoordsPanel.textContent = '';
|
||||
|
||||
viewerToolbar.appendChild(rulerButton);
|
||||
viewerToolbar.appendChild(coordsButton);
|
||||
viewerToolbar.appendChild(sunButton);
|
||||
viewer.cesiumWidget.container.appendChild(cursorCoordsPanel);
|
||||
|
||||
pcpCesiumToolsState = {
|
||||
toolbarEl: viewerToolbar,
|
||||
rulerButton: rulerButton,
|
||||
cursorCoordsButton: coordsButton,
|
||||
cursorCoordsPanel: cursorCoordsPanel,
|
||||
sunButton: sunButton,
|
||||
cursorCoordsEnabled: false,
|
||||
sunLightingEnabled: false,
|
||||
ruler: {
|
||||
enabled: false,
|
||||
handler: null,
|
||||
points: [],
|
||||
tempPoint: null,
|
||||
completed: false,
|
||||
lineEntity: null,
|
||||
labelEntity: null,
|
||||
pointEntities: []
|
||||
}
|
||||
};
|
||||
|
||||
rulerButton.addEventListener('click', function () {
|
||||
toggleRuler(viewer);
|
||||
});
|
||||
|
||||
coordsButton.addEventListener('click', function () {
|
||||
toggleCursorCoordinates(viewer);
|
||||
});
|
||||
|
||||
sunButton.addEventListener('click', function () {
|
||||
toggleSunLighting(viewer);
|
||||
});
|
||||
|
||||
viewer.scene.canvas.addEventListener('mousemove', function (event) {
|
||||
if (!pcpCesiumToolsState || !pcpCesiumToolsState.cursorCoordsEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
var rect = viewer.scene.canvas.getBoundingClientRect();
|
||||
var screenPosition = new Cesium.Cartesian2(
|
||||
event.clientX - rect.left,
|
||||
event.clientY - rect.top
|
||||
);
|
||||
updateCursorCoordinatesText(viewer, screenPosition);
|
||||
});
|
||||
|
||||
viewer.scene.canvas.addEventListener('mouseleave', function () {
|
||||
if (!pcpCesiumToolsState || !pcpCesiumToolsState.cursorCoordsEnabled || !pcpCesiumToolsState.cursorCoordsPanel) {
|
||||
return;
|
||||
}
|
||||
pcpCesiumToolsState.cursorCoordsPanel.textContent = 'Координаты: курсор вне карты';
|
||||
});
|
||||
}
|
||||
|
||||
function initializeCesium() {
|
||||
// Задаем область по умолчанию
|
||||
var ext = Cesium.Rectangle.fromDegrees(351, 72.6, 76.3, 31.4);
|
||||
Cesium.Camera.DEFAULT_VIEW_RECTANGLE = ext;
|
||||
var imageryProviderViewModels = Cesium.createDefaultImageryProviderViewModels();
|
||||
var arcGisWorldImagery = imageryProviderViewModels.find(function (viewModel) {
|
||||
return viewModel && viewModel.name && viewModel.name.indexOf('ArcGIS World Imagery') !== -1;
|
||||
}) || imageryProviderViewModels[0];
|
||||
|
||||
// Создаем viewer
|
||||
var viewer = new Cesium.Viewer('cesiumContainer', {
|
||||
baseLayerPicker: true,
|
||||
imageryProviderViewModels: imageryProviderViewModels,
|
||||
selectedImageryProviderViewModel: arcGisWorldImagery,
|
||||
geocoder: false,
|
||||
infoBox: true,
|
||||
navigationHelpButton: false,
|
||||
sceneMode: Cesium.SceneMode.SCENE2D,
|
||||
// Оптимизация для полного заполнения контейнера
|
||||
useDefaultRenderLoop: true,
|
||||
orderIndependentTranslucency: false,
|
||||
contextOptions: {
|
||||
webgl: {
|
||||
alpha: false,
|
||||
antialias: true,
|
||||
preserveDrawingBuffer: true,
|
||||
failIfMajorPerformanceCaveat: false
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var scene = viewer.scene;
|
||||
|
||||
|
||||
// Настройка форматирования времени
|
||||
if (viewer.animation && viewer.animation.viewModel) {
|
||||
viewer.animation.viewModel.timeFormatter = TDMT;
|
||||
viewer.animation.viewModel.dateFormatter = TDMD;
|
||||
}
|
||||
|
||||
if (viewer.timeline) {
|
||||
viewer.timeline.makeLabel = TDMDT;
|
||||
}
|
||||
|
||||
// Обработчик изменения размера окна
|
||||
window.addEventListener('resize', function() {
|
||||
viewer.resize();
|
||||
});
|
||||
|
||||
// Принудительно вызываем resize после создания viewer
|
||||
setTimeout(function() {
|
||||
viewer.resize();
|
||||
}, 100);
|
||||
|
||||
createCesiumToolsWidget(viewer);
|
||||
setSunLightingEnabled(viewer, false);
|
||||
|
||||
// Сохраняем viewer в глобальной области видимости для доступа из других функций
|
||||
window.viewer = viewer;
|
||||
window.heatmapLayer = null; // Здесь будет храниться слой тепловой карты
|
||||
}
|
||||
@@ -0,0 +1,752 @@
|
||||
(function () {
|
||||
const apiBase = '/api/com-plan/runs';
|
||||
const state = {
|
||||
runs: [],
|
||||
selectedRunId: null,
|
||||
selectedRun: null,
|
||||
selectedRunModes: null,
|
||||
selectedRunModesError: null,
|
||||
page: 1,
|
||||
pageSize: 10
|
||||
};
|
||||
|
||||
function text(value, fallback = '') {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return fallback;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return text(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function showAlert(message, type = 'danger') {
|
||||
const container = document.getElementById('complex-plan-alert');
|
||||
container.innerHTML = message
|
||||
? `<div class="alert alert-${type} mb-3" role="alert">${escapeHtml(message)}</div>`
|
||||
: '';
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '-';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return date.toLocaleString('ru-RU');
|
||||
}
|
||||
|
||||
function formatDuration(value) {
|
||||
if (value === null || value === undefined) return '-';
|
||||
const ms = Number(value);
|
||||
if (!Number.isFinite(ms)) return text(value, '-');
|
||||
const seconds = Math.round(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainder = seconds % 60;
|
||||
return minutes > 0 ? `${minutes} мин ${remainder} с` : `${remainder} с`;
|
||||
}
|
||||
|
||||
function formatList(values) {
|
||||
return Array.isArray(values) && values.length ? values.join(', ') : '-';
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) return '-';
|
||||
return number.toLocaleString('ru-RU', { maximumFractionDigits: 4 });
|
||||
}
|
||||
|
||||
function formatPercent(value) {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) return '-';
|
||||
return `${number.toLocaleString('ru-RU', { maximumFractionDigits: 2 })}%`;
|
||||
}
|
||||
|
||||
function isoDateTime(value) {
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? String(value) : date.toISOString();
|
||||
}
|
||||
|
||||
function formatDateTimeLocal(value) {
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return String(value).slice(0, 16);
|
||||
}
|
||||
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
async function requestJson(url, options) {
|
||||
const response = await fetch(url, options);
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isJson = response.headers.get('content-type')?.includes('application/json');
|
||||
const body = isJson ? await response.json() : await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
if (body && typeof body === 'object') {
|
||||
throw new Error(body.error || body.message || Object.values(body).join('; '));
|
||||
}
|
||||
throw new Error(body || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
function renderRuns() {
|
||||
const body = document.getElementById('complex-plan-runs-body');
|
||||
const meta = document.getElementById('complex-plan-list-meta');
|
||||
const pagination = document.getElementById('complex-plan-pagination');
|
||||
|
||||
if (!state.runs.length) {
|
||||
body.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-5">Расчеты не найдены</td></tr>';
|
||||
meta.textContent = 'Нет расчетов';
|
||||
pagination.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(state.runs.length / state.pageSize));
|
||||
state.page = Math.min(Math.max(1, state.page), totalPages);
|
||||
const pageStart = (state.page - 1) * state.pageSize;
|
||||
const pageRuns = state.runs.slice(pageStart, pageStart + state.pageSize);
|
||||
|
||||
meta.textContent = `Всего расчетов: ${state.runs.length}`;
|
||||
body.innerHTML = pageRuns.map(run => {
|
||||
const runId = run.runId ?? run.id;
|
||||
const activeClass = String(runId) === String(state.selectedRunId) ? ' active' : '';
|
||||
const interval = `${formatDateTime(run.intervalStart)} - ${formatDateTime(run.intervalEnd)}`;
|
||||
return `
|
||||
<tr class="complex-plan-run-row${activeClass}" data-run-id="${escapeHtml(runId)}">
|
||||
<td>${escapeHtml(runId)}</td>
|
||||
<td>${escapeHtml(run.status)}</td>
|
||||
<td>${escapeHtml(interval)}</td>
|
||||
<td>${escapeHtml(formatList(run.satelliteIds))}</td>
|
||||
<td>
|
||||
<button type="button"
|
||||
class="btn btn-outline-danger btn-sm complex-plan-delete-btn"
|
||||
data-delete-run-id="${escapeHtml(runId)}">
|
||||
Удалить
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
body.querySelectorAll('.complex-plan-run-row').forEach(row => {
|
||||
row.addEventListener('click', () => selectRun(row.dataset.runId));
|
||||
});
|
||||
body.querySelectorAll('[data-delete-run-id]').forEach(button => {
|
||||
button.addEventListener('click', event => {
|
||||
event.stopPropagation();
|
||||
deleteRun(button.dataset.deleteRunId);
|
||||
});
|
||||
});
|
||||
|
||||
pagination.innerHTML = `
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" id="complex-plan-page-prev" ${state.page <= 1 ? 'disabled' : ''}>
|
||||
Назад
|
||||
</button>
|
||||
<span class="complex-plan-helper">Страница ${state.page} из ${totalPages}</span>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" id="complex-plan-page-next" ${state.page >= totalPages ? 'disabled' : ''}>
|
||||
Вперед
|
||||
</button>
|
||||
`;
|
||||
document.getElementById('complex-plan-page-prev')?.addEventListener('click', () => {
|
||||
state.page -= 1;
|
||||
renderRuns();
|
||||
});
|
||||
document.getElementById('complex-plan-page-next')?.addEventListener('click', () => {
|
||||
state.page += 1;
|
||||
renderRuns();
|
||||
});
|
||||
}
|
||||
|
||||
function field(label, value) {
|
||||
return `
|
||||
<div class="complex-plan-field">
|
||||
<div class="complex-plan-field-label">${escapeHtml(label)}</div>
|
||||
<div class="complex-plan-field-value">${escapeHtml(value)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function isSurveyMode(mode) {
|
||||
return !mode.type || String(mode.type).toUpperCase() === 'SURVEY';
|
||||
}
|
||||
|
||||
function isAddedSurveyMode(mode) {
|
||||
const source = String(mode.source || '').toUpperCase();
|
||||
return isSurveyMode(mode) && (source === 'COMPLAN' || source === 'MIXED');
|
||||
}
|
||||
|
||||
function addedSurveyRowsBySatellite(run, modes) {
|
||||
const countsBySatelliteId = new Map();
|
||||
modes
|
||||
.filter(isAddedSurveyMode)
|
||||
.forEach(mode => {
|
||||
if (mode.satelliteId === null || mode.satelliteId === undefined || mode.satelliteId === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = String(mode.satelliteId);
|
||||
countsBySatelliteId.set(key, (countsBySatelliteId.get(key) || 0) + 1);
|
||||
});
|
||||
|
||||
const satelliteIds = [...new Set([
|
||||
...(Array.isArray(run.satelliteIds) ? run.satelliteIds : []),
|
||||
...modes.map(mode => mode.satelliteId)
|
||||
].filter(id => id !== null && id !== undefined && id !== ''))]
|
||||
.sort((left, right) => Number(left) - Number(right));
|
||||
|
||||
return satelliteIds.map(satelliteId => [
|
||||
satelliteId,
|
||||
countsBySatelliteId.get(String(satelliteId)) || 0
|
||||
]);
|
||||
}
|
||||
|
||||
function renderAddedSurveyStats(run) {
|
||||
if (state.selectedRunModesError) {
|
||||
return '<div class="text-muted">Не удалось загрузить съемки расчета</div>';
|
||||
}
|
||||
|
||||
if (state.selectedRunModes === null) {
|
||||
return '<div class="text-muted">Загрузка съемок...</div>';
|
||||
}
|
||||
|
||||
const rows = addedSurveyRowsBySatellite(run, state.selectedRunModes);
|
||||
if (!rows.length) {
|
||||
return '<div class="text-muted">Нет данных по спутникам</div>';
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="complex-plan-added-surveys">
|
||||
<table class="table table-sm align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Спутник</th>
|
||||
<th class="text-end">Добавлено съемок</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows.map(([satelliteId, count]) => `
|
||||
<tr>
|
||||
<td>${escapeHtml(satelliteId)}</td>
|
||||
<td class="text-end">${escapeHtml(count)}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderDetail(run) {
|
||||
const container = document.getElementById('complex-plan-detail');
|
||||
const meta = document.getElementById('complex-plan-detail-meta');
|
||||
|
||||
if (!run) {
|
||||
meta.textContent = 'Выберите расчет слева.';
|
||||
container.innerHTML = '<div class="text-muted py-5 text-center">Нет выбранного расчета</div>';
|
||||
document.getElementById('complex-plan-report').disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const runId = run.runId ?? run.id;
|
||||
meta.textContent = `Расчет #${runId}`;
|
||||
document.getElementById('complex-plan-report').disabled = false;
|
||||
const requestPayload = formatJsonText(run.requestPayload);
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="complex-plan-detail-grid mb-4">
|
||||
${field('ID', runId)}
|
||||
${field('Статус', run.status)}
|
||||
${field('Начало интервала', formatDateTime(run.intervalStart))}
|
||||
${field('Конец интервала', formatDateTime(run.intervalEnd))}
|
||||
${field('Спутники', formatList(run.satelliteIds))}
|
||||
${field('Инициатор', text(run.requestedBy, '-'))}
|
||||
${field('Создан', formatDateTime(run.createdAt))}
|
||||
${field('Запущен', formatDateTime(run.startedAt))}
|
||||
${field('Завершен', formatDateTime(run.finishedAt))}
|
||||
${field('Длительность', formatDuration(run.durationMs))}
|
||||
${field('Ошибка', text(run.errorMessage, '-'))}
|
||||
${field('Snapshot IDs', formatList(run.snapshotIds))}
|
||||
${field('Calculated modes', run.calculatedModesCount ?? 0)}
|
||||
${field('Booked modes', run.bookedModesCount ?? 0)}
|
||||
${field('Complan modes', run.complanModesCount ?? 0)}
|
||||
${field('Mixed modes', run.mixedModesCount ?? 0)}
|
||||
${field('Affected satellites', run.affectedSatellitesCount ?? 0)}
|
||||
${field('Processed cells', run.processedCellsCount ?? 0)}
|
||||
${field('Booked slot links', run.bookedSlotLinksCount ?? 0)}
|
||||
${field('Процент покрытия', formatPercent(run.coveredAreaPercent))}
|
||||
</div>
|
||||
<div class="complex-plan-section-title mb-2">Добавлено съемок по спутникам</div>
|
||||
<div class="mb-4">${renderAddedSurveyStats(run)}</div>
|
||||
<div class="complex-plan-section-title mb-2">Request payload</div>
|
||||
<pre class="complex-plan-json mb-0">${escapeHtml(requestPayload || '-')}</pre>
|
||||
`;
|
||||
}
|
||||
|
||||
function formatJsonText(value) {
|
||||
if (!value) return '';
|
||||
if (typeof value === 'object') {
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(value), null, 2);
|
||||
} catch (error) {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function parseRequestPayload(value) {
|
||||
if (!value) return {};
|
||||
if (typeof value === 'object') return value;
|
||||
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (error) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function fillProcessForm(run) {
|
||||
const payload = parseRequestPayload(run.requestPayload);
|
||||
const intervalStart = payload.intervalStart || run.intervalStart;
|
||||
const intervalEnd = payload.intervalEnd || run.intervalEnd;
|
||||
const satelliteIds = Array.isArray(payload.satelliteIds) && payload.satelliteIds.length
|
||||
? payload.satelliteIds
|
||||
: (Array.isArray(run.satelliteIds) ? run.satelliteIds : []);
|
||||
|
||||
document.getElementById('complex-plan-interval-start').value = formatDateTimeLocal(intervalStart);
|
||||
document.getElementById('complex-plan-interval-end').value = formatDateTimeLocal(intervalEnd);
|
||||
document.getElementById('complex-plan-satellite-ids').value = satelliteIds.join(', ');
|
||||
document.getElementById('complex-plan-sun').value = payload.sun === null || payload.sun === undefined
|
||||
? ''
|
||||
: payload.sun;
|
||||
document.getElementById('complex-plan-count-lat').value = payload.countLat === null || payload.countLat === undefined
|
||||
? ''
|
||||
: payload.countLat;
|
||||
document.getElementById('complex-plan-count-long').value = payload.countLong === null || payload.countLong === undefined
|
||||
? ''
|
||||
: payload.countLong;
|
||||
|
||||
const coverageSourceInput = document.getElementById('complex-plan-coverage-source');
|
||||
if (payload.coverageSource && Array.from(coverageSourceInput.options).some(option => option.value === payload.coverageSource)) {
|
||||
coverageSourceInput.value = payload.coverageSource;
|
||||
} else {
|
||||
coverageSourceInput.value = 'SLOTS';
|
||||
}
|
||||
|
||||
const coverageStrategyInput = document.getElementById('complex-plan-coverage-strategy');
|
||||
if (payload.coverageStrategy && Array.from(coverageStrategyInput.options).some(option => option.value === payload.coverageStrategy)) {
|
||||
coverageStrategyInput.value = payload.coverageStrategy;
|
||||
} else {
|
||||
coverageStrategyInput.value = 'GREEDY';
|
||||
}
|
||||
}
|
||||
|
||||
async function selectRun(runId) {
|
||||
if (!runId) return;
|
||||
|
||||
state.selectedRunId = runId;
|
||||
state.selectedRunModes = null;
|
||||
state.selectedRunModesError = null;
|
||||
renderRuns();
|
||||
renderDetail(null);
|
||||
document.getElementById('complex-plan-detail-meta').textContent = `Загрузка расчета #${runId}...`;
|
||||
|
||||
try {
|
||||
const run = await requestJson(`${apiBase}/${encodeURIComponent(runId)}`);
|
||||
state.selectedRun = run;
|
||||
renderDetail(run);
|
||||
fillProcessForm(run);
|
||||
showAlert('');
|
||||
|
||||
try {
|
||||
const modes = await requestJson(`${apiBase}/${encodeURIComponent(runId)}/modes`);
|
||||
if (String(state.selectedRunId) !== String(runId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.selectedRunModes = Array.isArray(modes) ? modes : [];
|
||||
state.selectedRunModesError = null;
|
||||
} catch (error) {
|
||||
if (String(state.selectedRunId) !== String(runId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.selectedRunModes = [];
|
||||
state.selectedRunModesError = error;
|
||||
}
|
||||
renderDetail(run);
|
||||
} catch (error) {
|
||||
state.selectedRun = null;
|
||||
state.selectedRunModes = null;
|
||||
state.selectedRunModesError = null;
|
||||
showAlert(error.message || 'Не удалось загрузить расчет');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRun(runId) {
|
||||
if (!runId) return;
|
||||
const confirmed = window.confirm(`Удалить версию расчета #${runId}?`);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await requestJson(`${apiBase}/${encodeURIComponent(runId)}`, { method: 'DELETE' });
|
||||
if (String(state.selectedRunId) === String(runId)) {
|
||||
state.selectedRunId = null;
|
||||
state.selectedRun = null;
|
||||
state.selectedRunModes = null;
|
||||
state.selectedRunModesError = null;
|
||||
renderDetail(null);
|
||||
}
|
||||
await loadRuns();
|
||||
showAlert(`Версия расчета #${runId} удалена`, 'success');
|
||||
} catch (error) {
|
||||
showAlert(error.message || 'Не удалось удалить расчет');
|
||||
}
|
||||
}
|
||||
|
||||
function parseSatelliteIds(value) {
|
||||
return value
|
||||
.split(/[,\s;]+/)
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean)
|
||||
.map(item => Number(item))
|
||||
.filter(item => Number.isFinite(item));
|
||||
}
|
||||
|
||||
function parseOptionalPositiveInt(value) {
|
||||
const parsedValue = Number.parseInt(String(value || '').trim(), 10);
|
||||
return Number.isFinite(parsedValue) && parsedValue > 0 ? parsedValue : null;
|
||||
}
|
||||
|
||||
async function submitProcessForm(event) {
|
||||
event.preventDefault();
|
||||
const submitButton = document.getElementById('complex-plan-process-submit');
|
||||
const intervalStart = document.getElementById('complex-plan-interval-start').value;
|
||||
const intervalEnd = document.getElementById('complex-plan-interval-end').value;
|
||||
const satelliteIds = parseSatelliteIds(document.getElementById('complex-plan-satellite-ids').value);
|
||||
const sunValue = document.getElementById('complex-plan-sun').value.trim();
|
||||
const coverageSource = document.getElementById('complex-plan-coverage-source').value;
|
||||
const coverageStrategy = document.getElementById('complex-plan-coverage-strategy').value;
|
||||
const countLat = parseOptionalPositiveInt(document.getElementById('complex-plan-count-lat').value);
|
||||
const countLong = parseOptionalPositiveInt(document.getElementById('complex-plan-count-long').value);
|
||||
|
||||
if (!intervalStart || !intervalEnd || satelliteIds.length === 0) {
|
||||
showAlert('Заполните интервал и список Satellite IDs');
|
||||
return;
|
||||
}
|
||||
|
||||
submitButton.disabled = true;
|
||||
showAlert('');
|
||||
try {
|
||||
const response = await requestJson('/api/com-plan/process', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
intervalStart: intervalStart,
|
||||
intervalEnd: intervalEnd,
|
||||
satelliteId: null,
|
||||
satelliteIds: satelliteIds,
|
||||
sun: sunValue ? Number(sunValue) : null,
|
||||
coverageSource: coverageSource || null,
|
||||
countLat: countLat,
|
||||
countLong: countLong,
|
||||
coverageStrategy: coverageStrategy || null
|
||||
})
|
||||
});
|
||||
const runId = response?.runId ?? response?.id;
|
||||
await loadRuns(runId ? String(runId) : null);
|
||||
showAlert(`Расчет${runId ? ` #${runId}` : ''} запущен`, 'success');
|
||||
} catch (error) {
|
||||
showAlert(error.message || 'Не удалось запустить расчет');
|
||||
} finally {
|
||||
submitButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRuns(preferredRunId) {
|
||||
const refreshButton = document.getElementById('complex-plan-refresh');
|
||||
refreshButton.disabled = true;
|
||||
if (!preferredRunId) {
|
||||
showAlert('');
|
||||
}
|
||||
document.getElementById('complex-plan-list-meta').textContent = 'Загрузка...';
|
||||
|
||||
try {
|
||||
const runs = await requestJson(apiBase);
|
||||
state.runs = Array.isArray(runs) ? runs : [];
|
||||
const preferredId = preferredRunId || state.selectedRunId;
|
||||
const preferredRun = state.runs.find(run => String(run.runId ?? run.id) === String(preferredId));
|
||||
state.selectedRunId = preferredRun
|
||||
? String(preferredRun.runId ?? preferredRun.id)
|
||||
: (state.runs[0] ? String(state.runs[0].runId ?? state.runs[0].id) : null);
|
||||
if (state.selectedRunId) {
|
||||
const selectedIndex = state.runs.findIndex(run => String(run.runId ?? run.id) === String(state.selectedRunId));
|
||||
state.page = Math.floor(Math.max(0, selectedIndex) / state.pageSize) + 1;
|
||||
}
|
||||
renderRuns();
|
||||
if (state.selectedRunId) {
|
||||
await selectRun(state.selectedRunId);
|
||||
} else {
|
||||
state.selectedRun = null;
|
||||
renderDetail(null);
|
||||
}
|
||||
} catch (error) {
|
||||
state.runs = [];
|
||||
renderRuns();
|
||||
state.selectedRunModes = null;
|
||||
state.selectedRunModesError = null;
|
||||
renderDetail(null);
|
||||
showAlert(error.message || 'Не удалось загрузить расчеты');
|
||||
} finally {
|
||||
refreshButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function csvValue(value) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return String(value).replace('.', ',');
|
||||
}
|
||||
|
||||
const normalized = text(value, '')
|
||||
.replace(/\r?\n/g, ' ')
|
||||
.replace(/"/g, '""');
|
||||
return `"${normalized}"`;
|
||||
}
|
||||
|
||||
function csvRow(values) {
|
||||
return values.map(csvValue).join(';');
|
||||
}
|
||||
|
||||
function runDetailRows(run) {
|
||||
return [
|
||||
['ID', run.runId ?? run.id],
|
||||
['Статус', run.status],
|
||||
['Начало интервала', isoDateTime(run.intervalStart)],
|
||||
['Конец интервала', isoDateTime(run.intervalEnd)],
|
||||
['Спутники', formatList(run.satelliteIds)],
|
||||
['Инициатор', text(run.requestedBy, '-')],
|
||||
['Создан', isoDateTime(run.createdAt)],
|
||||
['Запущен', isoDateTime(run.startedAt)],
|
||||
['Завершен', isoDateTime(run.finishedAt)],
|
||||
['Длительность, мс', run.durationMs ?? ''],
|
||||
['Ошибка', text(run.errorMessage, '-')],
|
||||
['Snapshot IDs', formatList(run.snapshotIds)],
|
||||
['Calculated modes', run.calculatedModesCount ?? 0],
|
||||
['Booked modes', run.bookedModesCount ?? 0],
|
||||
['Complan modes', run.complanModesCount ?? 0],
|
||||
['Mixed modes', run.mixedModesCount ?? 0],
|
||||
['Affected satellites', run.affectedSatellitesCount ?? 0],
|
||||
['Processed cells', run.processedCellsCount ?? 0],
|
||||
['Booked slot links', run.bookedSlotLinksCount ?? 0],
|
||||
['Процент покрытия', Number(run.coveredAreaPercent) || 0]
|
||||
];
|
||||
}
|
||||
|
||||
function parseDate(value) {
|
||||
if (!value) return null;
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function intervalDurationDays(run) {
|
||||
const intervalStart = parseDate(run.intervalStart);
|
||||
const intervalEnd = parseDate(run.intervalEnd);
|
||||
if (!intervalStart || !intervalEnd || intervalEnd <= intervalStart) {
|
||||
return 0;
|
||||
}
|
||||
return (intervalEnd.getTime() - intervalStart.getTime()) / 86400000;
|
||||
}
|
||||
|
||||
function modeDurationSeconds(mode) {
|
||||
const duration = Number(mode.duration);
|
||||
if (Number.isFinite(duration) && duration > 0) {
|
||||
return duration;
|
||||
}
|
||||
|
||||
const startTime = parseDate(mode.startTime);
|
||||
const endTime = parseDate(mode.endTime);
|
||||
if (!startTime || !endTime || endTime <= startTime) {
|
||||
return 0;
|
||||
}
|
||||
return (endTime.getTime() - startTime.getTime()) / 1000;
|
||||
}
|
||||
|
||||
async function loadSatelliteDetails(satelliteIds) {
|
||||
const uniqueIds = [...new Set(satelliteIds.filter(id => id !== null && id !== undefined && id !== ''))];
|
||||
const entries = await Promise.all(uniqueIds.map(async satelliteId => {
|
||||
try {
|
||||
const satellite = await requestJson(`/api/catalog/satellites/${encodeURIComponent(satelliteId)}`);
|
||||
return [String(satelliteId), satellite];
|
||||
} catch (error) {
|
||||
return [String(satelliteId), null];
|
||||
}
|
||||
}));
|
||||
return new Map(entries);
|
||||
}
|
||||
|
||||
function satelliteLoadRows(run, modes, satellitesById) {
|
||||
const intervalDays = intervalDurationDays(run);
|
||||
const satelliteIds = [...new Set([
|
||||
...(Array.isArray(run.satelliteIds) ? run.satelliteIds : []),
|
||||
...modes.map(mode => mode.satelliteId)
|
||||
].filter(id => id !== null && id !== undefined && id !== ''))]
|
||||
.sort((left, right) => Number(left) - Number(right));
|
||||
|
||||
return satelliteIds.map(satelliteId => {
|
||||
const satellite = satellitesById.get(String(satelliteId));
|
||||
const dailyMaxDurationSeconds = Number(satellite?.observationProfile?.dailyMaxDurationSeconds);
|
||||
const totalSurveySeconds = modes
|
||||
.filter(mode => String(mode.satelliteId) === String(satelliteId))
|
||||
.filter(isSurveyMode)
|
||||
.reduce((sum, mode) => sum + modeDurationSeconds(mode), 0);
|
||||
const capacitySeconds = Number.isFinite(dailyMaxDurationSeconds) && dailyMaxDurationSeconds > 0
|
||||
? intervalDays * dailyMaxDurationSeconds
|
||||
: 0;
|
||||
const loadFactor = capacitySeconds > 0
|
||||
? (totalSurveySeconds / capacitySeconds) * 100
|
||||
: null;
|
||||
|
||||
return [
|
||||
Number(satelliteId),
|
||||
satellite?.name ?? '',
|
||||
intervalDays,
|
||||
Number.isFinite(dailyMaxDurationSeconds) ? dailyMaxDurationSeconds / 86400 : '',
|
||||
Number.isFinite(dailyMaxDurationSeconds) ? dailyMaxDurationSeconds : '',
|
||||
capacitySeconds,
|
||||
totalSurveySeconds,
|
||||
loadFactor === null ? '' : loadFactor
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function modeRows(modes) {
|
||||
return modes
|
||||
.slice()
|
||||
.sort((left, right) => String(left.startTime || '').localeCompare(String(right.startTime || '')))
|
||||
.map(mode => [
|
||||
mode.id ?? '',
|
||||
mode.satelliteId ?? '',
|
||||
mode.source ?? '',
|
||||
mode.type ?? '',
|
||||
isoDateTime(mode.startTime),
|
||||
isoDateTime(mode.endTime),
|
||||
mode.revolution ?? '',
|
||||
mode.roll ?? '',
|
||||
mode.lat ?? '',
|
||||
mode.longitude ?? '',
|
||||
mode.duration ?? '',
|
||||
Array.isArray(mode.cellNums) ? mode.cellNums.join(', ') : text(mode.cellNum, ''),
|
||||
Array.isArray(mode.bookedSlotIds) ? mode.bookedSlotIds.join(', ') : '',
|
||||
mode.snapshotId ?? '',
|
||||
mode.contourWkt ?? ''
|
||||
]);
|
||||
}
|
||||
|
||||
function downloadCsv(filename, rows) {
|
||||
const csv = '\uFEFF' + rows.map(csvRow).join('\n');
|
||||
const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8;' }));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function downloadReport() {
|
||||
const run = state.selectedRun;
|
||||
const runId = run?.runId ?? run?.id;
|
||||
if (!runId) {
|
||||
showAlert('Выберите расчет для формирования отчета');
|
||||
return;
|
||||
}
|
||||
|
||||
const reportButton = document.getElementById('complex-plan-report');
|
||||
reportButton.disabled = true;
|
||||
try {
|
||||
const modes = await requestJson(`${apiBase}/${encodeURIComponent(runId)}/modes`);
|
||||
const normalizedModes = Array.isArray(modes) ? modes : [];
|
||||
const satelliteIds = [
|
||||
...(Array.isArray(run.satelliteIds) ? run.satelliteIds : []),
|
||||
...normalizedModes.map(mode => mode.satelliteId)
|
||||
];
|
||||
const satellitesById = await loadSatelliteDetails(satelliteIds);
|
||||
const rows = [
|
||||
['Детальная информация о плане'],
|
||||
['Параметр', 'Значение'],
|
||||
...runDetailRows(run),
|
||||
[],
|
||||
['Добавлено съемок по спутникам'],
|
||||
['satelliteId', 'addedSurveyModesCount'],
|
||||
...addedSurveyRowsBySatellite(run, normalizedModes),
|
||||
[],
|
||||
['Сводная таблица по загруженности спутников'],
|
||||
[
|
||||
'satelliteId',
|
||||
'satelliteName',
|
||||
'calculationDurationDays',
|
||||
'dailyMaxDurationDays',
|
||||
'dailyMaxDurationSeconds',
|
||||
'availableDurationSeconds',
|
||||
'surveyModesDurationSeconds',
|
||||
'loadFactor'
|
||||
],
|
||||
...satelliteLoadRows(run, normalizedModes, satellitesById),
|
||||
[],
|
||||
['Режимы спутников'],
|
||||
[
|
||||
'modeId',
|
||||
'satelliteId',
|
||||
'source',
|
||||
'type',
|
||||
'startTime',
|
||||
'endTime',
|
||||
'revolution',
|
||||
'roll',
|
||||
'lat',
|
||||
'longitude',
|
||||
'duration',
|
||||
'cellNums',
|
||||
'bookedSlotIds',
|
||||
'snapshotId',
|
||||
'contourWkt'
|
||||
],
|
||||
...modeRows(normalizedModes)
|
||||
];
|
||||
downloadCsv(`complex-plan-${runId}.csv`, rows);
|
||||
showAlert(`Отчет по расчету #${runId} сформирован`, 'success');
|
||||
} catch (error) {
|
||||
showAlert(error.message || 'Не удалось сформировать отчет');
|
||||
} finally {
|
||||
reportButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('complex-plan-refresh').addEventListener('click', () => loadRuns());
|
||||
document.getElementById('complex-plan-process-form').addEventListener('submit', submitProcessForm);
|
||||
document.getElementById('complex-plan-report').addEventListener('click', downloadReport);
|
||||
loadRuns();
|
||||
})();
|
||||
@@ -0,0 +1,145 @@
|
||||
.catalog-page {
|
||||
min-height: calc(100vh - 6rem);
|
||||
}
|
||||
|
||||
.catalog-card {
|
||||
border: 1px solid #dbe5ef;
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 0.75rem 2rem rgba(15, 32, 55, 0.08);
|
||||
}
|
||||
|
||||
.catalog-card .card-header {
|
||||
background: linear-gradient(135deg, #edf5ff, #f7fbff);
|
||||
border-bottom: 1px solid #dbe5ef;
|
||||
border-radius: 1rem 1rem 0 0;
|
||||
}
|
||||
|
||||
.catalog-list {
|
||||
max-height: 68vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.catalog-list-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.catalog-list-card .catalog-list-search {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.catalog-list-card .catalog-list {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.catalog-list-card .catalog-list-page-scroll {
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.catalog-list .table tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.catalog-list .table tbody tr.table-active {
|
||||
--bs-table-bg: #e9f3ff;
|
||||
}
|
||||
|
||||
.catalog-section-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #52708f;
|
||||
}
|
||||
|
||||
.catalog-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.catalog-helper {
|
||||
color: #5e7288;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.catalog-checkbox-list {
|
||||
max-height: 20rem;
|
||||
overflow: auto;
|
||||
border: 1px solid #dbe5ef;
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: #fbfdff;
|
||||
}
|
||||
|
||||
.catalog-checkbox-list .form-check {
|
||||
padding: 0.35rem 0 0.35rem 1.8rem;
|
||||
}
|
||||
|
||||
.catalog-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
padding: 0.25rem 0.65rem;
|
||||
background: #eef6ff;
|
||||
color: #36516e;
|
||||
font-size: 0.85rem;
|
||||
margin: 0.15rem;
|
||||
}
|
||||
|
||||
.catalog-color-preview {
|
||||
width: 100%;
|
||||
height: 2.75rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid #dbe5ef;
|
||||
background: rgb(255, 0, 0);
|
||||
}
|
||||
|
||||
.catalog-empty {
|
||||
padding: 1rem;
|
||||
border: 1px dashed #bfd0e2;
|
||||
border-radius: 0.75rem;
|
||||
color: #5e7288;
|
||||
background: #fbfdff;
|
||||
}
|
||||
|
||||
.catalog-slot-indicator {
|
||||
display: inline-block;
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #9caec2;
|
||||
background: #e5ebf1;
|
||||
}
|
||||
|
||||
.catalog-slot-indicator-active {
|
||||
border-color: #248a55;
|
||||
background: #2fb66d;
|
||||
box-shadow: 0 0 0 0.2rem rgba(47, 182, 109, 0.16);
|
||||
}
|
||||
|
||||
.catalog-slot-summary {
|
||||
border-top: 1px solid #dbe5ef;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.catalog-slot-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.catalog-summary-label {
|
||||
color: #5e7288;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.catalog-summary-value {
|
||||
color: #24364a;
|
||||
font-size: 0.98rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
.complex-plan-page {
|
||||
min-height: calc(100vh - 6rem);
|
||||
}
|
||||
|
||||
.complex-plan-panel {
|
||||
border: 1px solid #dbe5ef;
|
||||
border-radius: 0.75rem;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 0.75rem 2rem rgba(15, 32, 55, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.complex-plan-panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
background: #f7fbff;
|
||||
border-bottom: 1px solid #dbe5ef;
|
||||
}
|
||||
|
||||
.complex-plan-section-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #52708f;
|
||||
}
|
||||
|
||||
.complex-plan-helper {
|
||||
color: #5e7288;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.complex-plan-table-wrap {
|
||||
max-height: calc(100vh - 24rem);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.complex-plan-table-wrap th,
|
||||
.complex-plan-table-wrap td {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.complex-plan-run-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.complex-plan-run-row.active td {
|
||||
background: #eef6ff;
|
||||
}
|
||||
|
||||
.complex-plan-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-top: 1px solid #dbe5ef;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.complex-plan-form {
|
||||
padding: 1rem 1.25rem 1.25rem;
|
||||
}
|
||||
|
||||
.complex-plan-delete-btn {
|
||||
padding: 0.15rem 0.45rem;
|
||||
}
|
||||
|
||||
.complex-plan-detail {
|
||||
padding: 1.25rem;
|
||||
min-height: 24rem;
|
||||
}
|
||||
|
||||
.complex-plan-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.complex-plan-field {
|
||||
border-bottom: 1px solid #eef4fb;
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.complex-plan-field-label {
|
||||
color: #6b7d90;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.complex-plan-field-value {
|
||||
font-weight: 600;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.complex-plan-json {
|
||||
background: #f7fbff;
|
||||
border: 1px solid #dbe5ef;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 18rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.complex-plan-added-surveys {
|
||||
border: 1px solid #dbe5ef;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.complex-plan-added-surveys th,
|
||||
.complex-plan-added-surveys td {
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.complex-plan-detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
.current-plans-page {
|
||||
height: calc(100vh - 4.25rem);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.current-plans-page > .d-flex:first-child {
|
||||
flex: 0 0 auto;
|
||||
margin-bottom: 0.75rem !important;
|
||||
}
|
||||
|
||||
#current-plans-alert {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.current-plans-layout {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
height: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.current-plans-layout > [class*="col-"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.current-plans-layout > [class*="col-"] > .catalog-card,
|
||||
.current-plans-layout > [class*="col-"] > .current-plans-main-column {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.current-plans-main-column {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.current-plans-sidebar,
|
||||
.current-plans-missions-card,
|
||||
.current-plans-modes-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.current-plans-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.current-plans-sidebar > .card-header,
|
||||
.current-plans-sidebar > .card-body:not(.catalog-list) {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.current-plans-sidebar .catalog-list {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.current-plans-missions-card {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: column;
|
||||
max-height: none;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.current-plans-missions-card .card-body {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.current-plans-missions-card .current-plans-table-wrap {
|
||||
max-height: 15rem;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.current-plans-modes-card {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.current-plans-modes-card .card-header {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.current-plans-modes-card .card-body {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.current-plans-modes-card .current-plans-table-wrap {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.current-plans-modes-card .catalog-empty {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 12rem;
|
||||
}
|
||||
|
||||
.current-plans-satellites-table tbody tr,
|
||||
#current-plans-missions-body tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.current-plans-selected-row > td {
|
||||
background: rgba(13, 110, 253, 0.12) !important;
|
||||
}
|
||||
|
||||
.current-plans-table-wrap {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.current-plans-pagination {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.current-plans-id {
|
||||
font-family: var(--bs-font-monospace);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.current-plans-main-text {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.current-plans-sub-text {
|
||||
color: #6c757d;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.current-plans-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
border: 1px solid rgba(108, 117, 125, 0.35);
|
||||
border-radius: 999px;
|
||||
padding: 0.1rem 0.45rem;
|
||||
font-size: 0.78rem;
|
||||
color: #495057;
|
||||
background: rgba(248, 249, 250, 0.9);
|
||||
}
|
||||
|
||||
.current-plans-mode-params {
|
||||
min-width: 11rem;
|
||||
}
|
||||
|
||||
#current-plans-modes-body tr.current-plans-mode-row-drop > td {
|
||||
background: rgba(25, 135, 84, 0.14) !important;
|
||||
}
|
||||
|
||||
#current-plans-modes-body tr.current-plans-mode-row-slots > td {
|
||||
background: rgba(255, 193, 7, 0.18) !important;
|
||||
}
|
||||
|
||||
#current-plans-modes-body tr.current-plans-mode-row-drop:hover > td {
|
||||
background: rgba(25, 135, 84, 0.22) !important;
|
||||
}
|
||||
|
||||
#current-plans-modes-body tr.current-plans-mode-row-slots:hover > td {
|
||||
background: rgba(255, 193, 7, 0.28) !important;
|
||||
}
|
||||
|
||||
.current-plans-badge-drop {
|
||||
border-color: rgba(25, 135, 84, 0.45);
|
||||
color: #0f5132;
|
||||
background: rgba(25, 135, 84, 0.14);
|
||||
}
|
||||
|
||||
.current-plans-badge-slots {
|
||||
border-color: rgba(255, 193, 7, 0.55);
|
||||
color: #664d03;
|
||||
background: rgba(255, 193, 7, 0.22);
|
||||
}
|
||||
|
||||
@media (max-width: 1199.98px) {
|
||||
.current-plans-page {
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
overflow: visible;
|
||||
padding-bottom: 1rem !important;
|
||||
}
|
||||
|
||||
.current-plans-layout {
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.current-plans-layout > [class*="col-"] {
|
||||
display: block;
|
||||
min-height: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.current-plans-main-column,
|
||||
.current-plans-sidebar,
|
||||
.current-plans-missions-card,
|
||||
.current-plans-modes-card,
|
||||
.current-plans-missions-card .card-body,
|
||||
.current-plans-modes-card .card-body {
|
||||
height: auto;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.current-plans-missions-card {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.current-plans-sidebar .catalog-list,
|
||||
.current-plans-table-wrap,
|
||||
.current-plans-missions-card .current-plans-table-wrap,
|
||||
.current-plans-modes-card .current-plans-table-wrap {
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.current-plans-actions-col {
|
||||
min-width: 13rem;
|
||||
}
|
||||
|
||||
.current-plans-actions-cell {
|
||||
min-width: 13rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.current-plans-action-btn + .current-plans-action-btn {
|
||||
margin-left: 0.35rem;
|
||||
}
|
||||
|
||||
#current-plans-create-modal .form-label {
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
.dynamic-plan-page {
|
||||
min-height: calc(100vh - 6rem);
|
||||
}
|
||||
|
||||
.dynamic-plan-panel {
|
||||
border: 1px solid #dbe5ef;
|
||||
border-radius: 0.5rem;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 0.75rem 2rem rgba(15, 32, 55, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dynamic-plan-panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
background: #f7fbff;
|
||||
border-bottom: 1px solid #dbe5ef;
|
||||
}
|
||||
|
||||
.dynamic-plan-section-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #52708f;
|
||||
}
|
||||
|
||||
.dynamic-plan-helper {
|
||||
color: #5e7288;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.dynamic-plan-form,
|
||||
.dynamic-plan-result {
|
||||
padding: 1rem 1.25rem 1.25rem;
|
||||
}
|
||||
|
||||
.dynamic-plan-runs {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.dynamic-plan-mode-group {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.dynamic-plan-mode-group .btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dynamic-plan-revolution-group {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.dynamic-plan-revolution-group .btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dynamic-plan-run-item {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
border: 1px solid #e5edf6;
|
||||
border-radius: 0.5rem;
|
||||
background: #ffffff;
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dynamic-plan-run-item + .dynamic-plan-run-item {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.dynamic-plan-run-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dynamic-plan-run-title {
|
||||
font-weight: 700;
|
||||
color: #2c4057;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.dynamic-plan-run-details {
|
||||
margin-top: 0.25rem;
|
||||
color: #63788f;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.dynamic-plan-run-status {
|
||||
white-space: nowrap;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
color: #52708f;
|
||||
}
|
||||
|
||||
.dynamic-plan-result-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.dynamic-plan-map-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
border: 1px solid #dbe5ef;
|
||||
border-radius: 0.5rem;
|
||||
background: #f7fbff;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.dynamic-plan-map-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.dynamic-plan-field {
|
||||
border-bottom: 1px solid #eef4fb;
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.dynamic-plan-field-label {
|
||||
color: #6b7d90;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.dynamic-plan-field-value {
|
||||
font-weight: 600;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.dynamic-plan-json {
|
||||
background: #f7fbff;
|
||||
border: 1px solid #dbe5ef;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 18rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dynamic-plan-result-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dynamic-plan-map-controls {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dynamic-plan-map-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
.group-state-sidebar .catalog-list {
|
||||
max-height: 76vh;
|
||||
}
|
||||
|
||||
.group-state-interval-cell {
|
||||
min-width: 16rem;
|
||||
font-size: 0.9rem;
|
||||
color: #42576d;
|
||||
}
|
||||
|
||||
.group-state-time-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 14rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.group-state-table-wrap {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.group-state-matrix-table {
|
||||
min-width: 48rem;
|
||||
}
|
||||
|
||||
.group-state-matrix-table thead th {
|
||||
min-width: 12rem;
|
||||
background: #f6faff;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
.group-state-matrix-table tbody th {
|
||||
min-width: 12rem;
|
||||
background: #f9fbfe;
|
||||
}
|
||||
|
||||
.group-state-matrix-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
font-size: 0.9rem;
|
||||
color: #344b63;
|
||||
}
|
||||
|
||||
.group-state-matrix-diagonal {
|
||||
text-align: center;
|
||||
color: #7c8ea1;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.group-state-chart-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.group-state-chart-card {
|
||||
border: 1px solid #dbe5ef;
|
||||
border-radius: 1rem;
|
||||
padding: 1rem;
|
||||
background: linear-gradient(180deg, #fbfdff, #f4f9ff);
|
||||
}
|
||||
|
||||
.group-state-chart-title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: #3e5c7a;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.group-state-chart-svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.group-state-chart-ring {
|
||||
fill: none;
|
||||
stroke: #c8d7e8;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.group-state-chart-inner {
|
||||
fill: #ffffff;
|
||||
stroke: #e6eef7;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.group-state-chart-center {
|
||||
font-size: 0.9rem;
|
||||
fill: #53708d;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.group-state-chart-center-value {
|
||||
font-size: 1.5rem;
|
||||
fill: #1f3650;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.group-state-chart-legend {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.group-state-legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
font-size: 0.9rem;
|
||||
color: #3d536b;
|
||||
}
|
||||
|
||||
.group-state-legend-color {
|
||||
width: 0.9rem;
|
||||
height: 0.9rem;
|
||||
border-radius: 999px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
@media (max-width: 991.98px) {
|
||||
.group-state-time-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.group-state-interval-cell {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.group-state-matrix-table {
|
||||
min-width: 36rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,732 @@
|
||||
|
||||
.main-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 700px;
|
||||
min-width: 700px;
|
||||
max-width: 700px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
background-color: #f8f9fa;
|
||||
border-right: 1px solid #dee2e6;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
padding: 1rem;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.sidebar-controls {
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.cesium-container {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#cesiumContainer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
/* Стили для тепловой карты */
|
||||
.heatmap-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.heatmap-toggle .btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 60px;
|
||||
height: 34px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #ccc;
|
||||
transition: .4s;
|
||||
}
|
||||
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 26px;
|
||||
width: 26px;
|
||||
left: 4px;
|
||||
bottom: 4px;
|
||||
background-color: white;
|
||||
transition: .4s;
|
||||
}
|
||||
|
||||
input:checked + .slider {
|
||||
background-color: #2196F3;
|
||||
}
|
||||
|
||||
input:checked + .slider:before {
|
||||
transform: translateX(26px);
|
||||
}
|
||||
|
||||
.slider.round {
|
||||
border-radius: 34px;
|
||||
}
|
||||
|
||||
.slider.round:before {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* Стили таблицы */
|
||||
.table-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.table-responsive {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
background-color: white;
|
||||
overflow: hidden;
|
||||
/* Прокрутка теперь здесь */
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin-bottom: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.table thead {
|
||||
flex: 0 0 auto;
|
||||
display: table;
|
||||
width: 100%;
|
||||
table-layout: fixed;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.table tbody {
|
||||
flex: 1 1 auto;
|
||||
display: block;
|
||||
/* Убираем overflow-y: auto отсюда */
|
||||
}
|
||||
|
||||
.table tbody tr {
|
||||
display: table;
|
||||
width: 100%;
|
||||
table-layout: fixed;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.table tbody tr:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.table th, .table td {
|
||||
padding: 0.5rem 0.75rem;
|
||||
vertical-align: middle;
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.table thead th {
|
||||
font-weight: 600;
|
||||
background-color: #f8f9fa;
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
/* Убираем position: sticky отсюда */
|
||||
}
|
||||
|
||||
/* Фиксированные ширины столбцов */
|
||||
#table-stations th:nth-child(1),
|
||||
#table-stations td:nth-child(1) {
|
||||
width: 40px;
|
||||
min-width: 40px;
|
||||
max-width: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#table-stations th:nth-child(2),
|
||||
#table-stations td:nth-child(2) {
|
||||
width: 60%;
|
||||
min-width: 60%;
|
||||
max-width: 60%;
|
||||
}
|
||||
|
||||
#table-stations th:nth-child(3),
|
||||
#table-stations td:nth-child(3) {
|
||||
width: 40%;
|
||||
min-width: 40%;
|
||||
max-width: 40%;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/* Фиксированные ширины столбцов */
|
||||
#table-request th:nth-child(1),
|
||||
#table-request td:nth-child(1) {
|
||||
width: 40px;
|
||||
min-width: 40px;
|
||||
max-width: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#table-request th:nth-child(2),
|
||||
#table-request td:nth-child(2) {
|
||||
width: 45%;
|
||||
min-width: 45%;
|
||||
max-width: 45%;
|
||||
}
|
||||
|
||||
#table-request th:nth-child(3),
|
||||
#table-request td:nth-child(3) {
|
||||
width: 10%;
|
||||
min-width: 10%;
|
||||
max-width: 10%;
|
||||
}
|
||||
|
||||
|
||||
#table-request th:nth-child(4),
|
||||
#table-request td:nth-child(4) {
|
||||
width: 25%;
|
||||
min-width: 25%;
|
||||
max-width: 25%;
|
||||
}
|
||||
|
||||
#table-request th:nth-child(5),
|
||||
#table-request td:nth-child(5) {
|
||||
width: 10%;
|
||||
min-width: 10%;
|
||||
max-width: 10%;
|
||||
}
|
||||
#table-request th:nth-child(6),
|
||||
#table-request td:nth-child(6) {
|
||||
width: 10%;
|
||||
min-width: 10%;
|
||||
max-width: 10%;
|
||||
}
|
||||
#table-request th:nth-child(7),
|
||||
#table-request td:nth-child(7) {
|
||||
width: 10%;
|
||||
min-width: 10%;
|
||||
max-width: 10%;
|
||||
}
|
||||
|
||||
|
||||
|
||||
#slotsTable th:nth-child(1),
|
||||
#slotsTable td:nth-child(1) {
|
||||
width: 14%;
|
||||
min-width: 14%;
|
||||
max-width: 14%;
|
||||
}
|
||||
|
||||
#slotsTable th:nth-child(2),
|
||||
#slotsTable td:nth-child(2) {
|
||||
width: 14%;
|
||||
min-width:14%;
|
||||
max-width: 14%;
|
||||
}
|
||||
|
||||
#slotsTable th:nth-child(3),
|
||||
#slotsTable td:nth-child(3) {
|
||||
width: 28%;
|
||||
min-width: 28%;
|
||||
max-width: 28%;
|
||||
}
|
||||
#slotsTable th:nth-child(4),
|
||||
#slotsTable td:nth-child(4) {
|
||||
width: 14%;
|
||||
min-width: 14%;
|
||||
max-width: 14%;
|
||||
}
|
||||
|
||||
#slotsTable th:nth-child(5),
|
||||
#slotsTable td:nth-child(5) {
|
||||
width: 14%;
|
||||
min-width: 14%;
|
||||
max-width: 14%;
|
||||
}
|
||||
#slotsTable th:nth-child(6),
|
||||
#slotsTable td:nth-child(6) {
|
||||
width: 8%;
|
||||
min-width: 8%;
|
||||
max-width: 8%;
|
||||
}
|
||||
#slotsTable th:nth-child(7),
|
||||
#slotsTable td:nth-child(7) {
|
||||
width: 8%;
|
||||
min-width: 8%;
|
||||
max-width: 8%;
|
||||
}
|
||||
|
||||
|
||||
|
||||
#table-satellites th:nth-child(1),
|
||||
#table-satellites td:nth-child(1) {
|
||||
width: 20%;
|
||||
min-width: 20%;
|
||||
max-width: 20%;
|
||||
}
|
||||
|
||||
#table-satellites th:nth-child(2),
|
||||
#table-satellites td:nth-child(2) {
|
||||
width: 60%;
|
||||
min-width: 60%;
|
||||
max-width: 60%;
|
||||
}
|
||||
|
||||
#table-satellites th:nth-child(3),
|
||||
#table-satellites td:nth-child(3) {
|
||||
width: 10%;
|
||||
min-width: 10%;
|
||||
max-width: 20%;
|
||||
}
|
||||
|
||||
#table-satellites th:nth-child(4),
|
||||
#table-satellites td:nth-child(4) {
|
||||
width: 10%;
|
||||
min-width: 10%;
|
||||
max-width: 20%;
|
||||
}
|
||||
|
||||
|
||||
/* Стили для сортировки */
|
||||
.table th.sortable {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
transition: background-color 0.2s;
|
||||
padding-right: 25px;
|
||||
}
|
||||
|
||||
.table th.sortable:hover {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
.sort-icon {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 0.8em;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.table th.sortable:hover .sort-icon {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.table th.sortable.asc .sort-icon,
|
||||
.table th.sortable.desc .sort-icon {
|
||||
opacity: 1;
|
||||
color: #0d6efd;
|
||||
}
|
||||
|
||||
.table th.sortable.desc .sort-icon i {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Чекбоксы */
|
||||
.request-checkbox {
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
#selectAllCheckbox {
|
||||
cursor: pointer;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Бейджи */
|
||||
.badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
min-width: 40px;
|
||||
text-align: center;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Счетчик */
|
||||
.selected-counter {
|
||||
font-size: 0.8rem;
|
||||
color: #6c757d;
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
/* Сообщение "Нет заявок" */
|
||||
.no-requests {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #6c757d;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Полоса прокрутки теперь у .table-responsive */
|
||||
.table-responsive::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.table-responsive::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.table-responsive::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.table-responsive::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Стили для таблицы слотов */
|
||||
.slots-container {
|
||||
animation: fadeIn 0.3s ease-in;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.slots-header {
|
||||
padding: 10px 0;
|
||||
border-bottom: 2px solid #0d6efd;
|
||||
}
|
||||
|
||||
.slots-actions .btn {
|
||||
padding: 5px 10px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.slots-actions .btn i {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
#slotsTable {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
#slotsTable th {
|
||||
padding: 8px 10px;
|
||||
font-weight: 600;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background-color: #f8f9fa;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
#slotsTable td {
|
||||
padding: 6px 10px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
#slotsTable .sortable-slot {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#slotsTable .sortable-slot:hover {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
#slotsTable .sortable-slot .sort-icon {
|
||||
margin-left: 5px;
|
||||
font-size: 0.8rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
#slotsTable .sortable-slot:hover .sort-icon {
|
||||
color: #0d6efd;
|
||||
}
|
||||
|
||||
#slotsTable .badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
#slotsTable .text-nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.coverage-satellites-list {
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.slots-summary {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.slots-summary .row {
|
||||
row-gap: 5px;
|
||||
}
|
||||
|
||||
.slots-summary small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Адаптивность */
|
||||
@media (max-width: 768px) {
|
||||
.slots-summary .row > div {
|
||||
flex: 0 0 50%;
|
||||
max-width: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.slots-summary .row > div {
|
||||
flex: 0 0 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.slots-actions .btn {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.slots-actions .btn:not(:last-child) {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/* Панель инструментов карты */
|
||||
.cesium-toolbar {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.toolbar-group {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||
padding: 5px;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Кнопки панели инструментов */
|
||||
.cesium-toolbar-btn {
|
||||
background: white;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.9rem;
|
||||
color: #495057;
|
||||
transition: all 0.2s ease;
|
||||
min-width: 120px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.cesium-toolbar-btn:hover {
|
||||
background: #e9ecef;
|
||||
border-color: #adb5bd;
|
||||
color: #0d6efd;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.cesium-toolbar-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.cesium-toolbar-btn.active {
|
||||
background: #0d6efd;
|
||||
border-color: #0d6efd;
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px rgba(13, 110, 253, 0.4);
|
||||
}
|
||||
|
||||
.cesium-toolbar-btn.active:hover {
|
||||
background: #0b5ed7;
|
||||
border-color: #0b5ed7;
|
||||
}
|
||||
|
||||
.cesium-toolbar-btn i {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.btn-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Информация о линейке */
|
||||
.ruler-info {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border-radius: 6px;
|
||||
padding: 10px 15px;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
|
||||
animation: slideDown 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.ruler-info-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ruler-info i {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.ruler-info .btn-close {
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.ruler-info .btn-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Стили для линий линейки */
|
||||
.ruler-line {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
background: linear-gradient(90deg, rgba(255,255,255,0.2) 0%, rgba(255,255,255,0.8) 50%, rgba(255,255,255,0.2) 100%);
|
||||
height: 2px;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Стили для маркеров точек */
|
||||
.ruler-marker {
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: #ff4444;
|
||||
border: 2px solid white;
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
box-shadow: 0 0 10px rgba(255, 68, 68, 0.8);
|
||||
pointer-events: none;
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
/* Стили для меток расстояния */
|
||||
.ruler-label {
|
||||
position: absolute;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
z-index: 1002;
|
||||
white-space: nowrap;
|
||||
transform: translate(-50%, -100%);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Стили для временной линии при наведении */
|
||||
.ruler-temp-line {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
z-index: 999;
|
||||
height: 2px;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
width: 0;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
.rva-page {
|
||||
min-height: calc(100vh - 6rem);
|
||||
}
|
||||
|
||||
.rva-card {
|
||||
border: 1px solid #dbe5ef;
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 0.75rem 2rem rgba(15, 32, 55, 0.08);
|
||||
}
|
||||
|
||||
.rva-card .card-header {
|
||||
background: linear-gradient(135deg, #edf5ff, #f7fbff);
|
||||
border-bottom: 1px solid #dbe5ef;
|
||||
border-radius: 1rem 1rem 0 0;
|
||||
}
|
||||
|
||||
.rva-section-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #52708f;
|
||||
}
|
||||
|
||||
.rva-helper {
|
||||
color: #5e7288;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.rva-stations {
|
||||
max-height: 26rem;
|
||||
overflow: auto;
|
||||
border: 1px solid #dbe5ef;
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: #fbfdff;
|
||||
}
|
||||
|
||||
.rva-station-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
gap: 0.65rem;
|
||||
padding: 0.55rem 0.25rem;
|
||||
border-bottom: 1px solid #eef4fb;
|
||||
}
|
||||
|
||||
.rva-station-item.form-check {
|
||||
margin-bottom: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.rva-station-item .form-check-input {
|
||||
margin-left: 0;
|
||||
margin-top: 0.2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rva-station-label {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rva-station-item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
#rva-results-head th {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#rva-results-body td {
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
.satellite-pdcm-sidebar .catalog-list {
|
||||
max-height: 76vh;
|
||||
}
|
||||
|
||||
.satellite-pdcm-sidebar th:first-child,
|
||||
.satellite-pdcm-sidebar td:first-child {
|
||||
width: 45%;
|
||||
}
|
||||
|
||||
.satellite-pdcm-summary-root {
|
||||
min-height: 1.5rem;
|
||||
}
|
||||
|
||||
.satellite-pdcm-status {
|
||||
display: inline-flex;
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
border-radius: 999px;
|
||||
margin-right: 0.5rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.satellite-pdcm-status-active {
|
||||
background: #2f9e44;
|
||||
box-shadow: 0 0 0 0.2rem rgba(47, 158, 68, 0.16);
|
||||
}
|
||||
|
||||
.satellite-pdcm-status-inactive {
|
||||
background: #9aa9b8;
|
||||
box-shadow: 0 0 0 0.2rem rgba(154, 169, 184, 0.16);
|
||||
}
|
||||
|
||||
.satellite-pdcm-interval-cell {
|
||||
min-width: 10.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: #42576d;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.satellite-pdcm-satellite-name {
|
||||
color: #1f3650;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.satellite-pdcm-interval-line {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.satellite-pdcm-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.satellite-pdcm-summary-card {
|
||||
border: 1px solid #dbe5ef;
|
||||
border-radius: 0.9rem;
|
||||
padding: 0.9rem 1rem;
|
||||
background: linear-gradient(180deg, #fbfdff, #f5f9ff);
|
||||
}
|
||||
|
||||
.satellite-pdcm-summary-label {
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #6d8398;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.satellite-pdcm-summary-value {
|
||||
color: #1f3650;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.satellite-pdcm-table-wrap {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.satellite-pdcm-table {
|
||||
min-width: 92rem;
|
||||
}
|
||||
|
||||
.satellite-pdcm-table thead th {
|
||||
background: #f6faff;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.satellite-pdcm-table td {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 991.98px) {
|
||||
.satellite-pdcm-sidebar th:first-child,
|
||||
.satellite-pdcm-sidebar td:first-child {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.satellite-pdcm-interval-cell {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.satellite-pdcm-table {
|
||||
min-width: 72rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,655 @@
|
||||
(() => {
|
||||
const state = {
|
||||
satellites: [],
|
||||
filteredSatellites: [],
|
||||
missions: [],
|
||||
currentModes: [],
|
||||
selectedSatelliteId: null,
|
||||
selectedMissionId: null,
|
||||
selectedMission: null,
|
||||
missionsPage: 0,
|
||||
missionsSize: 4,
|
||||
missionsTotalPages: 0,
|
||||
missionsTotalElements: 0
|
||||
};
|
||||
|
||||
const el = (id) => document.getElementById(id);
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
bindEvents();
|
||||
loadSatellites();
|
||||
});
|
||||
|
||||
function bindEvents() {
|
||||
el('current-plans-refresh')?.addEventListener('click', () => reloadAll());
|
||||
el('current-plans-satellite-filter')?.addEventListener('input', () => applySatelliteFilter());
|
||||
el('current-plans-missions-prev')?.addEventListener('click', () => changeMissionPage(-1));
|
||||
el('current-plans-missions-next')?.addEventListener('click', () => changeMissionPage(1));
|
||||
el('current-plans-create-mission')?.addEventListener('click', () => openCreateMissionDialog());
|
||||
el('current-plans-create-form')?.addEventListener('submit', (event) => createMission(event));
|
||||
el('current-plans-export-csv')?.addEventListener('click', () => exportSelectedMissionCsv());
|
||||
}
|
||||
|
||||
function reloadAll() {
|
||||
if (state.selectedSatelliteId) {
|
||||
loadMissions(state.selectedSatelliteId, state.missionsPage, state.selectedMissionId);
|
||||
} else {
|
||||
loadSatellites();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSatellites() {
|
||||
showAlert('');
|
||||
setCreateMissionEnabled(false);
|
||||
clearMissions('Выберите спутник для загрузки миссий.');
|
||||
clearModes('Режимы появятся после выбора миссии.');
|
||||
setTableLoading('current-plans-satellites-body', 3, 'Загрузка спутников...');
|
||||
|
||||
try {
|
||||
const satellites = await fetchJson('/api/current-plans/satellites');
|
||||
state.satellites = Array.isArray(satellites) ? satellites : [];
|
||||
applySatelliteFilter();
|
||||
} catch (error) {
|
||||
state.satellites = [];
|
||||
state.filteredSatellites = [];
|
||||
renderSatellites();
|
||||
showAlert(error.message || 'Не удалось загрузить список спутников.', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
function applySatelliteFilter() {
|
||||
const query = (el('current-plans-satellite-filter')?.value || '').trim().toLowerCase();
|
||||
state.filteredSatellites = state.satellites.filter((satellite) => {
|
||||
const text = [
|
||||
satellite.id,
|
||||
satellite.noradId,
|
||||
satellite.code,
|
||||
satellite.name,
|
||||
satellite.typeCode
|
||||
].filter((value) => value !== null && value !== undefined)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
return !query || text.includes(query);
|
||||
});
|
||||
renderSatellites();
|
||||
}
|
||||
|
||||
function renderSatellites() {
|
||||
const tbody = el('current-plans-satellites-body');
|
||||
if (!tbody) return;
|
||||
|
||||
if (!state.filteredSatellites.length) {
|
||||
tbody.innerHTML = emptyRow(3, 'Спутники не найдены.');
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = state.filteredSatellites.map((satellite) => {
|
||||
const selected = Number(satellite.id) === Number(state.selectedSatelliteId) ? ' current-plans-selected-row' : '';
|
||||
const title = escapeHtml(satellite.name || satellite.code || `КА ${satellite.id}`);
|
||||
const code = escapeHtml(satellite.code || '—');
|
||||
const norad = satellite.noradId ? `NORAD ${escapeHtml(satellite.noradId)}` : 'NORAD —';
|
||||
return `
|
||||
<tr class="${selected}" data-satellite-id="${escapeHtml(satellite.id)}">
|
||||
<td class="current-plans-id">${escapeHtml(satellite.id)}</td>
|
||||
<td>
|
||||
<div class="current-plans-main-text">${title}</div>
|
||||
<div class="current-plans-sub-text">${code} · ${norad}</div>
|
||||
</td>
|
||||
<td>${escapeHtml(satellite.typeCode || '—')}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
tbody.querySelectorAll('tr[data-satellite-id]').forEach((row) => {
|
||||
row.addEventListener('click', () => selectSatellite(Number(row.dataset.satelliteId)));
|
||||
});
|
||||
}
|
||||
|
||||
function selectSatellite(satelliteId) {
|
||||
state.selectedSatelliteId = satelliteId;
|
||||
state.selectedMissionId = null;
|
||||
state.selectedMission = null;
|
||||
state.missionsPage = 0;
|
||||
renderSatellites();
|
||||
const satellite = selectedSatellite();
|
||||
el('current-plans-selected-satellite').textContent = satelliteLabel(satellite);
|
||||
setCreateMissionEnabled(true);
|
||||
clearModes('Выберите миссию в верхней таблице.');
|
||||
loadMissions(satelliteId, 0);
|
||||
}
|
||||
|
||||
async function loadMissions(satelliteId, page, missionIdToSelect = null) {
|
||||
setTableLoading('current-plans-missions-body', 6, 'Загрузка миссий...');
|
||||
el('current-plans-missions-empty')?.classList.add('d-none');
|
||||
el('current-plans-missions-wrap')?.classList.remove('d-none');
|
||||
setMissionPaging(false);
|
||||
|
||||
try {
|
||||
const data = await fetchJson(`/api/current-plans/satellites/${encodeURIComponent(satelliteId)}/missions?page=${page}&size=${state.missionsSize}`);
|
||||
state.missionsPage = data.page || 0;
|
||||
state.missionsTotalPages = data.totalPages || 0;
|
||||
state.missionsTotalElements = data.totalElements || 0;
|
||||
state.missions = data.items || [];
|
||||
renderMissions(state.missions);
|
||||
updateMissionPaging();
|
||||
if (missionIdToSelect && state.missions.some((mission) => mission.missionId === missionIdToSelect)) {
|
||||
selectMission(missionIdToSelect);
|
||||
}
|
||||
} catch (error) {
|
||||
clearMissions('Не удалось загрузить миссии выбранного спутника.');
|
||||
showAlert(error.message || 'Не удалось загрузить миссии выбранного спутника.', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
function renderMissions(missions) {
|
||||
const tbody = el('current-plans-missions-body');
|
||||
if (!tbody) return;
|
||||
|
||||
if (!missions.length) {
|
||||
tbody.innerHTML = emptyRow(6, 'Для выбранного спутника миссии не найдены.');
|
||||
clearModes('Выберите миссию в верхней таблице.');
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = missions.map((mission) => {
|
||||
const selected = mission.missionId === state.selectedMissionId ? ' current-plans-selected-row' : '';
|
||||
return `
|
||||
<tr class="${selected}" data-mission-id="${escapeHtml(mission.missionId)}">
|
||||
<td class="current-plans-id">${shortUuid(mission.missionId)}</td>
|
||||
<td>${formatDateTime(mission.missionStart)}</td>
|
||||
<td>${formatDateTime(mission.missionStop)}</td>
|
||||
<td>${escapeHtml(mission.station || '—')}</td>
|
||||
<td><span class="current-plans-badge">${escapeHtml(mission.status || '—')}</span></td>
|
||||
<td class="current-plans-actions-cell">
|
||||
<button type="button"
|
||||
class="btn btn-outline-primary btn-sm current-plans-action-btn"
|
||||
data-action="calculate-surveys"
|
||||
data-mission-id="${escapeHtml(mission.missionId)}">Расчёт съёмки</button>
|
||||
<button type="button"
|
||||
class="btn btn-outline-success btn-sm current-plans-action-btn"
|
||||
data-action="calculate-drops"
|
||||
data-mission-id="${escapeHtml(mission.missionId)}">Расчёт сбросов</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
tbody.querySelectorAll('tr[data-mission-id]').forEach((row) => {
|
||||
row.addEventListener('click', () => selectMission(row.dataset.missionId));
|
||||
});
|
||||
tbody.querySelectorAll('button[data-action]').forEach((button) => {
|
||||
button.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const missionId = button.dataset.missionId;
|
||||
if (button.dataset.action === 'calculate-surveys') {
|
||||
calculateSurveys(missionId, button);
|
||||
} else if (button.dataset.action === 'calculate-drops') {
|
||||
calculateDrops(missionId, button);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function selectMission(missionId) {
|
||||
state.selectedMissionId = missionId;
|
||||
state.selectedMission = state.missions.find((mission) => mission.missionId === missionId) || null;
|
||||
el('current-plans-selected-mission').textContent = missionCaption(state.selectedMission, missionId);
|
||||
el('current-plans-missions-body')?.querySelectorAll('tr').forEach((row) => {
|
||||
row.classList.toggle('current-plans-selected-row', row.dataset.missionId === missionId);
|
||||
});
|
||||
loadModes(missionId);
|
||||
}
|
||||
|
||||
async function loadModes(missionId) {
|
||||
setTableLoading('current-plans-modes-body', 6, 'Загрузка режимов...');
|
||||
el('current-plans-modes-empty')?.classList.add('d-none');
|
||||
el('current-plans-modes-wrap')?.classList.remove('d-none');
|
||||
setExportEnabled(false);
|
||||
|
||||
try {
|
||||
const modes = await fetchJson(`/api/current-plans/missions/${encodeURIComponent(missionId)}/modes`);
|
||||
state.currentModes = Array.isArray(modes) ? modes : [];
|
||||
renderModes(state.currentModes);
|
||||
setExportEnabled(true);
|
||||
return state.currentModes;
|
||||
} catch (error) {
|
||||
clearModes('Не удалось загрузить режимы выбранной миссии.');
|
||||
showAlert(error.message || 'Не удалось загрузить режимы выбранной миссии.', 'danger');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function renderModes(modes) {
|
||||
const tbody = el('current-plans-modes-body');
|
||||
if (!tbody) return;
|
||||
|
||||
if (!modes.length) {
|
||||
tbody.innerHTML = emptyRow(6, 'В выбранной миссии нет режимов работы.');
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = modes.map((mode) => {
|
||||
const type = mode.type || '—';
|
||||
const duration = formatNumber(mode.duration);
|
||||
const rowClass = modeRowClass(mode);
|
||||
const badgeClass = modeBadgeClass(mode);
|
||||
return `
|
||||
<tr class="${rowClass}">
|
||||
<td><span class="current-plans-badge ${badgeClass}">${escapeHtml(type)}</span></td>
|
||||
<td>${formatDateTime(mode.timeStart)}</td>
|
||||
<td>${escapeHtml(mode.revolution ?? '—')}</td>
|
||||
<td>${duration}</td>
|
||||
<td class="current-plans-mode-params">${modeParams(mode)}</td>
|
||||
<td>${modeStatus(mode)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function modeRowClass(mode) {
|
||||
if (mode.type === 'DROP') return 'current-plans-mode-row-drop';
|
||||
if (mode.source === 'SLOTS') return 'current-plans-mode-row-slots';
|
||||
return '';
|
||||
}
|
||||
|
||||
function modeBadgeClass(mode) {
|
||||
if (mode.type === 'DROP') return 'current-plans-badge-drop';
|
||||
if (mode.source === 'SLOTS') return 'current-plans-badge-slots';
|
||||
return '';
|
||||
}
|
||||
|
||||
function modeParams(mode) {
|
||||
if (mode.type === 'DROP') {
|
||||
const surveys = Array.isArray(mode.surveys) ? mode.surveys.length : 0;
|
||||
return `
|
||||
<div>Станция: ${escapeHtml(mode.station || '—')}</div>
|
||||
<div class="current-plans-sub-text">Съёмок: ${surveys}</div>`;
|
||||
}
|
||||
|
||||
const booked = Array.isArray(mode.bookedSlotIds) && mode.bookedSlotIds.length
|
||||
? ` · slots: ${mode.bookedSlotIds.length}`
|
||||
: '';
|
||||
return `
|
||||
<div>Крен: ${formatNumber(mode.roll)}°</div>
|
||||
<div class="current-plans-sub-text">lat ${formatNumber(mode.lat)}, lon ${formatNumber(mode.longitude)}${booked}</div>`;
|
||||
}
|
||||
|
||||
function modeStatus(mode) {
|
||||
if (mode.type === 'DROP') {
|
||||
return '<span class="current-plans-badge current-plans-badge-drop">DROP</span>';
|
||||
}
|
||||
const sourceBadgeClass = mode.source === 'SLOTS' ? ' current-plans-badge-slots' : '';
|
||||
return `
|
||||
<div><span class="current-plans-badge">${escapeHtml(mode.status || '—')}</span></div>
|
||||
<div><span class="current-plans-badge${sourceBadgeClass}">${escapeHtml(mode.source || '—')}</span></div>`;
|
||||
}
|
||||
|
||||
function openCreateMissionDialog() {
|
||||
if (!state.selectedSatelliteId) {
|
||||
showAlert('Сначала выберите спутник.', 'warning');
|
||||
return;
|
||||
}
|
||||
const satellite = selectedSatellite();
|
||||
el('current-plans-create-satellite').value = satelliteLabel(satellite);
|
||||
const startInput = el('current-plans-create-start');
|
||||
const stopInput = el('current-plans-create-stop');
|
||||
if (startInput && !startInput.value) startInput.value = toDateTimeLocalValue(new Date());
|
||||
if (stopInput && !stopInput.value) stopInput.value = toDateTimeLocalValue(new Date(Date.now() + 24 * 60 * 60 * 1000));
|
||||
const modalElement = el('current-plans-create-modal');
|
||||
if (window.bootstrap && modalElement) {
|
||||
window.bootstrap.Modal.getOrCreateInstance(modalElement).show();
|
||||
}
|
||||
}
|
||||
|
||||
async function createMission(event) {
|
||||
event.preventDefault();
|
||||
if (!state.selectedSatelliteId) {
|
||||
showAlert('Сначала выберите спутник.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const missionStart = el('current-plans-create-start')?.value;
|
||||
const missionStop = el('current-plans-create-stop')?.value;
|
||||
if (!missionStart || !missionStop) {
|
||||
showAlert('Укажите начало и конец миссии.', 'warning');
|
||||
return;
|
||||
}
|
||||
if (new Date(missionStart).getTime() > new Date(missionStop).getTime()) {
|
||||
showAlert('Время начала миссии должно быть меньше или равно времени окончания.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const button = el('current-plans-create-submit');
|
||||
setButtonLoading(button, true, 'Создание...');
|
||||
const body = {
|
||||
satelliteId: Number(state.selectedSatelliteId),
|
||||
station: trimToNull(el('current-plans-create-station')?.value),
|
||||
missionStart,
|
||||
missionStop,
|
||||
status: trimToNull(el('current-plans-create-status')?.value) || 'NEW'
|
||||
};
|
||||
|
||||
try {
|
||||
const mission = await fetchJson('/api/current-plans/missions', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json', 'Accept': 'application/json'},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
closeCreateMissionDialog();
|
||||
showAlert(`План создан: ${mission?.missionId || ''}`, 'success');
|
||||
await loadMissions(state.selectedSatelliteId, 0, mission?.missionId);
|
||||
} catch (error) {
|
||||
showAlert(error.message || 'Не удалось создать план.', 'danger');
|
||||
} finally {
|
||||
setButtonLoading(button, false);
|
||||
}
|
||||
}
|
||||
|
||||
async function calculateSurveys(missionId, button) {
|
||||
setButtonLoading(button, true, 'Расчёт...');
|
||||
try {
|
||||
const result = await fetchJson(`/api/current-plans/missions/${encodeURIComponent(missionId)}/surveys/calculate`, {
|
||||
method: 'POST',
|
||||
headers: {'Accept': 'application/json'}
|
||||
});
|
||||
showAlert(surveyCalculationMessage(result), 'success');
|
||||
if (state.selectedMissionId === missionId) {
|
||||
await loadModes(missionId);
|
||||
}
|
||||
} catch (error) {
|
||||
showAlert(error.message || 'Не удалось рассчитать съёмку.', 'danger');
|
||||
} finally {
|
||||
setButtonLoading(button, false);
|
||||
}
|
||||
}
|
||||
|
||||
async function calculateDrops(missionId, button) {
|
||||
setButtonLoading(button, true, 'Расчёт...');
|
||||
try {
|
||||
await fetchJson(`/api/current-plans/missions/${encodeURIComponent(missionId)}/drops/calculate`, {
|
||||
method: 'POST',
|
||||
headers: {'Accept': 'application/json'}
|
||||
});
|
||||
const modes = state.selectedMissionId === missionId
|
||||
? await loadModes(missionId)
|
||||
: await fetchJson(`/api/current-plans/missions/${encodeURIComponent(missionId)}/modes`);
|
||||
showAlert(`Расчёт сбросов выполнен. ${modeCountsMessage(Array.isArray(modes) ? modes : [])}`, 'success');
|
||||
} catch (error) {
|
||||
showAlert(error.message || 'Не удалось рассчитать сбросы.', 'danger');
|
||||
} finally {
|
||||
setButtonLoading(button, false);
|
||||
}
|
||||
}
|
||||
|
||||
function exportSelectedMissionCsv() {
|
||||
if (!state.selectedMissionId) {
|
||||
showAlert('Выберите миссию для сохранения CSV.', 'warning');
|
||||
return;
|
||||
}
|
||||
const mission = state.selectedMission || state.missions.find((item) => item.missionId === state.selectedMissionId);
|
||||
const satellite = selectedSatellite();
|
||||
const modes = state.currentModes || [];
|
||||
const csv = buildMissionCsv(mission, satellite, modes);
|
||||
const filename = `mission-${state.selectedMissionId}-modes.csv`;
|
||||
downloadTextFile(filename, csv, 'text/csv;charset=utf-8;');
|
||||
}
|
||||
|
||||
function buildMissionCsv(mission, satellite, modes) {
|
||||
const lines = [];
|
||||
lines.push(['Миссия', mission?.missionId || state.selectedMissionId].map(csvCell).join(';'));
|
||||
lines.push(['Спутник ID', mission?.satelliteId || state.selectedSatelliteId].map(csvCell).join(';'));
|
||||
lines.push(['Спутник', satelliteLabel(satellite)].map(csvCell).join(';'));
|
||||
lines.push(['Начало миссии', mission?.missionStart || ''].map(csvCell).join(';'));
|
||||
lines.push(['Конец миссии', mission?.missionStop || ''].map(csvCell).join(';'));
|
||||
lines.push(['Станция', mission?.station || ''].map(csvCell).join(';'));
|
||||
lines.push(['Статус', mission?.status || ''].map(csvCell).join(';'));
|
||||
lines.push(['Количество режимов', modes.length].map(csvCell).join(';'));
|
||||
lines.push(['Состав режимов', modeCountsPlain(modes)].map(csvCell).join(';'));
|
||||
lines.push('');
|
||||
lines.push(['Тип', 'ID', 'Plan ID', 'Начало', 'Виток', 'Длительность, с', 'Статус', 'Источник', 'Крен', 'Широта', 'Долгота', 'Станция', 'Связанные съёмки', 'Booked slots'].map(csvCell).join(';'));
|
||||
modes.forEach((mode) => {
|
||||
lines.push([
|
||||
mode.type || '',
|
||||
mode.id ?? '',
|
||||
mode.planId ?? '',
|
||||
mode.timeStart || '',
|
||||
mode.revolution ?? '',
|
||||
mode.duration ?? '',
|
||||
mode.status || '',
|
||||
mode.source || '',
|
||||
mode.roll ?? '',
|
||||
mode.lat ?? '',
|
||||
mode.longitude ?? '',
|
||||
mode.station || '',
|
||||
Array.isArray(mode.surveys) ? mode.surveys.join('|') : '',
|
||||
Array.isArray(mode.bookedSlotIds) ? mode.bookedSlotIds.join('|') : ''
|
||||
].map(csvCell).join(';'));
|
||||
});
|
||||
return '\ufeff' + lines.join('\r\n');
|
||||
}
|
||||
|
||||
function changeMissionPage(delta) {
|
||||
if (!state.selectedSatelliteId) return;
|
||||
const next = state.missionsPage + delta;
|
||||
if (next < 0 || (state.missionsTotalPages > 0 && next >= state.missionsTotalPages)) return;
|
||||
state.selectedMissionId = null;
|
||||
state.selectedMission = null;
|
||||
clearModes('Выберите миссию в верхней таблице.');
|
||||
loadMissions(state.selectedSatelliteId, next);
|
||||
}
|
||||
|
||||
function updateMissionPaging() {
|
||||
const pageText = state.missionsTotalPages > 0
|
||||
? `${state.missionsPage + 1} / ${state.missionsTotalPages}`
|
||||
: '0 / 0';
|
||||
el('current-plans-missions-page').textContent = `${pageText} · ${state.missionsTotalElements}`;
|
||||
setMissionPaging(true);
|
||||
}
|
||||
|
||||
function setMissionPaging(enabled) {
|
||||
const hasPrev = enabled && state.missionsPage > 0;
|
||||
const hasNext = enabled && state.missionsTotalPages > 0 && state.missionsPage < state.missionsTotalPages - 1;
|
||||
el('current-plans-missions-prev').disabled = !hasPrev;
|
||||
el('current-plans-missions-next').disabled = !hasNext;
|
||||
}
|
||||
|
||||
function clearMissions(message) {
|
||||
el('current-plans-missions-wrap')?.classList.add('d-none');
|
||||
el('current-plans-missions-empty')?.classList.remove('d-none');
|
||||
el('current-plans-missions-empty').textContent = message;
|
||||
el('current-plans-missions-body').innerHTML = '';
|
||||
state.missions = [];
|
||||
state.missionsTotalPages = 0;
|
||||
state.missionsTotalElements = 0;
|
||||
updateMissionPaging();
|
||||
}
|
||||
|
||||
function clearModes(message) {
|
||||
el('current-plans-modes-wrap')?.classList.add('d-none');
|
||||
el('current-plans-modes-empty')?.classList.remove('d-none');
|
||||
el('current-plans-modes-empty').textContent = message;
|
||||
el('current-plans-modes-body').innerHTML = '';
|
||||
el('current-plans-selected-mission').textContent = 'Выберите миссию в верхней таблице.';
|
||||
state.currentModes = [];
|
||||
setExportEnabled(false);
|
||||
}
|
||||
|
||||
function setTableLoading(bodyId, colspan, message) {
|
||||
const tbody = el(bodyId);
|
||||
if (tbody) tbody.innerHTML = emptyRow(colspan, message);
|
||||
}
|
||||
|
||||
function selectedSatellite() {
|
||||
return state.satellites.find((item) => Number(item.id) === Number(state.selectedSatelliteId)) || null;
|
||||
}
|
||||
|
||||
function satelliteLabel(satellite) {
|
||||
if (!satellite) return `Спутник ${state.selectedSatelliteId}`;
|
||||
const name = satellite.name || satellite.code || `КА ${satellite.id}`;
|
||||
const type = satellite.typeCode ? `, тип ${satellite.typeCode}` : '';
|
||||
const norad = satellite.noradId ? `, NORAD ${satellite.noradId}` : '';
|
||||
return `${name} (ID ${satellite.id}${norad}${type})`;
|
||||
}
|
||||
|
||||
function missionCaption(mission, missionId) {
|
||||
if (!mission) return `Миссия ${missionId}`;
|
||||
return `Миссия ${mission.missionId}: ${formatDateTime(mission.missionStart)} — ${formatDateTime(mission.missionStop)}`;
|
||||
}
|
||||
|
||||
async function fetchJson(url, options = {}) {
|
||||
const response = await fetch(url, options.headers ? options : {...options, headers: {'Accept': 'application/json'}});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(extractError(text) || `HTTP ${response.status}`);
|
||||
}
|
||||
if (response.status === 204) return null;
|
||||
const text = await response.text();
|
||||
return text ? JSON.parse(text) : null;
|
||||
}
|
||||
|
||||
function extractError(text) {
|
||||
if (!text) return '';
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
return json.message || json.error || text;
|
||||
} catch (_) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function showAlert(message, type = 'danger') {
|
||||
const target = el('current-plans-alert');
|
||||
if (!target) return;
|
||||
if (!message) {
|
||||
target.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
target.innerHTML = `<div class="alert alert-${type} alert-dismissible fade show" role="alert">
|
||||
${escapeHtml(message)}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function surveyCalculationMessage(result) {
|
||||
if (!result) return 'Расчёт съёмки выполнен.';
|
||||
return [
|
||||
`Расчёт съёмки выполнен. Всего режимов съёмки: ${result.surveyModesCount ?? 0}`,
|
||||
`COMPLAN: ${result.complanModesCount ?? 0}`,
|
||||
`SLOTS: ${result.slotsModesCount ?? 0}`,
|
||||
`MIXED: ${result.mixedModesCount ?? 0}`,
|
||||
`MANUAL: ${result.manualModesCount ?? 0}`,
|
||||
`booked slots: ${result.bookedSlotIdsCount ?? 0}`
|
||||
].join(' · ');
|
||||
}
|
||||
|
||||
function modeCountsMessage(modes) {
|
||||
const counts = countModes(modes);
|
||||
const parts = Object.keys(counts).sort().map((type) => `${type}: ${counts[type]}`);
|
||||
return parts.length ? `Режимы: ${parts.join(' · ')}` : 'Режимы отсутствуют.';
|
||||
}
|
||||
|
||||
function modeCountsPlain(modes) {
|
||||
const counts = countModes(modes);
|
||||
return Object.keys(counts).sort().map((type) => `${type}: ${counts[type]}`).join(', ');
|
||||
}
|
||||
|
||||
function countModes(modes) {
|
||||
return (modes || []).reduce((acc, mode) => {
|
||||
const type = mode.type || 'UNKNOWN';
|
||||
acc[type] = (acc[type] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function emptyRow(colspan, message) {
|
||||
return `<tr><td colspan="${colspan}" class="text-muted text-center py-4">${escapeHtml(message)}</td></tr>`;
|
||||
}
|
||||
|
||||
function shortUuid(value) {
|
||||
if (!value) return '—';
|
||||
return `${String(value).slice(0, 8)}…`;
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '—';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return escapeHtml(value);
|
||||
return date.toLocaleString('ru-RU', {
|
||||
year: '2-digit',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) return '—';
|
||||
return number.toLocaleString('ru-RU', {maximumFractionDigits: 2});
|
||||
}
|
||||
|
||||
function toDateTimeLocalValue(date) {
|
||||
const pad = (number) => String(number).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
}
|
||||
|
||||
function trimToNull(value) {
|
||||
const trimmed = String(value || '').trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
function setCreateMissionEnabled(enabled) {
|
||||
const button = el('current-plans-create-mission');
|
||||
if (button) button.disabled = !enabled;
|
||||
}
|
||||
|
||||
function setExportEnabled(enabled) {
|
||||
const button = el('current-plans-export-csv');
|
||||
if (button) button.disabled = !enabled || !state.selectedMissionId;
|
||||
}
|
||||
|
||||
function setButtonLoading(button, loading, text = 'Загрузка...') {
|
||||
if (!button) return;
|
||||
if (loading) {
|
||||
button.dataset.originalText = button.textContent;
|
||||
button.textContent = text;
|
||||
button.disabled = true;
|
||||
} else {
|
||||
button.textContent = button.dataset.originalText || button.textContent;
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function closeCreateMissionDialog() {
|
||||
const modalElement = el('current-plans-create-modal');
|
||||
if (window.bootstrap && modalElement) {
|
||||
window.bootstrap.Modal.getOrCreateInstance(modalElement).hide();
|
||||
}
|
||||
}
|
||||
|
||||
function csvCell(value) {
|
||||
const text = String(value ?? '');
|
||||
return `"${text.replaceAll('"', '""')}"`;
|
||||
}
|
||||
|
||||
function downloadTextFile(filename, content, type) {
|
||||
const blob = new Blob([content], {type});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,326 @@
|
||||
(function () {
|
||||
const storageKey = 'pcp.dynamicPlan.visibleRuns';
|
||||
const routePageLimit = 1000;
|
||||
const intervalPageLimit = 1000;
|
||||
const maxRoutesToDraw = 10000;
|
||||
const maxIntervalsToDraw = 10000;
|
||||
const dataSourcePrefix = 'dynamic_plan_run_';
|
||||
let mapViewer = null;
|
||||
|
||||
function readRuns() {
|
||||
try {
|
||||
const value = window.localStorage.getItem(storageKey);
|
||||
const parsed = value ? JSON.parse(value) : [];
|
||||
return Array.isArray(parsed) ? parsed.slice(0, 1) : [];
|
||||
} catch (error) {
|
||||
console.warn('Failed to read dynamic plan map runs', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeRuns(runs) {
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(runs));
|
||||
window.dispatchEvent(new CustomEvent('dynamic-plan-map-runs-changed', { detail: runs }));
|
||||
}
|
||||
|
||||
function normalizeRun(run) {
|
||||
return {
|
||||
runId: String(run.runId),
|
||||
requestId: run.requestId ? String(run.requestId) : null,
|
||||
routesCount: Number.isFinite(Number(run.routesCount)) ? Number(run.routesCount) : null,
|
||||
addedAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
function showRun(run) {
|
||||
if (!run || !run.runId) {
|
||||
return;
|
||||
}
|
||||
const normalizedRun = normalizeRun(run);
|
||||
writeRuns([normalizedRun]);
|
||||
}
|
||||
|
||||
function hideRun(runId) {
|
||||
const id = String(runId);
|
||||
writeRuns(readRuns().filter(item => String(item.runId) !== id));
|
||||
removeRunDataSource(id);
|
||||
}
|
||||
|
||||
function isRunVisible(runId) {
|
||||
const id = String(runId);
|
||||
return readRuns().some(item => String(item.runId) === id);
|
||||
}
|
||||
|
||||
function dataSourceName(runId) {
|
||||
return `${dataSourcePrefix}${runId}`;
|
||||
}
|
||||
|
||||
function removeRunDataSource(runId) {
|
||||
if (!mapViewer) {
|
||||
return;
|
||||
}
|
||||
let dataSource = mapViewer.dataSources.getByName(dataSourceName(runId))[0];
|
||||
while (dataSource && mapViewer.dataSources.remove(dataSource)) {
|
||||
dataSource = mapViewer.dataSources.getByName(dataSourceName(runId))[0];
|
||||
}
|
||||
}
|
||||
|
||||
function closeDegreesArrayHeights(positions) {
|
||||
if (!Array.isArray(positions) || positions.length < 9) {
|
||||
return positions;
|
||||
}
|
||||
|
||||
const first = positions.slice(0, 3);
|
||||
const last = positions.slice(-3);
|
||||
const isClosed = first.every((value, index) => value === last[index]);
|
||||
return isClosed ? positions : positions.concat(first);
|
||||
}
|
||||
|
||||
function parsePolygonWktToDegreesArray(contourWkt) {
|
||||
if (typeof contourWkt !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = contourWkt.trim();
|
||||
if (!trimmed.toUpperCase().startsWith('POLYGON')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const body = trimmed
|
||||
.replace(/^POLYGON\s*\(\s*\(/i, '')
|
||||
.replace(/\)\s*\)\s*$/i, '');
|
||||
const outerRingText = body.split(/\)\s*,\s*\(/)[0];
|
||||
const coordinates = outerRingText
|
||||
.split(',')
|
||||
.map(pair => pair.trim().split(/\s+/))
|
||||
.map(parts => ({
|
||||
longitude: Number(parts[0]),
|
||||
latitude: Number(parts[1])
|
||||
}))
|
||||
.filter(point => Number.isFinite(point.longitude) && Number.isFinite(point.latitude));
|
||||
|
||||
if (coordinates.length < 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const degrees = [];
|
||||
coordinates.forEach(point => {
|
||||
degrees.push(point.longitude, point.latitude, 0.0);
|
||||
});
|
||||
return degrees;
|
||||
}
|
||||
|
||||
function intervalColor(revSign) {
|
||||
return revSign === 'DESC'
|
||||
? Cesium.Color.CYAN
|
||||
: Cesium.Color.YELLOW;
|
||||
}
|
||||
|
||||
function colorForSatellite(satelliteId) {
|
||||
const id = Number(satelliteId);
|
||||
const seed = Number.isFinite(id) ? Math.abs(id) : 0;
|
||||
const hue = ((seed * 37) % 360) / 360;
|
||||
return Cesium.Color.fromHsl(hue, 0.72, 0.52, 1.0);
|
||||
}
|
||||
|
||||
function formatValue(value) {
|
||||
return value === null || value === undefined || value === '' ? '-' : String(value).replace('T', ' ');
|
||||
}
|
||||
|
||||
function createRoutesDataSource(run, routes, intervals) {
|
||||
const dataSource = new Cesium.CustomDataSource(dataSourceName(run.runId));
|
||||
intervals.forEach((interval, index) => {
|
||||
const positions = parsePolygonWktToDegreesArray(interval.contourWkt);
|
||||
if (!positions) {
|
||||
console.warn('Failed to parse dynamic plan interval contour', interval);
|
||||
return;
|
||||
}
|
||||
|
||||
const color = intervalColor(interval.revSign);
|
||||
dataSource.entities.add({
|
||||
id: `dynamic_plan_interval_${run.runId}_${interval.id || index}`,
|
||||
name: `Dynamic Plan interval ${interval.revSign || '-'}`,
|
||||
description: [
|
||||
`Run ID: ${formatValue(run.runId)}`,
|
||||
`Request ID: ${formatValue(interval.requestId || run.requestId)}`,
|
||||
`Interval ID: ${formatValue(interval.intervalId)}`,
|
||||
`Region ID: ${formatValue(interval.regionId)}`,
|
||||
`Revolution sign: ${formatValue(interval.revSign)}`,
|
||||
`Observation parameters: ${formatValue(interval.observationParametersCount)}`
|
||||
].join('<br>'),
|
||||
polygon: {
|
||||
hierarchy: Cesium.Cartesian3.fromDegreesArrayHeights(positions),
|
||||
material: color.withAlpha(0.08),
|
||||
outline: true,
|
||||
outlineColor: color.withAlpha(0.95),
|
||||
outlineWidth: 1.0
|
||||
},
|
||||
polyline: {
|
||||
positions: Cesium.Cartesian3.fromDegreesArrayHeights(closeDegreesArrayHeights(positions)),
|
||||
width: 1.0,
|
||||
material: color.withAlpha(0.95)
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
routes.forEach((route, index) => {
|
||||
const positions = parsePolygonWktToDegreesArray(route.contourWkt);
|
||||
if (!positions) {
|
||||
console.warn('Failed to parse dynamic plan route contour', route);
|
||||
return;
|
||||
}
|
||||
|
||||
const color = colorForSatellite(route.satelliteId);
|
||||
dataSource.entities.add({
|
||||
id: `dynamic_plan_route_${run.runId}_${route.id || index}`,
|
||||
name: `Dynamic Plan route ${route.satelliteId || '-'}`,
|
||||
description: [
|
||||
`Run ID: ${formatValue(run.runId)}`,
|
||||
`Request ID: ${formatValue(route.requestId || run.requestId)}`,
|
||||
`Satellite: ${formatValue(route.satelliteId)}`,
|
||||
`Start: ${formatValue(route.startTime)}`,
|
||||
`End: ${formatValue(route.endTime)}`,
|
||||
`Duration: ${formatValue(route.duration)}`,
|
||||
`Revolution: ${formatValue(route.revolution)}`,
|
||||
`Roll: ${formatValue(route.roll)}`
|
||||
].join('<br>'),
|
||||
polygon: {
|
||||
hierarchy: Cesium.Cartesian3.fromDegreesArrayHeights(positions),
|
||||
material: color.withAlpha(0.22),
|
||||
outline: true,
|
||||
outlineColor: color,
|
||||
outlineWidth: 2.0
|
||||
},
|
||||
polyline: {
|
||||
positions: Cesium.Cartesian3.fromDegreesArrayHeights(closeDegreesArrayHeights(positions)),
|
||||
width: 2.0,
|
||||
material: color
|
||||
}
|
||||
});
|
||||
});
|
||||
return dataSource;
|
||||
}
|
||||
|
||||
async function requestJson(url) {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
const isJson = response.headers.get('content-type')?.includes('application/json');
|
||||
const body = isJson ? await response.json() : await response.text();
|
||||
if (!response.ok) {
|
||||
if (body && typeof body === 'object') {
|
||||
throw new Error(body.error || body.message || Object.values(body).join('; '));
|
||||
}
|
||||
throw new Error(body || `HTTP ${response.status}`);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
async function loadRoutes(run) {
|
||||
const routes = [];
|
||||
let offset = 0;
|
||||
while (routes.length < maxRoutesToDraw) {
|
||||
const page = await requestJson(
|
||||
`/api/dynamic-plan/runs/${encodeURIComponent(run.runId)}/routes?limit=${routePageLimit}&offset=${offset}`
|
||||
);
|
||||
if (!Array.isArray(page) || page.length === 0) {
|
||||
break;
|
||||
}
|
||||
routes.push(...page);
|
||||
if (page.length < routePageLimit) {
|
||||
break;
|
||||
}
|
||||
offset += routePageLimit;
|
||||
}
|
||||
if (run.routesCount !== null && run.routesCount > maxRoutesToDraw) {
|
||||
console.warn(`Dynamic plan run ${run.runId} has ${run.routesCount} routes; only ${maxRoutesToDraw} are drawn`);
|
||||
}
|
||||
return routes.slice(0, maxRoutesToDraw);
|
||||
}
|
||||
|
||||
async function loadIntervals(run) {
|
||||
const intervals = [];
|
||||
let offset = 0;
|
||||
while (intervals.length < maxIntervalsToDraw) {
|
||||
const page = await requestJson(
|
||||
`/api/dynamic-plan/runs/${encodeURIComponent(run.runId)}/intervals?limit=${intervalPageLimit}&offset=${offset}`
|
||||
);
|
||||
if (!Array.isArray(page) || page.length === 0) {
|
||||
break;
|
||||
}
|
||||
intervals.push(...page);
|
||||
if (page.length < intervalPageLimit) {
|
||||
break;
|
||||
}
|
||||
offset += intervalPageLimit;
|
||||
}
|
||||
if (intervals.length >= maxIntervalsToDraw) {
|
||||
console.warn(`Dynamic plan run ${run.runId} has at least ${maxIntervalsToDraw} intervals; only ${maxIntervalsToDraw} are drawn`);
|
||||
}
|
||||
return intervals.slice(0, maxIntervalsToDraw);
|
||||
}
|
||||
|
||||
async function drawRun(run) {
|
||||
if (!mapViewer || !run || !run.runId) {
|
||||
return;
|
||||
}
|
||||
|
||||
removeRunDataSource(run.runId);
|
||||
const [intervals, routes] = await Promise.all([
|
||||
loadIntervals(run),
|
||||
loadRoutes(run)
|
||||
]);
|
||||
const dataSource = createRoutesDataSource(run, routes, intervals);
|
||||
await mapViewer.dataSources.add(dataSource);
|
||||
if (dataSource.entities.values.length > 0) {
|
||||
mapViewer.flyTo(dataSource, { duration: 1.5 });
|
||||
}
|
||||
}
|
||||
|
||||
function syncMap() {
|
||||
if (!mapViewer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const runs = readRuns();
|
||||
const visibleIds = new Set(runs.map(run => String(run.runId)));
|
||||
mapViewer.dataSources._dataSources
|
||||
.filter(dataSource => dataSource.name && dataSource.name.startsWith(dataSourcePrefix))
|
||||
.forEach(dataSource => {
|
||||
const runId = dataSource.name.substring(dataSourcePrefix.length);
|
||||
if (!visibleIds.has(runId)) {
|
||||
mapViewer.dataSources.remove(dataSource);
|
||||
}
|
||||
});
|
||||
|
||||
runs.forEach(run => {
|
||||
const existing = mapViewer.dataSources.getByName(dataSourceName(run.runId))[0];
|
||||
if (!existing) {
|
||||
drawRun(run).catch(error => console.error('Failed to draw dynamic plan run', run.runId, error));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initializeMap(viewer) {
|
||||
mapViewer = viewer;
|
||||
syncMap();
|
||||
}
|
||||
|
||||
window.addEventListener('storage', event => {
|
||||
if (event.key === storageKey) {
|
||||
syncMap();
|
||||
}
|
||||
});
|
||||
window.addEventListener('dynamic-plan-map-runs-changed', syncMap);
|
||||
|
||||
window.PcpDynamicPlanMapLayers = {
|
||||
initializeMap,
|
||||
showRun,
|
||||
hideRun,
|
||||
isRunVisible,
|
||||
visibleRuns: readRuns
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,483 @@
|
||||
(function () {
|
||||
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const pollingDelayMs = 3000;
|
||||
let pollingTimer = null;
|
||||
let selectedRun = null;
|
||||
|
||||
function text(value, fallback = '') {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return fallback;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return text(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function showAlert(message, type = 'danger') {
|
||||
const container = document.getElementById('dynamic-plan-alert');
|
||||
container.innerHTML = message
|
||||
? `<div class="alert alert-${type} mb-3" role="alert">${escapeHtml(message)}</div>`
|
||||
: '';
|
||||
}
|
||||
|
||||
function parseSatelliteIds(value) {
|
||||
return String(value || '')
|
||||
.split(/[,\s;]+/)
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean)
|
||||
.map(item => Number(item))
|
||||
.filter(item => Number.isInteger(item));
|
||||
}
|
||||
|
||||
function formatDuration(value) {
|
||||
const ms = Number(value);
|
||||
if (!Number.isFinite(ms) || ms < 0) {
|
||||
return '-';
|
||||
}
|
||||
if (ms < 1000) {
|
||||
return `${Math.round(ms)} мс`;
|
||||
}
|
||||
const seconds = Math.round(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainder = seconds % 60;
|
||||
return minutes > 0 ? `${minutes} мин ${remainder} с` : `${remainder} с`;
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
return text(value, '-').replace('T', ' ');
|
||||
}
|
||||
|
||||
function startOfDay(value) {
|
||||
return `${value}T00:00:00`;
|
||||
}
|
||||
|
||||
function endOfDay(value) {
|
||||
return `${value}T23:59:59`;
|
||||
}
|
||||
|
||||
function shortId(value) {
|
||||
const fullValue = text(value, '-');
|
||||
return fullValue.length > 12 ? `${fullValue.slice(0, 8)}...${fullValue.slice(-4)}` : fullValue;
|
||||
}
|
||||
|
||||
function statusText(status) {
|
||||
switch (status) {
|
||||
case 'PENDING':
|
||||
return 'В очереди';
|
||||
case 'RUNNING':
|
||||
return 'Выполняется';
|
||||
case 'COMPLETED':
|
||||
return 'Готово';
|
||||
case 'FAILED':
|
||||
return 'Ошибка';
|
||||
default:
|
||||
return text(status, '-');
|
||||
}
|
||||
}
|
||||
|
||||
function calculationModeText(mode) {
|
||||
switch (mode) {
|
||||
case 'GREEDY':
|
||||
return 'Жадный';
|
||||
case 'FULL':
|
||||
return 'Полный';
|
||||
default:
|
||||
return text(mode, '-');
|
||||
}
|
||||
}
|
||||
|
||||
function revolutionModeText(mode) {
|
||||
switch (mode) {
|
||||
case 'ASC':
|
||||
return 'Восходящий';
|
||||
case 'DESC':
|
||||
return 'Нисходящий';
|
||||
case 'BOTH':
|
||||
return 'Оба';
|
||||
default:
|
||||
return text(mode, '-');
|
||||
}
|
||||
}
|
||||
|
||||
function booleanText(value) {
|
||||
return value ? 'Да' : 'Нет';
|
||||
}
|
||||
|
||||
function percentText(value) {
|
||||
const percent = Number(value);
|
||||
return Number.isFinite(percent) ? `${percent.toFixed(2)}%` : '-';
|
||||
}
|
||||
|
||||
function routeIntervalText(start, end) {
|
||||
if (!start && !end) {
|
||||
return '-';
|
||||
}
|
||||
return `${formatDateTime(start)} - ${formatDateTime(end)}`;
|
||||
}
|
||||
|
||||
function renderRuns(items) {
|
||||
const body = document.getElementById('dynamic-plan-runs-body');
|
||||
const meta = document.getElementById('dynamic-plan-runs-meta');
|
||||
const runs = Array.isArray(items) ? items : [];
|
||||
meta.textContent = runs.length > 0 ? `Показано запусков: ${runs.length}` : 'Запуски не найдены.';
|
||||
|
||||
if (runs.length === 0) {
|
||||
body.innerHTML = '<div class="text-muted py-4 text-center">Нет данных</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
body.innerHTML = runs.map(run => `
|
||||
<button type="button" class="dynamic-plan-run-item" data-run-id="${escapeHtml(run.runId)}">
|
||||
<span class="dynamic-plan-run-main">
|
||||
<span class="dynamic-plan-run-title">${escapeHtml(shortId(run.runId))}</span>
|
||||
<span class="dynamic-plan-run-details d-block">
|
||||
${escapeHtml(formatDateTime(run.createdAt))} · ${escapeHtml(calculationModeText(run.calculationMode))} · ${escapeHtml(revolutionModeText(run.revolutionMode))} · фильтр ${escapeHtml(booleanText(run.filterCoveredRoutes))} · заявка ${escapeHtml(shortId(run.requestId))} · маршруты ${escapeHtml(run.routesCount ?? '-')}
|
||||
</span>
|
||||
</span>
|
||||
<span class="dynamic-plan-run-status">${escapeHtml(statusText(run.status))}</span>
|
||||
</button>
|
||||
`).join('');
|
||||
|
||||
body.querySelectorAll('[data-run-id]').forEach(button => {
|
||||
button.addEventListener('click', () => openRun(button.dataset.runId));
|
||||
});
|
||||
}
|
||||
|
||||
async function loadRuns() {
|
||||
const payload = await requestJson('/api/dynamic-plan/runs?limit=20&offset=0', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
renderRuns(payload);
|
||||
}
|
||||
|
||||
async function requestJson(url, options) {
|
||||
const response = await fetch(url, options);
|
||||
const isJson = response.headers.get('content-type')?.includes('application/json');
|
||||
const body = isJson ? await response.json() : await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
if (body && typeof body === 'object') {
|
||||
throw new Error(body.error || body.message || Object.values(body).join('; '));
|
||||
}
|
||||
throw new Error(body || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
function field(label, value) {
|
||||
return `
|
||||
<div class="dynamic-plan-field">
|
||||
<div class="dynamic-plan-field-label">${escapeHtml(label)}</div>
|
||||
<div class="dynamic-plan-field-value">${escapeHtml(value)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function mapControls(run) {
|
||||
if (!run || run.status !== 'COMPLETED') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="dynamic-plan-map-controls mb-4">
|
||||
<div>
|
||||
<div class="dynamic-plan-section-title">Карта</div>
|
||||
<div id="dynamic-plan-map-state" class="dynamic-plan-helper mt-1"></div>
|
||||
</div>
|
||||
<div class="dynamic-plan-map-actions">
|
||||
<button id="dynamic-plan-map-show" type="button" class="btn btn-outline-primary btn-sm">Отобразить расчет</button>
|
||||
<button id="dynamic-plan-map-hide" type="button" class="btn btn-outline-secondary btn-sm">Скрыть расчет</button>
|
||||
<button id="dynamic-plan-map-open" type="button" class="btn btn-primary btn-sm">Открыть карту</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function updateMapControls(run) {
|
||||
const showButton = document.getElementById('dynamic-plan-map-show');
|
||||
const hideButton = document.getElementById('dynamic-plan-map-hide');
|
||||
const state = document.getElementById('dynamic-plan-map-state');
|
||||
if (!showButton || !hideButton || !state || !window.PcpDynamicPlanMapLayers) {
|
||||
return;
|
||||
}
|
||||
|
||||
const visible = window.PcpDynamicPlanMapLayers.isRunVisible(run.runId);
|
||||
showButton.disabled = visible;
|
||||
hideButton.disabled = !visible;
|
||||
state.textContent = visible
|
||||
? 'Расчет добавлен в слой карты.'
|
||||
: 'Расчет не отображается на карте.';
|
||||
}
|
||||
|
||||
function attachMapControls(run) {
|
||||
const showButton = document.getElementById('dynamic-plan-map-show');
|
||||
const hideButton = document.getElementById('dynamic-plan-map-hide');
|
||||
const openButton = document.getElementById('dynamic-plan-map-open');
|
||||
if (!showButton || !hideButton || !openButton || !window.PcpDynamicPlanMapLayers) {
|
||||
return;
|
||||
}
|
||||
|
||||
showButton.addEventListener('click', () => {
|
||||
window.PcpDynamicPlanMapLayers.showRun(run);
|
||||
updateMapControls(run);
|
||||
showAlert('Расчет добавлен в слой карты. Откройте вкладку map для просмотра.', 'success');
|
||||
});
|
||||
hideButton.addEventListener('click', () => {
|
||||
window.PcpDynamicPlanMapLayers.hideRun(run.runId);
|
||||
updateMapControls(run);
|
||||
showAlert('Расчет скрыт с карты.', 'success');
|
||||
});
|
||||
openButton.addEventListener('click', () => {
|
||||
window.location.href = '/map';
|
||||
});
|
||||
updateMapControls(run);
|
||||
}
|
||||
|
||||
function clearPolling() {
|
||||
if (pollingTimer) {
|
||||
clearTimeout(pollingTimer);
|
||||
pollingTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function renderRun(payload) {
|
||||
selectedRun = payload;
|
||||
const result = document.getElementById('dynamic-plan-result');
|
||||
const meta = document.getElementById('dynamic-plan-result-meta');
|
||||
meta.textContent = `Статус запуска: ${statusText(payload.status)}`;
|
||||
result.innerHTML = `
|
||||
<div class="dynamic-plan-result-grid mb-4">
|
||||
${field('Run ID', payload.runId)}
|
||||
${field('Статус запуска', statusText(payload.status))}
|
||||
${field('Вариант расчета', calculationModeText(payload.calculationMode))}
|
||||
${field('Виток', revolutionModeText(payload.revolutionMode))}
|
||||
${field('Фильтр покрытия', booleanText(payload.filterCoveredRoutes))}
|
||||
${field('Заявка', payload.requestId)}
|
||||
${field('КА', Array.isArray(payload.satelliteIds) ? payload.satelliteIds.join(', ') : '-')}
|
||||
${field('Начало интервала', formatDateTime(payload.calculationStart))}
|
||||
${field('Конец интервала', formatDateTime(payload.calculationEnd))}
|
||||
${field('Статус результата', payload.resultStatus ?? '-')}
|
||||
${field('Маршруты', payload.routesCount ?? '-')}
|
||||
${field('Длительность', formatDuration(payload.durationMs))}
|
||||
</div>
|
||||
${payload.errorMessage ? `<div class="alert alert-danger mb-4" role="alert">${escapeHtml(payload.errorMessage)}</div>` : ''}
|
||||
${mapControls(payload)}
|
||||
<div class="dynamic-plan-section-title mb-2">Run payload</div>
|
||||
<pre class="dynamic-plan-json mb-0">${escapeHtml(JSON.stringify(payload, null, 2))}</pre>
|
||||
`;
|
||||
attachMapControls(payload);
|
||||
}
|
||||
|
||||
function renderResult(payload, runId) {
|
||||
const run = selectedRun && String(selectedRun.runId) === String(runId)
|
||||
? {
|
||||
...selectedRun,
|
||||
status: 'COMPLETED',
|
||||
requestId: payload.requestId ?? selectedRun.requestId,
|
||||
routesCount: payload.routesCount ?? selectedRun.routesCount,
|
||||
calculationMode: payload.calculationMode ?? selectedRun.calculationMode,
|
||||
revolutionMode: payload.revolutionMode ?? selectedRun.revolutionMode,
|
||||
filterCoveredRoutes: payload.filterCoveredRoutes ?? selectedRun.filterCoveredRoutes,
|
||||
lastRouteStart: payload.lastRouteStart ?? selectedRun.lastRouteStart,
|
||||
lastRouteEnd: payload.lastRouteEnd ?? selectedRun.lastRouteEnd,
|
||||
coveredAreaPercent: payload.coveredAreaPercent ?? selectedRun.coveredAreaPercent
|
||||
}
|
||||
: {
|
||||
runId,
|
||||
status: 'COMPLETED',
|
||||
requestId: payload.requestId,
|
||||
routesCount: payload.routesCount,
|
||||
calculationMode: payload.calculationMode,
|
||||
revolutionMode: payload.revolutionMode,
|
||||
filterCoveredRoutes: payload.filterCoveredRoutes,
|
||||
lastRouteStart: payload.lastRouteStart,
|
||||
lastRouteEnd: payload.lastRouteEnd,
|
||||
coveredAreaPercent: payload.coveredAreaPercent
|
||||
};
|
||||
selectedRun = run;
|
||||
const result = document.getElementById('dynamic-plan-result');
|
||||
const meta = document.getElementById('dynamic-plan-result-meta');
|
||||
meta.textContent = `Расчет ${runId}: готов`;
|
||||
result.innerHTML = `
|
||||
<div class="dynamic-plan-result-grid mb-4">
|
||||
${field('Run ID', runId)}
|
||||
${field('Статус результата', payload.status)}
|
||||
${field('Вариант расчета', calculationModeText(payload.calculationMode))}
|
||||
${field('Виток', revolutionModeText(payload.revolutionMode))}
|
||||
${field('Фильтр покрытия', booleanText(payload.filterCoveredRoutes))}
|
||||
${field('Заявка', payload.requestId)}
|
||||
${field('КА', Array.isArray(payload.satelliteIds) ? payload.satelliteIds.join(', ') : '-')}
|
||||
${field('Отсутствующие КА', Array.isArray(payload.missingSatelliteIds) && payload.missingSatelliteIds.length ? payload.missingSatelliteIds.join(', ') : '-')}
|
||||
${field('Маршруты', payload.routesCount ?? '-')}
|
||||
${field('Последний маршрут', routeIntervalText(payload.lastRouteStart, payload.lastRouteEnd))}
|
||||
${field('Процент снятой территории', percentText(payload.coveredAreaPercent))}
|
||||
${field('Длительность', formatDuration(payload.durationMs))}
|
||||
</div>
|
||||
${mapControls(run)}
|
||||
<div class="dynamic-plan-section-title mb-2">Result payload</div>
|
||||
<pre class="dynamic-plan-json mb-0">${escapeHtml(JSON.stringify(payload, null, 2))}</pre>
|
||||
`;
|
||||
attachMapControls(run);
|
||||
}
|
||||
|
||||
function setSubmitLoading(isLoading) {
|
||||
const button = document.getElementById('dynamic-plan-submit');
|
||||
if (!button.dataset.defaultText) {
|
||||
button.dataset.defaultText = button.innerHTML;
|
||||
}
|
||||
button.disabled = isLoading;
|
||||
button.innerHTML = isLoading
|
||||
? '<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>Запуск'
|
||||
: button.dataset.defaultText;
|
||||
}
|
||||
|
||||
async function loadResult(runId) {
|
||||
const payload = await requestJson(`/api/dynamic-plan/runs/${encodeURIComponent(runId)}/result`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
renderResult(payload, runId);
|
||||
showAlert('Расчет завершен, результат получен', 'success');
|
||||
}
|
||||
|
||||
async function openRun(runId) {
|
||||
if (!runId) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearPolling();
|
||||
showAlert('');
|
||||
try {
|
||||
const payload = await requestJson(`/api/dynamic-plan/runs/${encodeURIComponent(runId)}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
renderRun(payload);
|
||||
|
||||
if (payload.status === 'COMPLETED') {
|
||||
await loadResult(runId);
|
||||
return;
|
||||
}
|
||||
if (payload.status === 'FAILED') {
|
||||
showAlert(`Расчет завершился с ошибкой: ${payload.errorMessage || 'неизвестная ошибка'}`);
|
||||
return;
|
||||
}
|
||||
|
||||
pollingTimer = setTimeout(() => pollRun(runId), pollingDelayMs);
|
||||
} catch (error) {
|
||||
showAlert(`Ошибка получения статуса расчета: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function pollRun(runId) {
|
||||
try {
|
||||
const payload = await requestJson(`/api/dynamic-plan/runs/${encodeURIComponent(runId)}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
renderRun(payload);
|
||||
|
||||
if (payload.status === 'COMPLETED') {
|
||||
clearPolling();
|
||||
await loadResult(runId);
|
||||
loadRuns().catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
if (payload.status === 'FAILED') {
|
||||
clearPolling();
|
||||
loadRuns().catch(() => undefined);
|
||||
showAlert(`Расчет завершился с ошибкой: ${payload.errorMessage || 'неизвестная ошибка'}`);
|
||||
return;
|
||||
}
|
||||
|
||||
pollingTimer = setTimeout(() => pollRun(runId), pollingDelayMs);
|
||||
} catch (error) {
|
||||
clearPolling();
|
||||
showAlert(`Ошибка получения статуса расчета: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitForm(event) {
|
||||
event.preventDefault();
|
||||
const requestId = document.getElementById('dynamic-plan-request-id').value.trim();
|
||||
const satelliteIds = parseSatelliteIds(document.getElementById('dynamic-plan-satellite-ids').value);
|
||||
const calculationMode = document.querySelector('input[name="dynamic-plan-mode"]:checked')?.value || 'FULL';
|
||||
const revolutionMode = document.querySelector('input[name="dynamic-plan-revolution-mode"]:checked')?.value || 'BOTH';
|
||||
const filterCoveredRoutes = document.getElementById('dynamic-plan-filter-covered-routes').checked;
|
||||
const calculationStartDate = document.getElementById('dynamic-plan-interval-start').value;
|
||||
const calculationEndDate = document.getElementById('dynamic-plan-interval-end').value;
|
||||
|
||||
if (!uuidPattern.test(requestId)) {
|
||||
showAlert('Укажите корректный UUID заявки');
|
||||
return;
|
||||
}
|
||||
if (satelliteIds.length === 0) {
|
||||
showAlert('Укажите хотя бы один идентификатор КА');
|
||||
return;
|
||||
}
|
||||
if (!calculationStartDate || !calculationEndDate) {
|
||||
showAlert('Укажите дату начала и дату конца интервала расчета');
|
||||
return;
|
||||
}
|
||||
if (calculationStartDate > calculationEndDate) {
|
||||
showAlert('Дата начала интервала расчета должна быть не позже даты окончания');
|
||||
return;
|
||||
}
|
||||
|
||||
const calculationStart = startOfDay(calculationStartDate);
|
||||
const calculationEnd = endOfDay(calculationEndDate);
|
||||
|
||||
setSubmitLoading(true);
|
||||
clearPolling();
|
||||
showAlert('');
|
||||
try {
|
||||
const payload = await requestJson('/api/dynamic-plan/calc', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
requestId,
|
||||
satelliteIds,
|
||||
calculationMode,
|
||||
revolutionMode,
|
||||
filterCoveredRoutes,
|
||||
calculationStart,
|
||||
calculationEnd
|
||||
})
|
||||
});
|
||||
renderRun(payload);
|
||||
showAlert('Расчет поставлен в очередь', 'success');
|
||||
loadRuns().catch(() => undefined);
|
||||
pollingTimer = setTimeout(() => pollRun(payload.runId), pollingDelayMs);
|
||||
} catch (error) {
|
||||
showAlert(`Ошибка запуска расчета: ${error.message}`);
|
||||
} finally {
|
||||
setSubmitLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('dynamic-plan-form').addEventListener('submit', submitForm);
|
||||
document.getElementById('dynamic-plan-runs-refresh').addEventListener('click', () => {
|
||||
loadRuns().catch(error => showAlert(`Ошибка обновления истории запусков: ${error.message}`));
|
||||
});
|
||||
loadRuns().catch(error => showAlert(`Ошибка загрузки истории запусков: ${error.message}`));
|
||||
})();
|
||||
@@ -0,0 +1,327 @@
|
||||
(function () {
|
||||
const pageRoot = document.getElementById('group-state-page');
|
||||
if (!pageRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const apiBase = '/api/catalog/group-states';
|
||||
const palette = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', '#8c564b', '#17becf', '#bcbd22'];
|
||||
|
||||
const state = {
|
||||
groups: [],
|
||||
selectedGroupId: null,
|
||||
details: null
|
||||
};
|
||||
|
||||
function showAlert(message, type = 'danger') {
|
||||
const container = document.getElementById('group-state-alert');
|
||||
if (!message) {
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = `<div class="alert alert-${type} mb-3" role="alert">${message}</div>`;
|
||||
}
|
||||
|
||||
async function requestJson(url, options = {}) {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
...options
|
||||
});
|
||||
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isJson = response.headers.get('content-type')?.includes('application/json');
|
||||
const payload = isJson ? await response.json() : await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
if (payload && typeof payload === 'object') {
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
throw new Error(Object.values(payload).join('; '));
|
||||
}
|
||||
throw new Error(payload || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function formatDateTimeLabel(value) {
|
||||
return value ? value.replace('T', ' ') : '-';
|
||||
}
|
||||
|
||||
function formatInputDateTime(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
return value.length >= 19 ? value.slice(0, 19) : value;
|
||||
}
|
||||
|
||||
function formatAngle(value) {
|
||||
return `${value.toFixed(2)}deg`;
|
||||
}
|
||||
|
||||
function renderGroupList() {
|
||||
const tableBody = document.getElementById('group-state-list');
|
||||
tableBody.innerHTML = '';
|
||||
|
||||
if (!state.groups.length) {
|
||||
tableBody.innerHTML = '<tr><td colspan="3" class="text-center text-muted py-4">Группировки не найдены</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
state.groups.forEach(group => {
|
||||
const tr = document.createElement('tr');
|
||||
if (group.groupId === state.selectedGroupId) {
|
||||
tr.classList.add('table-active');
|
||||
}
|
||||
const interval = group.availableInterval
|
||||
? `${formatDateTimeLabel(group.availableInterval.timeStart)} - ${formatDateTimeLabel(group.availableInterval.timeStop)}`
|
||||
: 'Нет пересечения';
|
||||
tr.innerHTML = `
|
||||
<td>
|
||||
<div class="fw-semibold">${group.groupName}</div>
|
||||
<div class="catalog-helper">#${group.groupId}</div>
|
||||
</td>
|
||||
<td>${group.satelliteCount}</td>
|
||||
<td class="group-state-interval-cell">${interval}</td>
|
||||
`;
|
||||
tr.addEventListener('click', () => selectGroup(group.groupId));
|
||||
tableBody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function renderTable(details) {
|
||||
const empty = document.getElementById('group-state-table-empty');
|
||||
const wrap = document.getElementById('group-state-table-wrap');
|
||||
|
||||
if (!details || !details.availableInterval || !details.satellites.length) {
|
||||
empty.classList.remove('d-none');
|
||||
wrap.classList.add('d-none');
|
||||
wrap.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const headerCells = details.satellites
|
||||
.map(satellite => `<th>${escapeHtml(satellite.name)}<div class="catalog-helper">${escapeHtml(satellite.code || String(satellite.satelliteId))}</div></th>`)
|
||||
.join('');
|
||||
const rows = details.matrix.map(row => {
|
||||
const satellite = details.satellites.find(item => item.satelliteId === row.satelliteId);
|
||||
const cells = row.cells.map(cell => {
|
||||
const isDiagonal = cell.rowSatelliteId === cell.columnSatelliteId;
|
||||
if (isDiagonal) {
|
||||
return '<td class="group-state-matrix-diagonal">-</td>';
|
||||
}
|
||||
return `
|
||||
<td>
|
||||
<div class="group-state-matrix-cell">
|
||||
<div>dOMEGAB: ${formatAngle(cell.deltaOmegabDeg)}</div>
|
||||
<div>du: ${formatAngle(cell.deltaUDeg)}</div>
|
||||
</div>
|
||||
</td>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<th scope="row">
|
||||
${escapeHtml(satellite?.name || String(row.satelliteId))}
|
||||
<div class="catalog-helper">${escapeHtml(satellite?.code || String(row.satelliteId))}</div>
|
||||
</th>
|
||||
${cells}
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
wrap.innerHTML = `
|
||||
<table class="table table-bordered align-middle group-state-matrix-table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Спутник</th>
|
||||
${headerCells}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
`;
|
||||
empty.classList.add('d-none');
|
||||
wrap.classList.remove('d-none');
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function polarPoint(angleDeg, radius, center) {
|
||||
const angleRad = (angleDeg - 90) * Math.PI / 180;
|
||||
return {
|
||||
x: center + Math.cos(angleRad) * radius,
|
||||
y: center + Math.sin(angleRad) * radius
|
||||
};
|
||||
}
|
||||
|
||||
function renderPolarChart(title, satellites, key, valueLabel) {
|
||||
const size = 320;
|
||||
const center = size / 2;
|
||||
const radius = 112;
|
||||
const innerRadius = 92;
|
||||
|
||||
const ticks = satellites.map((satellite, index) => {
|
||||
const color = palette[index % palette.length];
|
||||
const start = polarPoint(satellite[key], innerRadius, center);
|
||||
const end = polarPoint(satellite[key], radius, center);
|
||||
return `<line x1="${start.x}" y1="${start.y}" x2="${end.x}" y2="${end.y}" stroke="${color}" stroke-width="6" stroke-linecap="round"></line>`;
|
||||
}).join('');
|
||||
|
||||
const legend = satellites.map((satellite, index) => {
|
||||
const color = palette[index % palette.length];
|
||||
return `
|
||||
<div class="group-state-legend-item">
|
||||
<span class="group-state-legend-color" style="background:${color}"></span>
|
||||
<span>${escapeHtml(satellite.name)}: ${formatAngle(satellite[key])}</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div class="group-state-chart-card">
|
||||
<div class="group-state-chart-title">${title}</div>
|
||||
<svg viewBox="0 0 ${size} ${size}" class="group-state-chart-svg" aria-label="${escapeHtml(title)}">
|
||||
<circle cx="${center}" cy="${center}" r="${radius}" class="group-state-chart-ring"></circle>
|
||||
<circle cx="${center}" cy="${center}" r="${innerRadius}" class="group-state-chart-inner"></circle>
|
||||
${ticks}
|
||||
<text x="${center}" y="${center - 4}" text-anchor="middle" class="group-state-chart-center">${escapeHtml(valueLabel)}</text>
|
||||
<text x="${center}" y="${center + 18}" text-anchor="middle" class="group-state-chart-center-value">${satellites.length}</text>
|
||||
</svg>
|
||||
<div class="group-state-chart-legend">${legend}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderCharts(details) {
|
||||
const empty = document.getElementById('group-state-charts-empty');
|
||||
const container = document.getElementById('group-state-charts');
|
||||
|
||||
if (!details || !details.availableInterval || !details.satellites.length) {
|
||||
empty.classList.remove('d-none');
|
||||
container.classList.add('d-none');
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = [
|
||||
renderPolarChart('ВУЗ орбиты', details.satellites, 'omegabDeg', 'OMEGAB'),
|
||||
renderPolarChart('Аргумент широты', details.satellites, 'uDeg', 'U')
|
||||
].join('');
|
||||
empty.classList.add('d-none');
|
||||
container.classList.remove('d-none');
|
||||
}
|
||||
|
||||
function renderSummary(details) {
|
||||
const summary = document.getElementById('group-state-summary');
|
||||
const timeInput = document.getElementById('group-state-time');
|
||||
const applyButton = document.getElementById('group-state-apply');
|
||||
|
||||
if (!details) {
|
||||
summary.textContent = 'Выберите группировку слева.';
|
||||
timeInput.value = '';
|
||||
timeInput.min = '';
|
||||
timeInput.max = '';
|
||||
timeInput.disabled = true;
|
||||
applyButton.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!details.availableInterval) {
|
||||
summary.textContent = `${details.groupName}: нет общего доступного интервала расчета по всем спутникам ОГ.`;
|
||||
timeInput.value = '';
|
||||
timeInput.min = '';
|
||||
timeInput.max = '';
|
||||
timeInput.disabled = true;
|
||||
applyButton.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
summary.innerHTML = `
|
||||
<div><strong>${escapeHtml(details.groupName)}</strong></div>
|
||||
<div>Доступный интервал: ${formatDateTimeLabel(details.availableInterval.timeStart)} - ${formatDateTimeLabel(details.availableInterval.timeStop)}</div>
|
||||
`;
|
||||
timeInput.disabled = false;
|
||||
applyButton.disabled = false;
|
||||
timeInput.min = formatInputDateTime(details.availableInterval.timeStart);
|
||||
timeInput.max = formatInputDateTime(details.availableInterval.timeStop);
|
||||
timeInput.value = formatInputDateTime(details.calculationTime || details.availableInterval.timeStart);
|
||||
}
|
||||
|
||||
function renderDetails(details) {
|
||||
state.details = details;
|
||||
renderSummary(details);
|
||||
renderTable(details);
|
||||
renderCharts(details);
|
||||
}
|
||||
|
||||
async function selectGroup(groupId, requestedTime = null) {
|
||||
state.selectedGroupId = groupId;
|
||||
renderGroupList();
|
||||
|
||||
const query = requestedTime ? `?time=${encodeURIComponent(requestedTime)}` : '';
|
||||
const details = await requestJson(`${apiBase}/${groupId}${query}`);
|
||||
renderDetails(details);
|
||||
}
|
||||
|
||||
async function loadGroups() {
|
||||
state.groups = await requestJson(apiBase);
|
||||
renderGroupList();
|
||||
|
||||
if (!state.groups.length) {
|
||||
renderDetails(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedId = state.groups.some(group => group.groupId === state.selectedGroupId)
|
||||
? state.selectedGroupId
|
||||
: state.groups[0].groupId;
|
||||
await selectGroup(selectedId);
|
||||
}
|
||||
|
||||
document.getElementById('group-state-refresh').addEventListener('click', async () => {
|
||||
try {
|
||||
showAlert('');
|
||||
await loadGroups();
|
||||
} catch (error) {
|
||||
showAlert(error.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('group-state-apply').addEventListener('click', async () => {
|
||||
if (!state.selectedGroupId) {
|
||||
showAlert('Сначала выберите группировку');
|
||||
return;
|
||||
}
|
||||
|
||||
const time = document.getElementById('group-state-time').value;
|
||||
if (!time) {
|
||||
showAlert('Выберите момент времени');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
showAlert('');
|
||||
await selectGroup(state.selectedGroupId, time);
|
||||
} catch (error) {
|
||||
showAlert(error.message);
|
||||
}
|
||||
});
|
||||
|
||||
renderDetails(null);
|
||||
loadGroups().catch(error => showAlert(error.message));
|
||||
})();
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,247 @@
|
||||
(function () {
|
||||
const pageData = window.rvaPageData;
|
||||
if (!pageData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const apiBase = '/api/rva';
|
||||
const stationsByNumber = new Map((pageData.stations || []).map(station => [station.number, station]));
|
||||
|
||||
const resultState = {
|
||||
mode: 'common',
|
||||
rows: []
|
||||
};
|
||||
|
||||
function showAlert(message, type = 'danger') {
|
||||
const container = document.getElementById('rva-alert');
|
||||
if (!message) {
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = `<div class="alert alert-${type} mb-3" role="alert">${message}</div>`;
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return date.toLocaleString('ru-RU');
|
||||
}
|
||||
|
||||
function selectedMode() {
|
||||
return document.querySelector('input[name="rva-mode"]:checked')?.value || 'common';
|
||||
}
|
||||
|
||||
function selectedStations() {
|
||||
return Array.from(document.querySelectorAll('.rva-station-checkbox:checked'))
|
||||
.map(input => Number(input.dataset.number))
|
||||
.map(number => stationsByNumber.get(number))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function requestJson(url, payload) {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const isJson = response.headers.get('content-type')?.includes('application/json');
|
||||
const body = isJson ? await response.json() : await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
if (body && typeof body === 'object') {
|
||||
throw new Error(body.error || Object.values(body).join('; '));
|
||||
}
|
||||
throw new Error(body || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
function renderResults() {
|
||||
const head = document.getElementById('rva-results-head');
|
||||
const body = document.getElementById('rva-results-body');
|
||||
const meta = document.getElementById('rva-result-meta');
|
||||
const downloadButton = document.getElementById('rva-download');
|
||||
|
||||
if (!resultState.rows.length) {
|
||||
head.innerHTML = '';
|
||||
body.innerHTML = '<tr><td class="text-center text-muted py-5">Нет результатов</td></tr>';
|
||||
meta.textContent = 'Выберите параметры и запустите расчет.';
|
||||
downloadButton.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (resultState.mode === 'common') {
|
||||
head.innerHTML = `
|
||||
<tr>
|
||||
<th>Станция</th>
|
||||
<th>Виток</th>
|
||||
<th>Начало</th>
|
||||
<th>Максимум</th>
|
||||
<th>Конец</th>
|
||||
<th>Длительность, с</th>
|
||||
</tr>
|
||||
`;
|
||||
body.innerHTML = resultState.rows.map(row => `
|
||||
<tr>
|
||||
<td>${row.stationId}</td>
|
||||
<td>${row.revolution}</td>
|
||||
<td>${formatDateTime(row.onStart.time)}</td>
|
||||
<td>${formatDateTime(row.onMaximum.time)}</td>
|
||||
<td>${formatDateTime(row.onStop.time)}</td>
|
||||
<td>${row.duration}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
} else if (resultState.mode === 'merge') {
|
||||
head.innerHTML = `
|
||||
<tr>
|
||||
<th>Начало</th>
|
||||
<th>Конец</th>
|
||||
<th>Длительность, с</th>
|
||||
</tr>
|
||||
`;
|
||||
body.innerHTML = resultState.rows.map(row => `
|
||||
<tr>
|
||||
<td>${formatDateTime(row.begin)}</td>
|
||||
<td>${formatDateTime(row.end)}</td>
|
||||
<td>${row.durationSec}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
} else {
|
||||
head.innerHTML = `
|
||||
<tr>
|
||||
<th>Виток</th>
|
||||
<th>Суммарная длительность, с</th>
|
||||
</tr>
|
||||
`;
|
||||
body.innerHTML = resultState.rows.map(row => `
|
||||
<tr>
|
||||
<td>${row.revolution}</td>
|
||||
<td>${row.durationSec}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
meta.textContent = `Получено строк: ${resultState.rows.length}`;
|
||||
downloadButton.disabled = false;
|
||||
}
|
||||
|
||||
function csvEscape(value) {
|
||||
const text = String(value ?? '');
|
||||
if (text.includes(';') || text.includes('"') || text.includes('\n')) {
|
||||
return `"${text.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function downloadCsv() {
|
||||
if (!resultState.rows.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = resultState.mode === 'common'
|
||||
? [
|
||||
['stationId', 'revolution', 'onStart', 'onMaximum', 'onStop', 'duration'],
|
||||
...resultState.rows.map(row => [
|
||||
row.stationId,
|
||||
row.revolution,
|
||||
row.onStart.time,
|
||||
row.onMaximum.time,
|
||||
row.onStop.time,
|
||||
row.duration
|
||||
])
|
||||
]
|
||||
: resultState.mode === 'merge'
|
||||
? [
|
||||
['begin', 'end', 'durationSec'],
|
||||
...resultState.rows.map(row => [
|
||||
row.begin,
|
||||
row.end,
|
||||
row.durationSec
|
||||
])
|
||||
]
|
||||
: [
|
||||
['revolution', 'durationSec'],
|
||||
...resultState.rows.map(row => [
|
||||
row.revolution,
|
||||
row.durationSec
|
||||
])
|
||||
];
|
||||
|
||||
const csv = lines.map(line => line.map(csvEscape).join(';')).join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `rva-${resultState.mode}.csv`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
document.getElementById('rva-select-all').addEventListener('click', () => {
|
||||
document.querySelectorAll('.rva-station-checkbox').forEach(input => {
|
||||
input.checked = true;
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('rva-reset').addEventListener('click', () => {
|
||||
setTimeout(() => {
|
||||
showAlert('');
|
||||
resultState.rows = [];
|
||||
resultState.mode = selectedMode();
|
||||
renderResults();
|
||||
}, 0);
|
||||
});
|
||||
|
||||
document.getElementById('rva-download').addEventListener('click', downloadCsv);
|
||||
|
||||
document.getElementById('rva-form').addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
showAlert('');
|
||||
|
||||
const satelliteId = Number(document.getElementById('rva-satellite').value);
|
||||
const duration = Number(document.getElementById('rva-duration').value);
|
||||
const mode = selectedMode();
|
||||
const stations = selectedStations();
|
||||
|
||||
if (!satelliteId) {
|
||||
showAlert('Нужно выбрать спутник');
|
||||
return;
|
||||
}
|
||||
if (!duration || duration < 1) {
|
||||
showAlert('Горизонт расчета должен быть положительным');
|
||||
return;
|
||||
}
|
||||
if (!stations.length) {
|
||||
showAlert('Нужно выбрать хотя бы одну станцию');
|
||||
return;
|
||||
}
|
||||
|
||||
const submitButton = event.submitter;
|
||||
submitButton.disabled = true;
|
||||
submitButton.textContent = 'Расчет...';
|
||||
|
||||
try {
|
||||
const rows = await requestJson(`${apiBase}/${mode}/${satelliteId}/${duration}`, stations);
|
||||
resultState.mode = mode;
|
||||
resultState.rows = rows;
|
||||
renderResults();
|
||||
showAlert('Расчет выполнен', 'success');
|
||||
} catch (error) {
|
||||
showAlert(error.message || 'Не удалось выполнить расчет');
|
||||
} finally {
|
||||
submitButton.disabled = false;
|
||||
submitButton.textContent = 'Рассчитать';
|
||||
}
|
||||
});
|
||||
|
||||
renderResults();
|
||||
}());
|
||||
@@ -0,0 +1,322 @@
|
||||
(function () {
|
||||
const pageRoot = document.getElementById('satellite-pdcm-page');
|
||||
if (!pageRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const apiBase = '/api/catalog/satellites/pdcm';
|
||||
const state = {
|
||||
satellites: [],
|
||||
selectedSatelliteId: null,
|
||||
details: null
|
||||
};
|
||||
|
||||
function showAlert(message, type = 'danger') {
|
||||
const container = document.getElementById('satellite-pdcm-alert');
|
||||
if (!message) {
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = `<div class="alert alert-${type} mb-3" role="alert">${escapeHtml(message)}</div>`;
|
||||
}
|
||||
|
||||
async function requestJson(url, options = {}) {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
...options
|
||||
});
|
||||
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isJson = response.headers.get('content-type')?.includes('application/json');
|
||||
const payload = isJson ? await response.json() : await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
if (payload && typeof payload === 'object') {
|
||||
throw new Error(payload.error || Object.values(payload).join('; '));
|
||||
}
|
||||
throw new Error(payload || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function formatDateTimeLabel(value) {
|
||||
return value ? value.replace('T', ' ') : '-';
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return Number(value ?? 0).toFixed(3);
|
||||
}
|
||||
|
||||
function formatNullableNumber(value) {
|
||||
return value == null ? '-' : formatNumber(value);
|
||||
}
|
||||
|
||||
function renderExportState(details) {
|
||||
const exportButton = document.getElementById('satellite-pdcm-export');
|
||||
exportButton.disabled = !details || !details.ascNodes || !details.ascNodes.length;
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
const tableBody = document.getElementById('satellite-pdcm-list');
|
||||
tableBody.innerHTML = '';
|
||||
|
||||
if (!state.satellites.length) {
|
||||
tableBody.innerHTML = '<tr><td colspan="2" class="text-center text-muted py-4">Спутники не найдены</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
state.satellites.forEach(satellite => {
|
||||
const tr = document.createElement('tr');
|
||||
if (satellite.satelliteId === state.selectedSatelliteId) {
|
||||
tr.classList.add('table-active');
|
||||
}
|
||||
const interval = satellite.availableInterval
|
||||
? `
|
||||
<span class="satellite-pdcm-interval-line">${escapeHtml(formatDateTimeLabel(satellite.availableInterval.timeStart))}</span>
|
||||
<span class="satellite-pdcm-interval-line">${escapeHtml(formatDateTimeLabel(satellite.availableInterval.timeStop))}</span>
|
||||
`
|
||||
: 'Нет ПДЦМ';
|
||||
tr.innerHTML = `
|
||||
<td>
|
||||
<div class="fw-semibold satellite-pdcm-satellite-name">
|
||||
<span class="satellite-pdcm-status ${satellite.activeInterval ? 'satellite-pdcm-status-active' : 'satellite-pdcm-status-inactive'}"></span>
|
||||
${escapeHtml(satellite.name)}
|
||||
</div>
|
||||
</td>
|
||||
<td class="satellite-pdcm-interval-cell">${interval}</td>
|
||||
`;
|
||||
tr.addEventListener('click', () => selectSatellite(satellite.satelliteId));
|
||||
tableBody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function renderSummary(details) {
|
||||
const summary = document.getElementById('satellite-pdcm-summary');
|
||||
if (!details) {
|
||||
summary.innerHTML = '<div class="catalog-helper">Выберите спутник слева.</div>';
|
||||
renderExportState(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = details.availableInterval
|
||||
? `${formatDateTimeLabel(details.availableInterval.timeStart)} - ${formatDateTimeLabel(details.availableInterval.timeStop)}`
|
||||
: 'Нет доступного интервала';
|
||||
const status = details.activeInterval ? 'Активен сейчас' : 'Неактивен сейчас';
|
||||
|
||||
summary.innerHTML = `
|
||||
<div class="satellite-pdcm-summary-grid">
|
||||
<div class="satellite-pdcm-summary-card">
|
||||
<div class="satellite-pdcm-summary-label">Спутник</div>
|
||||
<div class="satellite-pdcm-summary-value">${escapeHtml(details.name)}</div>
|
||||
<div class="catalog-helper">${escapeHtml(details.code || String(details.satelliteId))} · ID ${details.satelliteId}</div>
|
||||
</div>
|
||||
<div class="satellite-pdcm-summary-card">
|
||||
<div class="satellite-pdcm-summary-label">Статус</div>
|
||||
<div class="satellite-pdcm-summary-value">${escapeHtml(status)}</div>
|
||||
</div>
|
||||
<div class="satellite-pdcm-summary-card">
|
||||
<div class="satellite-pdcm-summary-label">Интервал ПДЦМ</div>
|
||||
<div class="satellite-pdcm-summary-value">${escapeHtml(interval)}</div>
|
||||
</div>
|
||||
<div class="satellite-pdcm-summary-card">
|
||||
<div class="satellite-pdcm-summary-label">Записей asc-node</div>
|
||||
<div class="satellite-pdcm-summary-value">${details.ascNodes.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
renderExportState(details);
|
||||
}
|
||||
|
||||
function renderTable(details) {
|
||||
const empty = document.getElementById('satellite-pdcm-table-empty');
|
||||
const wrap = document.getElementById('satellite-pdcm-table-wrap');
|
||||
|
||||
if (!details || !details.availableInterval || !details.ascNodes.length) {
|
||||
empty.classList.remove('d-none');
|
||||
wrap.classList.add('d-none');
|
||||
wrap.innerHTML = '';
|
||||
renderExportState(details);
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = details.ascNodes.map(item => `
|
||||
<tr>
|
||||
<td>${escapeHtml(formatDateTimeLabel(item.time))}</td>
|
||||
<td>${item.revolution}</td>
|
||||
<td>${formatNumber(item.long)}</td>
|
||||
<td>${formatNumber(item.height)}</td>
|
||||
<td>${escapeHtml(formatDateTimeLabel(item.localMeanTime))}</td>
|
||||
<td>${formatNullableNumber(item.keps?.ael)}</td>
|
||||
<td>${formatNullableNumber(item.keps?.e)}</td>
|
||||
<td>${formatNullableNumber(item.keps?.inkl)}</td>
|
||||
<td>${formatNullableNumber(item.keps?.omega)}</td>
|
||||
<td>${formatNullableNumber(item.keps?.w)}</td>
|
||||
<td>${formatNumber(item.x)}</td>
|
||||
<td>${formatNumber(item.y)}</td>
|
||||
<td>${formatNumber(item.z)}</td>
|
||||
<td>${formatNumber(item.vx)}</td>
|
||||
<td>${formatNumber(item.vy)}</td>
|
||||
<td>${formatNumber(item.vz)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
wrap.innerHTML = `
|
||||
<table class="table table-bordered align-middle satellite-pdcm-table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Время</th>
|
||||
<th>Виток</th>
|
||||
<th>Долгота</th>
|
||||
<th>Высота</th>
|
||||
<th>Местное ср. время</th>
|
||||
<th>a</th>
|
||||
<th>e</th>
|
||||
<th>i</th>
|
||||
<th>Ω</th>
|
||||
<th>ω</th>
|
||||
<th>X</th>
|
||||
<th>Y</th>
|
||||
<th>Z</th>
|
||||
<th>Vx</th>
|
||||
<th>Vy</th>
|
||||
<th>Vz</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
`;
|
||||
empty.classList.add('d-none');
|
||||
wrap.classList.remove('d-none');
|
||||
renderExportState(details);
|
||||
}
|
||||
|
||||
function buildCsvValue(value) {
|
||||
const text = String(value ?? '');
|
||||
return `"${text.replaceAll('"', '""')}"`;
|
||||
}
|
||||
|
||||
function exportCurrentCsv() {
|
||||
if (!state.details || !state.details.ascNodes.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = [
|
||||
[
|
||||
'Время',
|
||||
'Виток',
|
||||
'Долгота',
|
||||
'Высота',
|
||||
'Местное ср. время',
|
||||
'a',
|
||||
'e',
|
||||
'i',
|
||||
'Omega',
|
||||
'omega',
|
||||
'X',
|
||||
'Y',
|
||||
'Z',
|
||||
'Vx',
|
||||
'Vy',
|
||||
'Vz'
|
||||
],
|
||||
...state.details.ascNodes.map(item => [
|
||||
formatDateTimeLabel(item.time),
|
||||
item.revolution,
|
||||
formatNumber(item.long),
|
||||
formatNumber(item.height),
|
||||
formatDateTimeLabel(item.localMeanTime),
|
||||
item.keps?.ael ?? '',
|
||||
item.keps?.e ?? '',
|
||||
item.keps?.inkl ?? '',
|
||||
item.keps?.omega ?? '',
|
||||
item.keps?.w ?? '',
|
||||
formatNumber(item.x),
|
||||
formatNumber(item.y),
|
||||
formatNumber(item.z),
|
||||
formatNumber(item.vx),
|
||||
formatNumber(item.vy),
|
||||
formatNumber(item.vz)
|
||||
])
|
||||
];
|
||||
|
||||
const csv = rows
|
||||
.map(row => row.map(buildCsvValue).join(';'))
|
||||
.join('\n');
|
||||
|
||||
const fileName = `asc-node-${(state.details.code || state.details.satelliteId)}.csv`;
|
||||
const blob = new Blob([`\uFEFF${csv}`], {type: 'text/csv;charset=utf-8;'});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function selectSatellite(satelliteId) {
|
||||
state.selectedSatelliteId = satelliteId;
|
||||
renderList();
|
||||
showAlert('');
|
||||
|
||||
try {
|
||||
state.details = await requestJson(`${apiBase}/${satelliteId}`);
|
||||
renderSummary(state.details);
|
||||
renderTable(state.details);
|
||||
renderList();
|
||||
} catch (error) {
|
||||
state.details = null;
|
||||
renderSummary(null);
|
||||
renderTable(null);
|
||||
showAlert(error.message || 'Не удалось загрузить ПДЦМ спутника');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSatellites() {
|
||||
showAlert('');
|
||||
try {
|
||||
state.satellites = await requestJson(apiBase);
|
||||
renderList();
|
||||
const nextSatelliteId = state.selectedSatelliteId
|
||||
?? state.satellites.find(item => item.activeInterval)?.satelliteId
|
||||
?? state.satellites[0]?.satelliteId;
|
||||
if (nextSatelliteId) {
|
||||
await selectSatellite(nextSatelliteId);
|
||||
} else {
|
||||
state.details = null;
|
||||
renderSummary(null);
|
||||
renderTable(null);
|
||||
}
|
||||
} catch (error) {
|
||||
state.satellites = [];
|
||||
state.details = null;
|
||||
renderList();
|
||||
renderSummary(null);
|
||||
renderTable(null);
|
||||
showAlert(error.message || 'Не удалось загрузить список спутников');
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('satellite-pdcm-refresh').addEventListener('click', () => {
|
||||
loadSatellites();
|
||||
});
|
||||
document.getElementById('satellite-pdcm-export').addEventListener('click', exportCurrentCsv);
|
||||
|
||||
loadSatellites();
|
||||
})();
|
||||
@@ -0,0 +1,185 @@
|
||||
(function () {
|
||||
const pageRoot = document.getElementById('stations-page');
|
||||
if (!pageRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = {
|
||||
stations: [],
|
||||
selectedStationId: null
|
||||
};
|
||||
|
||||
const tableBody = document.getElementById('stations-table-body');
|
||||
const searchInput = document.getElementById('stations-search');
|
||||
|
||||
function showAlert(message, type = 'danger') {
|
||||
const container = document.getElementById('stations-alert');
|
||||
if (!message) {
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = `<div class="alert alert-${type} mb-3" role="alert">${message}</div>`;
|
||||
}
|
||||
|
||||
async function requestJson(url, options = {}) {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
...options
|
||||
});
|
||||
|
||||
const isJson = response.headers.get('content-type')?.includes('application/json');
|
||||
const payload = isJson ? await response.json() : await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
if (payload && typeof payload === 'object') {
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
if (payload.message) {
|
||||
throw new Error(payload.message);
|
||||
}
|
||||
}
|
||||
throw new Error(payload || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function formPayload() {
|
||||
const stationId = document.getElementById('station-id').value.trim();
|
||||
return {
|
||||
id: stationId || null,
|
||||
number: Number(document.getElementById('station-number').value),
|
||||
name: document.getElementById('station-name').value.trim(),
|
||||
position: {
|
||||
lat: Number(document.getElementById('station-lat').value),
|
||||
long: Number(document.getElementById('station-long').value),
|
||||
height: Number(document.getElementById('station-height').value)
|
||||
},
|
||||
elevationMin: Number(document.getElementById('station-elevation-min').value),
|
||||
elevationMax: Number(document.getElementById('station-elevation-max').value)
|
||||
};
|
||||
}
|
||||
|
||||
function renderStations() {
|
||||
const query = searchInput.value.trim().toLowerCase();
|
||||
const items = state.stations.filter(station =>
|
||||
!query ||
|
||||
String(station.number).includes(query) ||
|
||||
station.name.toLowerCase().includes(query)
|
||||
);
|
||||
|
||||
tableBody.innerHTML = '';
|
||||
if (!items.length) {
|
||||
tableBody.innerHTML = '<tr><td colspan="4" class="text-center text-muted py-4">Станции не найдены</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
items.forEach(station => {
|
||||
const row = document.createElement('tr');
|
||||
if (station.id === state.selectedStationId) {
|
||||
row.classList.add('table-active');
|
||||
}
|
||||
row.innerHTML = `
|
||||
<td>${station.number}</td>
|
||||
<td>${station.name}</td>
|
||||
<td>${station.elevationMin ?? ''}</td>
|
||||
<td>${station.elevationMax ?? ''}</td>
|
||||
`;
|
||||
row.addEventListener('click', () => selectStation(station));
|
||||
tableBody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
document.getElementById('station-form').reset();
|
||||
document.getElementById('station-id').value = '';
|
||||
document.getElementById('station-form-title').textContent = 'Новая станция';
|
||||
state.selectedStationId = null;
|
||||
renderStations();
|
||||
showAlert('');
|
||||
}
|
||||
|
||||
function selectStation(station) {
|
||||
state.selectedStationId = station.id ?? null;
|
||||
document.getElementById('station-id').value = station.id ?? '';
|
||||
document.getElementById('station-number').value = station.number;
|
||||
document.getElementById('station-name').value = station.name;
|
||||
document.getElementById('station-lat').value = station.position?.lat ?? '';
|
||||
document.getElementById('station-long').value = station.position?.long ?? '';
|
||||
document.getElementById('station-height').value = station.position?.height ?? '';
|
||||
document.getElementById('station-elevation-min').value = station.elevationMin;
|
||||
document.getElementById('station-elevation-max').value = station.elevationMax;
|
||||
document.getElementById('station-form-title').textContent = `Станция ${station.name}`;
|
||||
renderStations();
|
||||
showAlert('');
|
||||
}
|
||||
|
||||
async function loadStations() {
|
||||
state.stations = await requestJson('/api/stations');
|
||||
renderStations();
|
||||
|
||||
if (state.selectedStationId) {
|
||||
const selected = state.stations.find(station => station.id === state.selectedStationId);
|
||||
if (selected) {
|
||||
selectStation(selected);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!document.getElementById('station-id').value) {
|
||||
resetForm();
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('stations-refresh').addEventListener('click', async () => {
|
||||
try {
|
||||
await loadStations();
|
||||
} catch (error) {
|
||||
showAlert(error.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('stations-new').addEventListener('click', resetForm);
|
||||
document.getElementById('stations-reset').addEventListener('click', () => {
|
||||
window.setTimeout(resetForm, 0);
|
||||
});
|
||||
searchInput.addEventListener('input', renderStations);
|
||||
|
||||
document.getElementById('station-form').addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
const saved = await requestJson('/api/stations', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(formPayload())
|
||||
});
|
||||
state.selectedStationId = saved.id ?? null;
|
||||
await loadStations();
|
||||
showAlert('Станция сохранена', 'success');
|
||||
} catch (error) {
|
||||
showAlert(error.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('stations-delete').addEventListener('click', async () => {
|
||||
const stationId = document.getElementById('station-id').value.trim();
|
||||
if (!stationId) {
|
||||
showAlert('Сначала выберите станцию для удаления');
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`Удалить станцию ${stationId}?`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await requestJson(`/api/stations/${stationId}`, { method: 'DELETE' });
|
||||
resetForm();
|
||||
await loadStations();
|
||||
showAlert('Станция удалена', 'success');
|
||||
} catch (error) {
|
||||
showAlert(error.message);
|
||||
}
|
||||
});
|
||||
|
||||
loadStations().catch(error => showAlert(error.message));
|
||||
})();
|
||||
@@ -0,0 +1,156 @@
|
||||
<!DOCTYPE HTML>
|
||||
<html xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.w3.org/1999/xhtml"
|
||||
layout:decorate="~{layouts/main-layout}">
|
||||
<head>
|
||||
<title>Ком.План</title>
|
||||
<link rel="stylesheet" href="/css/complex-plan.css"/>
|
||||
</head>
|
||||
<body>
|
||||
<th:block layout:fragment="content">
|
||||
<div class="complex-plan-page py-3">
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
|
||||
<div>
|
||||
<h2 class="mb-1">Ком.План</h2>
|
||||
<div class="complex-plan-helper">История расчетов комплексного плана и параметры выбранного запуска.</div>
|
||||
</div>
|
||||
<button id="complex-plan-refresh" type="button" class="btn btn-outline-primary btn-sm">Обновить</button>
|
||||
</div>
|
||||
|
||||
<div id="complex-plan-alert"></div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-12 col-xl-5">
|
||||
<div class="complex-plan-panel">
|
||||
<div class="complex-plan-panel-header">
|
||||
<div>
|
||||
<div class="complex-plan-section-title">Расчеты</div>
|
||||
<div id="complex-plan-list-meta" class="complex-plan-helper mt-1">Загрузка...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="complex-plan-table-wrap">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Статус</th>
|
||||
<th>Интервал</th>
|
||||
<th>КА</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="complex-plan-runs-body">
|
||||
<tr>
|
||||
<td colspan="5" class="text-center text-muted py-5">Загрузка...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="complex-plan-pagination" class="complex-plan-pagination"></div>
|
||||
</div>
|
||||
|
||||
<div class="complex-plan-panel mt-3">
|
||||
<div class="complex-plan-panel-header">
|
||||
<div>
|
||||
<div class="complex-plan-section-title">Запуск расчета</div>
|
||||
<div class="complex-plan-helper mt-1">Создание новой версии комплексного плана.</div>
|
||||
</div>
|
||||
</div>
|
||||
<form id="complex-plan-process-form" class="complex-plan-form">
|
||||
<div class="mb-3">
|
||||
<label for="complex-plan-interval-start" class="form-label">Начало интервала</label>
|
||||
<input id="complex-plan-interval-start" type="datetime-local" class="form-control form-control-sm" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="complex-plan-interval-end" class="form-label">Конец интервала</label>
|
||||
<input id="complex-plan-interval-end" type="datetime-local" class="form-control form-control-sm" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="complex-plan-satellite-ids" class="form-label">Satellite IDs</label>
|
||||
<input id="complex-plan-satellite-ids"
|
||||
type="text"
|
||||
class="form-control form-control-sm"
|
||||
required
|
||||
placeholder="101, 202, 303">
|
||||
</div>
|
||||
<div class="row g-2">
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="complex-plan-sun" class="form-label">Солнце</label>
|
||||
<input id="complex-plan-sun"
|
||||
type="number"
|
||||
class="form-control form-control-sm"
|
||||
min="-90"
|
||||
max="90"
|
||||
step="0.1"
|
||||
placeholder="null">
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="complex-plan-coverage-source" class="form-label">Источник покрытия</label>
|
||||
<select id="complex-plan-coverage-source" class="form-select form-select-sm">
|
||||
<option value="SLOTS" selected>SLOTS</option>
|
||||
<option value="COVERAGE_SCHEME">COVERAGE_SCHEME</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-2 mt-1">
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="complex-plan-coverage-strategy" class="form-label">Вариант покрытия слотами</label>
|
||||
<select id="complex-plan-coverage-strategy" class="form-select form-select-sm">
|
||||
<option value="GREEDY" selected>GREEDY</option>
|
||||
<option value="CONTINUOUS">CONTINUOUS</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="complex-plan-count-lat" class="form-label">Объединение по широте</label>
|
||||
<input id="complex-plan-count-lat"
|
||||
type="number"
|
||||
class="form-control form-control-sm"
|
||||
min="1"
|
||||
step="1"
|
||||
placeholder="countLat">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-2 mt-1">
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="complex-plan-count-long" class="form-label">Объединение по долготе</label>
|
||||
<input id="complex-plan-count-long"
|
||||
type="number"
|
||||
class="form-control form-control-sm"
|
||||
min="1"
|
||||
step="1"
|
||||
placeholder="countLong">
|
||||
</div>
|
||||
</div>
|
||||
<button id="complex-plan-process-submit" type="submit" class="btn btn-primary btn-sm mt-3">
|
||||
Запустить
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xl-7">
|
||||
<div class="complex-plan-panel">
|
||||
<div class="complex-plan-panel-header">
|
||||
<div>
|
||||
<div class="complex-plan-section-title">Детали расчета</div>
|
||||
<div id="complex-plan-detail-meta" class="complex-plan-helper mt-1">Выберите расчет слева.</div>
|
||||
</div>
|
||||
<button id="complex-plan-report"
|
||||
type="button"
|
||||
class="btn btn-outline-primary btn-sm"
|
||||
disabled>
|
||||
CSV
|
||||
</button>
|
||||
</div>
|
||||
<div id="complex-plan-detail" class="complex-plan-detail">
|
||||
<div class="text-muted py-5 text-center">Нет выбранного расчета</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/complex_plan_scripts.js"></script>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,164 @@
|
||||
<!DOCTYPE HTML>
|
||||
<html xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.w3.org/1999/xhtml"
|
||||
layout:decorate="~{layouts/main-layout}">
|
||||
<head>
|
||||
<title>Текущие планы</title>
|
||||
<link rel="stylesheet" href="/css/catalog.css"/>
|
||||
<link rel="stylesheet" href="/css/current-plans.css"/>
|
||||
</head>
|
||||
<body>
|
||||
<th:block layout:fragment="content">
|
||||
<div id="current-plans-page" class="catalog-page current-plans-page py-3">
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
|
||||
<div>
|
||||
<h2 class="mb-1">Текущие планы</h2>
|
||||
<div class="catalog-helper">Просмотр миссий выбранного спутника и режимов работы внутри миссии.</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<button id="current-plans-create-mission" type="button" class="btn btn-primary" disabled>Создать план</button>
|
||||
<button id="current-plans-refresh" type="button" class="btn btn-outline-secondary">Обновить</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="current-plans-alert"></div>
|
||||
|
||||
<div class="row g-4 current-plans-layout">
|
||||
<div class="col-12 col-xl-4">
|
||||
<div class="card catalog-card current-plans-sidebar h-100">
|
||||
<div class="card-header">
|
||||
<div class="catalog-section-title">Спутники</div>
|
||||
<div class="catalog-helper mt-1">Выберите КА для просмотра текущих планов.</div>
|
||||
</div>
|
||||
<div class="card-body p-3">
|
||||
<label for="current-plans-satellite-filter" class="form-label">Фильтр</label>
|
||||
<input id="current-plans-satellite-filter"
|
||||
type="search"
|
||||
class="form-control form-control-sm"
|
||||
autocomplete="off"
|
||||
placeholder="ID, NORAD, код, имя, тип">
|
||||
</div>
|
||||
<div class="card-body catalog-list p-0">
|
||||
<table class="table table-hover mb-0 current-plans-satellites-table">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Спутник</th>
|
||||
<th>Тип</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="current-plans-satellites-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xl-8">
|
||||
<div class="d-flex flex-column gap-4 h-100 current-plans-main-column">
|
||||
<div class="card catalog-card current-plans-missions-card">
|
||||
<div class="card-header d-flex justify-content-between align-items-start gap-3 flex-wrap">
|
||||
<div>
|
||||
<div class="catalog-section-title">Миссии выбранного спутника</div>
|
||||
<div id="current-plans-selected-satellite" class="catalog-helper mt-1">Выберите спутник слева.</div>
|
||||
</div>
|
||||
<div class="current-plans-pagination">
|
||||
<button id="current-plans-missions-prev" type="button" class="btn btn-outline-secondary btn-sm" disabled>Назад</button>
|
||||
<span id="current-plans-missions-page" class="catalog-helper mx-2">0 / 0</span>
|
||||
<button id="current-plans-missions-next" type="button" class="btn btn-outline-secondary btn-sm" disabled>Вперёд</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div id="current-plans-missions-empty" class="catalog-empty">Выберите спутник для загрузки миссий.</div>
|
||||
<div class="table-responsive current-plans-table-wrap d-none" id="current-plans-missions-wrap">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Миссия</th>
|
||||
<th>Начало</th>
|
||||
<th>Конец</th>
|
||||
<th>Станция</th>
|
||||
<th>Статус</th>
|
||||
<th class="current-plans-actions-col">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="current-plans-missions-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card catalog-card current-plans-modes-card flex-grow-1">
|
||||
<div class="card-header d-flex justify-content-between align-items-start gap-3 flex-wrap">
|
||||
<div>
|
||||
<div class="catalog-section-title">Режимы работы внутри миссии</div>
|
||||
<div id="current-plans-selected-mission" class="catalog-helper mt-1">Выберите миссию в верхней таблице.</div>
|
||||
</div>
|
||||
<button id="current-plans-export-csv" type="button" class="btn btn-outline-secondary btn-sm" disabled>Сохранить CSV</button>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div id="current-plans-modes-empty" class="catalog-empty">Режимы появятся после выбора миссии.</div>
|
||||
<div class="table-responsive current-plans-table-wrap d-none" id="current-plans-modes-wrap">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Тип</th>
|
||||
<th>Начало</th>
|
||||
<th>Виток</th>
|
||||
<th>Длительность, с</th>
|
||||
<th>Параметры</th>
|
||||
<th>Статус / источник</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="current-plans-modes-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal fade" id="current-plans-create-modal" tabindex="-1" aria-labelledby="current-plans-create-modal-title" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<form id="current-plans-create-form" class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="current-plans-create-modal-title">Создание текущего плана</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Спутник</label>
|
||||
<input id="current-plans-create-satellite" type="text" class="form-control" readonly>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="current-plans-create-start" class="form-label">Начало миссии</label>
|
||||
<input id="current-plans-create-start" type="datetime-local" class="form-control" required>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="current-plans-create-stop" class="form-label">Конец миссии</label>
|
||||
<input id="current-plans-create-stop" type="datetime-local" class="form-control" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label for="current-plans-create-station" class="form-label">Станция</label>
|
||||
<input id="current-plans-create-station" type="text" class="form-control" maxlength="32" placeholder="Необязательно">
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label for="current-plans-create-status" class="form-label">Статус</label>
|
||||
<input id="current-plans-create-status" type="text" class="form-control" maxlength="15" value="NEW">
|
||||
</div>
|
||||
<div class="catalog-helper mt-3">План будет создан для выбранного спутника. Расчёт съёмки и сбросов запускается кнопками в строке плана.</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||
<button id="current-plans-create-submit" type="submit" class="btn btn-primary">Создать</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/current_plans_scripts.js"></script>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,171 @@
|
||||
<!DOCTYPE HTML>
|
||||
<html xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.w3.org/1999/xhtml"
|
||||
layout:decorate="~{layouts/main-layout}">
|
||||
<head>
|
||||
<title>Dynamic Plan</title>
|
||||
<link rel="stylesheet" href="/css/dynamic-plan.css"/>
|
||||
</head>
|
||||
<body>
|
||||
<th:block layout:fragment="content">
|
||||
<div class="dynamic-plan-page py-3">
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
|
||||
<div>
|
||||
<h2 class="mb-1">Dynamic Plan</h2>
|
||||
<div class="dynamic-plan-helper">Запуск расчета модели комплексного плана по одной заявке.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="dynamic-plan-alert"></div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-12 col-xl-5">
|
||||
<div class="dynamic-plan-panel">
|
||||
<div class="dynamic-plan-panel-header">
|
||||
<div>
|
||||
<div class="dynamic-plan-section-title">Параметры расчета</div>
|
||||
<div class="dynamic-plan-helper mt-1">UUID заявки, выбранные КА и расчетный интервал.</div>
|
||||
</div>
|
||||
</div>
|
||||
<form id="dynamic-plan-form" class="dynamic-plan-form">
|
||||
<div class="mb-3">
|
||||
<label for="dynamic-plan-request-id" class="form-label">UUID заявки</label>
|
||||
<input id="dynamic-plan-request-id"
|
||||
type="text"
|
||||
class="form-control form-control-sm"
|
||||
required
|
||||
placeholder="00000000-0000-0000-0000-000000000000">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="dynamic-plan-satellite-ids" class="form-label">Идентификаторы КА</label>
|
||||
<input id="dynamic-plan-satellite-ids"
|
||||
type="text"
|
||||
class="form-control form-control-sm"
|
||||
required
|
||||
placeholder="56756, 62138">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Вариант расчета</label>
|
||||
<div class="dynamic-plan-mode-group" role="group" aria-label="Вариант расчета">
|
||||
<input id="dynamic-plan-mode-full"
|
||||
type="radio"
|
||||
class="btn-check"
|
||||
name="dynamic-plan-mode"
|
||||
value="FULL"
|
||||
autocomplete="off"
|
||||
checked>
|
||||
<label class="btn btn-outline-primary btn-sm" for="dynamic-plan-mode-full">
|
||||
Полный
|
||||
</label>
|
||||
|
||||
<input id="dynamic-plan-mode-greedy"
|
||||
type="radio"
|
||||
class="btn-check"
|
||||
name="dynamic-plan-mode"
|
||||
value="GREEDY"
|
||||
autocomplete="off">
|
||||
<label class="btn btn-outline-primary btn-sm" for="dynamic-plan-mode-greedy">
|
||||
Жадный
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Виток для жадного расчета</label>
|
||||
<div class="dynamic-plan-revolution-group" role="group" aria-label="Виток для жадного расчета">
|
||||
<input id="dynamic-plan-revolution-both"
|
||||
type="radio"
|
||||
class="btn-check"
|
||||
name="dynamic-plan-revolution-mode"
|
||||
value="BOTH"
|
||||
autocomplete="off"
|
||||
checked>
|
||||
<label class="btn btn-outline-primary btn-sm" for="dynamic-plan-revolution-both">
|
||||
Оба
|
||||
</label>
|
||||
|
||||
<input id="dynamic-plan-revolution-asc"
|
||||
type="radio"
|
||||
class="btn-check"
|
||||
name="dynamic-plan-revolution-mode"
|
||||
value="ASC"
|
||||
autocomplete="off">
|
||||
<label class="btn btn-outline-primary btn-sm" for="dynamic-plan-revolution-asc">
|
||||
Восх.
|
||||
</label>
|
||||
|
||||
<input id="dynamic-plan-revolution-desc"
|
||||
type="radio"
|
||||
class="btn-check"
|
||||
name="dynamic-plan-revolution-mode"
|
||||
value="DESC"
|
||||
autocomplete="off">
|
||||
<label class="btn btn-outline-primary btn-sm" for="dynamic-plan-revolution-desc">
|
||||
Нисх.
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-check mb-3">
|
||||
<input id="dynamic-plan-filter-covered-routes"
|
||||
type="checkbox"
|
||||
class="form-check-input">
|
||||
<label for="dynamic-plan-filter-covered-routes" class="form-check-label">
|
||||
Удалять маршруты по уже снятой территории
|
||||
</label>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="dynamic-plan-interval-start" class="form-label">Дата начала интервала</label>
|
||||
<input id="dynamic-plan-interval-start"
|
||||
type="date"
|
||||
class="form-control form-control-sm"
|
||||
required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="dynamic-plan-interval-end" class="form-label">Дата конца интервала</label>
|
||||
<input id="dynamic-plan-interval-end"
|
||||
type="date"
|
||||
class="form-control form-control-sm"
|
||||
required>
|
||||
</div>
|
||||
<button id="dynamic-plan-submit" type="submit" class="btn btn-primary btn-sm">
|
||||
Запустить
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="dynamic-plan-panel mt-4">
|
||||
<div class="dynamic-plan-panel-header">
|
||||
<div>
|
||||
<div class="dynamic-plan-section-title">История запусков</div>
|
||||
<div id="dynamic-plan-runs-meta" class="dynamic-plan-helper mt-1">Последние расчеты.</div>
|
||||
</div>
|
||||
<button id="dynamic-plan-runs-refresh" type="button" class="btn btn-outline-secondary btn-sm">
|
||||
Обновить
|
||||
</button>
|
||||
</div>
|
||||
<div id="dynamic-plan-runs-body" class="dynamic-plan-runs">
|
||||
<div class="text-muted py-4 text-center">Нет данных</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xl-7">
|
||||
<div class="dynamic-plan-panel">
|
||||
<div class="dynamic-plan-panel-header">
|
||||
<div>
|
||||
<div class="dynamic-plan-section-title">Запуск и результат</div>
|
||||
<div id="dynamic-plan-result-meta" class="dynamic-plan-helper mt-1">Расчет еще не запускался.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="dynamic-plan-result" class="dynamic-plan-result">
|
||||
<div class="text-muted py-5 text-center">Нет данных</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/dynamic_plan_map_layers.js"></script>
|
||||
<script src="/dynamic_plan_scripts.js"></script>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en"
|
||||
xmlns:th="http://www.thymeleaf.org">
|
||||
<head th:fragment="head">
|
||||
<meta charset="UTF-8">
|
||||
<link rel="stylesheet"
|
||||
href="/webjars/bootstrap/5.3.0/css/bootstrap.min.css"/>
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
<footer th:fragment="footer">
|
||||
<script src="/webjars/jquery/3.6.2/jquery.min.js"></script>
|
||||
<script src="/webjars/bootstrap/5.3.0/js/bootstrap.bundle.min.js"></script>
|
||||
</footer>
|
||||
</html>
|
||||
@@ -0,0 +1,78 @@
|
||||
<ul xmlns:th="http://www.thymeleaf.org"
|
||||
th:fragment="menu-generator(elements)"
|
||||
class="navbar-nav ml-auto">
|
||||
<li
|
||||
th:each="element: ${elements}"
|
||||
class="nav-item">
|
||||
<a class="nav-link"
|
||||
th:href="${element[1]}">[[${element[0]}]]</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<nav xmlns:th="http://www.thymeleaf.org"
|
||||
th:fragment="menu"
|
||||
class="navbar navbar-expand-lg static-top" style="background-color: #e3f2fd;">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand"
|
||||
href="/">ПЦП</a>
|
||||
<button class="navbar-toggler"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#navbarResponsive"
|
||||
aria-controls="navbarResponsive"
|
||||
aria-expanded="false"
|
||||
aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarResponsive">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle"
|
||||
href="#"
|
||||
role="button"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false">Группировки</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="/groups">Управление</a></li>
|
||||
<li><a class="dropdown-item" href="/group-state">Состояние ОГ</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle"
|
||||
href="#"
|
||||
role="button"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false">Спутники</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="/satellites">Управление</a></li>
|
||||
<li><a class="dropdown-item" href="/satellites/pdcm">ПДЦМ</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle"
|
||||
href="#"
|
||||
role="button"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false">Станции</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="/stations">Управление</a></li>
|
||||
<li><a class="dropdown-item" href="/rva">ЗРВ</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/map">Карта</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/com-plan">Ком.План</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/dynamic-plan">Dynamic Plan</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/current-plans">Текущие планы</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</nav>
|
||||
@@ -0,0 +1,90 @@
|
||||
<!DOCTYPE HTML>
|
||||
<html xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.w3.org/1999/xhtml"
|
||||
layout:decorate="~{layouts/main-layout}">
|
||||
<head>
|
||||
<title>Состояние ОГ</title>
|
||||
<link rel="stylesheet" href="/css/catalog.css"/>
|
||||
<link rel="stylesheet" href="/css/group-state.css"/>
|
||||
</head>
|
||||
<body>
|
||||
<th:block layout:fragment="content">
|
||||
<div id="group-state-page" class="catalog-page py-3">
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
|
||||
<div>
|
||||
<h2 class="mb-1">Состояние ОГ</h2>
|
||||
<div class="catalog-helper">Состояние орбитальной группировки на выбранный момент времени.</div>
|
||||
</div>
|
||||
<button id="group-state-refresh" type="button" class="btn btn-outline-secondary">Обновить</button>
|
||||
</div>
|
||||
|
||||
<div id="group-state-alert"></div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-12 col-xl-4">
|
||||
<div class="card catalog-card group-state-sidebar h-100">
|
||||
<div class="card-header">
|
||||
<div class="catalog-section-title">Доступные группировки</div>
|
||||
</div>
|
||||
<div class="card-body catalog-list p-0">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>ОГ</th>
|
||||
<th>КА</th>
|
||||
<th>Интервал</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="group-state-list"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xl-8">
|
||||
<div class="d-flex flex-column gap-4">
|
||||
<div class="card catalog-card">
|
||||
<div class="card-header">
|
||||
<div class="catalog-section-title">Время</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="group-state-summary" class="catalog-helper mb-3">Выберите группировку слева.</div>
|
||||
<div class="group-state-time-grid">
|
||||
<div>
|
||||
<label for="group-state-time" class="form-label">Момент времени</label>
|
||||
<input id="group-state-time" type="datetime-local" step="1" class="form-control"/>
|
||||
</div>
|
||||
<div class="d-flex align-items-end">
|
||||
<button id="group-state-apply" type="button" class="btn btn-primary w-100">Показать состояние</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card catalog-card">
|
||||
<div class="card-header">
|
||||
<div class="catalog-section-title">Таблица</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="group-state-table-empty" class="catalog-empty">Выберите группировку с доступным интервалом расчета.</div>
|
||||
<div id="group-state-table-wrap" class="group-state-table-wrap d-none"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card catalog-card">
|
||||
<div class="card-header">
|
||||
<div class="catalog-section-title">График</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="group-state-charts-empty" class="catalog-empty">Графики появятся после выбора момента времени.</div>
|
||||
<div id="group-state-charts" class="group-state-chart-grid d-none"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/group_state_scripts.js"></script>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,85 @@
|
||||
<!DOCTYPE HTML>
|
||||
<html xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.w3.org/1999/xhtml"
|
||||
layout:decorate="~{layouts/main-layout}">
|
||||
<head>
|
||||
<title>Группировки</title>
|
||||
<link rel="stylesheet" href="/css/catalog.css"/>
|
||||
</head>
|
||||
<body>
|
||||
<th:block layout:fragment="content">
|
||||
<div id="catalog-page" data-page="groups" class="catalog-page py-3">
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
|
||||
<div>
|
||||
<h2 class="mb-1">Группировки</h2>
|
||||
<div class="catalog-helper">Орбитальные группировки и управление их составом.</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button id="groups-refresh" type="button" class="btn btn-outline-secondary">Обновить</button>
|
||||
<button id="groups-new" type="button" class="btn btn-primary">Новая группировка</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="groups-alert"></div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-12 col-xl-5">
|
||||
<div class="card catalog-card h-100">
|
||||
<div class="card-header">
|
||||
<div class="catalog-section-title">Список группировок</div>
|
||||
</div>
|
||||
<div class="card-body catalog-list p-0">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Название</th>
|
||||
<th>Состав</th>
|
||||
<th>Спутники</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="groups-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xl-7">
|
||||
<div class="card catalog-card h-100">
|
||||
<div class="card-header">
|
||||
<div class="catalog-section-title" id="group-form-title">Новая группировка</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="group-form" class="d-flex flex-column gap-3">
|
||||
<input id="group-id" type="hidden"/>
|
||||
<div>
|
||||
<label for="group-name" class="form-label">Название</label>
|
||||
<input id="group-name" class="form-control" required/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<label class="form-label mb-0">Состав группировки</label>
|
||||
<span class="catalog-helper">Можно выбрать несколько спутников</span>
|
||||
</div>
|
||||
<div id="group-satellite-checklist" class="catalog-checkbox-list"></div>
|
||||
</div>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<button type="submit" class="btn btn-primary">Сохранить</button>
|
||||
<button id="groups-delete" type="button" class="btn btn-outline-danger">Удалить</button>
|
||||
<button id="groups-reset" type="reset" class="btn btn-outline-secondary">Сбросить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/catalog_scripts.js"></script>
|
||||
<script>
|
||||
document.getElementById('groups-reset').addEventListener('click', function () {
|
||||
document.getElementById('groups-new').click();
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE HTML>
|
||||
<html xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.w3.org/1999/xhtml"
|
||||
layout:decorate="~{layouts/main-layout}">
|
||||
<head>
|
||||
<title>ПЦП</title>
|
||||
</head>
|
||||
<body>
|
||||
<th:block layout:fragment="content">
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,22 @@
|
||||
<!DOCTYPE HTML>
|
||||
<html lang="en"
|
||||
xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<th:block th:insert="~{fragments/base/general.html :: head}" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="vh-100">
|
||||
<div style="width:100%; position:fixed; z-index:1;">
|
||||
<div th:replace="~{fragments/base/menu.html :: menu}"></div>
|
||||
</div>
|
||||
<div class="container-fluid d-flex flex-column"
|
||||
style="padding-top:4rem; height:calc(100% - 1rem);">
|
||||
<div layout:fragment="content">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
<th:block th:insert="~{fragments/base/general.html :: footer}"/>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,127 @@
|
||||
<!DOCTYPE HTML>
|
||||
<html xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.w3.org/1999/xhtml"
|
||||
layout:decorate="~{layouts/main-layout}">
|
||||
<head>
|
||||
<title>ЗРВ</title>
|
||||
<link rel="stylesheet" href="/css/rva.css"/>
|
||||
</head>
|
||||
<body>
|
||||
<th:block layout:fragment="content">
|
||||
<div class="rva-page py-3">
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
|
||||
<div>
|
||||
<h2 class="mb-1">ЗРВ</h2>
|
||||
<div class="rva-helper">Расчет зон радиовидимости по нескольким станциям с объединением интервалов или без него.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="rva-alert"></div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-12 col-xxl-4">
|
||||
<div class="card rva-card">
|
||||
<div class="card-header">
|
||||
<div class="rva-section-title">Параметры расчета</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="rva-form" class="d-flex flex-column gap-3">
|
||||
<div>
|
||||
<label for="rva-satellite" class="form-label">Спутник</label>
|
||||
<select id="rva-satellite" class="form-select" required>
|
||||
<option value="">Выберите спутник</option>
|
||||
<option th:each="satellite : ${satellites}"
|
||||
th:value="${satellite.noradId}"
|
||||
th:text="${satellite.name + ' (' + satellite.noradId + ')'}"></option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="rva-duration" class="form-label">Горизонт расчета, суток</label>
|
||||
<input id="rva-duration" type="number" min="1" step="1" value="1" class="form-control" required/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="form-label">Вариант расчета</div>
|
||||
<div class="d-flex flex-column gap-2">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="rva-mode" id="rva-mode-common" value="common" checked>
|
||||
<label class="form-check-label" for="rva-mode-common">Полный список ЗРВ</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="rva-mode" id="rva-mode-merge" value="merge">
|
||||
<label class="form-check-label" for="rva-mode-merge">Объединенные интервалы</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="rva-mode" id="rva-mode-merge-on-rev" value="merge-on-rev">
|
||||
<label class="form-check-label" for="rva-mode-merge-on-rev">Суммарная длительность на витке</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<label class="form-label mb-0">Станции</label>
|
||||
<button id="rva-select-all" type="button" class="btn btn-outline-secondary btn-sm">Выбрать все</button>
|
||||
</div>
|
||||
<div id="rva-stations" class="rva-stations">
|
||||
<label class="form-check rva-station-item"
|
||||
th:each="station : ${stations}">
|
||||
<input class="form-check-input rva-station-checkbox"
|
||||
type="checkbox"
|
||||
th:value="${station.number}"
|
||||
th:attr="data-number=${station.number}">
|
||||
<span class="form-check-label rva-station-label">
|
||||
<span class="fw-semibold" th:text="${station.name}"></span>
|
||||
<span class="text-muted" th:text="${' (#' + station.number + ')'}"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="rva-helper mt-2">Нужно выбрать хотя бы одну станцию.</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<button type="submit" class="btn btn-primary">Рассчитать</button>
|
||||
<button id="rva-reset" type="reset" class="btn btn-outline-secondary">Сбросить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xxl-8">
|
||||
<div class="card rva-card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<div>
|
||||
<div class="rva-section-title">Результаты</div>
|
||||
<div id="rva-result-meta" class="rva-helper mt-1">Выберите параметры и запустите расчет.</div>
|
||||
</div>
|
||||
<button id="rva-download" type="button" class="btn btn-outline-primary btn-sm" disabled>Скачать CSV</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead id="rva-results-head"></thead>
|
||||
<tbody id="rva-results-body">
|
||||
<tr>
|
||||
<td class="text-center text-muted py-5">Нет результатов</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script th:inline="javascript">
|
||||
window.rvaPageData = {
|
||||
satellites: /*[[${satellites}]]*/ [],
|
||||
stations: /*[[${stations}]]*/ []
|
||||
};
|
||||
</script>
|
||||
<script src="/rva_scripts.js"></script>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,73 @@
|
||||
<!DOCTYPE HTML>
|
||||
<html xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.w3.org/1999/xhtml"
|
||||
layout:decorate="~{layouts/main-layout}">
|
||||
<head>
|
||||
<title>ПДЦМ</title>
|
||||
<link rel="stylesheet" href="/css/catalog.css"/>
|
||||
<link rel="stylesheet" href="/css/satellite-pdcm.css"/>
|
||||
</head>
|
||||
<body>
|
||||
<th:block layout:fragment="content">
|
||||
<div id="satellite-pdcm-page" class="catalog-page py-3">
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
|
||||
<div>
|
||||
<h2 class="mb-1">Параметры движения центра масс</h2>
|
||||
<div class="catalog-helper">Табличное представление asc-node по выбранному спутнику.</div>
|
||||
</div>
|
||||
<button id="satellite-pdcm-refresh" type="button" class="btn btn-outline-secondary">Обновить</button>
|
||||
</div>
|
||||
|
||||
<div id="satellite-pdcm-alert"></div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-12 col-lg-4 col-xl-3">
|
||||
<div class="card catalog-card catalog-list-card satellite-pdcm-sidebar h-100">
|
||||
<div class="card-header">
|
||||
<div class="catalog-section-title">Спутники</div>
|
||||
</div>
|
||||
<div class="card-body catalog-list p-0">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Спутник</th>
|
||||
<th>Интервал</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="satellite-pdcm-list"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-8 col-xl-9">
|
||||
<div class="d-flex flex-column gap-4">
|
||||
<div class="card catalog-card">
|
||||
<div class="card-header">
|
||||
<div class="catalog-section-title">Сводка</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="satellite-pdcm-summary" class="satellite-pdcm-summary-root">
|
||||
<div class="catalog-helper">Выберите спутник слева.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card catalog-card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center gap-2 flex-wrap">
|
||||
<div class="catalog-section-title">Asc-node</div>
|
||||
<button id="satellite-pdcm-export" type="button" class="btn btn-sm btn-outline-primary" disabled>Скачать CSV</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="satellite-pdcm-table-empty" class="catalog-empty">Нет доступного интервала ПДЦМ для выбранного спутника.</div>
|
||||
<div id="satellite-pdcm-table-wrap" class="satellite-pdcm-table-wrap d-none"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/satellite_pdcm_scripts.js"></script>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,273 @@
|
||||
<!DOCTYPE HTML>
|
||||
<html xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.w3.org/1999/xhtml"
|
||||
layout:decorate="~{layouts/main-layout}">
|
||||
<head>
|
||||
<title>Спутники</title>
|
||||
<link rel="stylesheet" href="/css/catalog.css"/>
|
||||
</head>
|
||||
<body>
|
||||
<th:block layout:fragment="content">
|
||||
<div id="catalog-page" data-page="satellites" class="catalog-page py-3">
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
|
||||
<div>
|
||||
<h2 class="mb-1">Спутники</h2>
|
||||
<div class="catalog-helper">Карточки спутников, observation profile и slot profile.</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button id="satellites-refresh" type="button" class="btn btn-outline-secondary">Обновить</button>
|
||||
<button id="satellites-new" type="button" class="btn btn-primary">Новый спутник</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="satellites-alert"></div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-12 col-xxl-4">
|
||||
<div class="card catalog-card catalog-list-card h-100">
|
||||
<div class="card-header">
|
||||
<div class="catalog-section-title">Список спутников</div>
|
||||
</div>
|
||||
<div class="card-body catalog-list-search p-3 border-bottom">
|
||||
<input id="satellite-search" class="form-control" placeholder="Поиск по ID, имени, коду"/>
|
||||
</div>
|
||||
<div class="card-body catalog-list catalog-list-page-scroll p-0">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Код</th>
|
||||
<th>Имя</th>
|
||||
<th>Тип</th>
|
||||
<th>Слоты</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="satellites-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xxl-8">
|
||||
<div class="d-flex flex-column gap-4">
|
||||
<div class="card catalog-card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<div class="catalog-section-title" id="satellite-form-title">Новый спутник</div>
|
||||
<button id="satellite-delete" type="button" class="btn btn-outline-danger btn-sm">Удалить спутник</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="satellite-form" class="d-flex flex-column gap-3">
|
||||
<div class="catalog-form-grid">
|
||||
<div>
|
||||
<label for="satellite-id" class="form-label">ID</label>
|
||||
<input id="satellite-id" type="number" min="1" class="form-control" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="satellite-norad-id" class="form-label">NORAD ID</label>
|
||||
<input id="satellite-norad-id" type="number" min="1" class="form-control"/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="satellite-code" class="form-label">Code</label>
|
||||
<input id="satellite-code" class="form-control" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="satellite-name" class="form-label">Name</label>
|
||||
<input id="satellite-name" class="form-control" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="satellite-type-code" class="form-label">Type code</label>
|
||||
<input id="satellite-type-code" class="form-control" required/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="catalog-form-grid align-items-end">
|
||||
<div class="form-check">
|
||||
<input id="satellite-active" class="form-check-input" type="checkbox" checked>
|
||||
<label class="form-check-label" for="satellite-active">Активен</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input id="satellite-scan-tle" class="form-check-input" type="checkbox">
|
||||
<label class="form-check-label" for="satellite-scan-tle">Сканировать TLE</label>
|
||||
</div>
|
||||
<div>
|
||||
<label for="satellite-red" class="form-label">Red</label>
|
||||
<input id="satellite-red" type="number" min="0" max="255" class="form-control" value="255" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="satellite-green" class="form-label">Green</label>
|
||||
<input id="satellite-green" type="number" min="0" max="255" class="form-control" value="0" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="satellite-blue" class="form-label">Blue</label>
|
||||
<input id="satellite-blue" type="number" min="0" max="255" class="form-control" value="0" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Preview</label>
|
||||
<div id="satellite-color-preview" class="catalog-color-preview"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="submit" class="btn btn-primary">Сохранить карточку</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card catalog-card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<div class="catalog-section-title">Observation profile</div>
|
||||
<button id="observation-delete" type="button" class="btn btn-outline-danger btn-sm">Удалить profile</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="observation-form" class="d-flex flex-column gap-3">
|
||||
<div class="catalog-form-grid">
|
||||
<div>
|
||||
<label for="observation-capture-angle" class="form-label">Capture angle</label>
|
||||
<input id="observation-capture-angle" type="number" step="any" class="form-control" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="observation-sun-angle-min" class="form-label">Sun angle min</label>
|
||||
<input id="observation-sun-angle-min" type="number" step="any" class="form-control" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="observation-duration-min" class="form-label">Duration min, s</label>
|
||||
<input id="observation-duration-min" type="number" min="0" class="form-control" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="observation-duration-max" class="form-label">Duration max, s</label>
|
||||
<input id="observation-duration-max" type="number" min="0" class="form-control" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="observation-mmi" class="form-label">MMI, s</label>
|
||||
<input id="observation-mmi" type="number" min="0" class="form-control" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="observation-daily-max" class="form-label">Daily max, s</label>
|
||||
<input id="observation-daily-max" type="number" min="0" class="form-control" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="observation-revolution-max" class="form-label">Revolution max, s</label>
|
||||
<input id="observation-revolution-max" type="number" min="0" class="form-control" required/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" class="btn btn-primary">Сохранить observation profile</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card catalog-card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<div class="catalog-section-title">Slot profile</div>
|
||||
<button id="slot-delete" type="button" class="btn btn-outline-danger btn-sm">Удалить profile</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="slot-ic-form" class="d-flex flex-column gap-3">
|
||||
<div>
|
||||
<div class="catalog-section-title">Начальные условия орбиты</div>
|
||||
<div id="slot-ic-status" class="catalog-helper mt-2">Начальные условия не заданы.</div>
|
||||
</div>
|
||||
|
||||
<div class="catalog-form-grid">
|
||||
<div>
|
||||
<label for="slot-ic-time" class="form-label">Time</label>
|
||||
<input id="slot-ic-time" type="text" class="form-control" placeholder="dd.mm.yyyy hh:mm:ss.zzz" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="slot-ic-revolution" class="form-label">Revolution</label>
|
||||
<input id="slot-ic-revolution" type="number" class="form-control" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="slot-ic-s-ball" class="form-label">S-ball</label>
|
||||
<input id="slot-ic-s-ball" type="number" step="any" class="form-control" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="slot-ic-f81" class="form-label">F81</label>
|
||||
<input id="slot-ic-f81" type="number" step="any" class="form-control" required/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="catalog-form-grid">
|
||||
<div>
|
||||
<label for="slot-ic-x" class="form-label">X</label>
|
||||
<input id="slot-ic-x" type="number" step="any" class="form-control" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="slot-ic-y" class="form-label">Y</label>
|
||||
<input id="slot-ic-y" type="number" step="any" class="form-control" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="slot-ic-z" class="form-label">Z</label>
|
||||
<input id="slot-ic-z" type="number" step="any" class="form-control" required/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="catalog-form-grid">
|
||||
<div>
|
||||
<label for="slot-ic-vx" class="form-label">Vx</label>
|
||||
<input id="slot-ic-vx" type="number" step="any" class="form-control" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="slot-ic-vy" class="form-label">Vy</label>
|
||||
<input id="slot-ic-vy" type="number" step="any" class="form-control" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="slot-ic-vz" class="form-label">Vz</label>
|
||||
<input id="slot-ic-vz" type="number" step="any" class="form-control" required/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="submit" class="btn btn-primary">Сохранить начальные условия</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<form id="slot-form" class="d-flex flex-column gap-3">
|
||||
<div class="catalog-form-grid">
|
||||
<div>
|
||||
<label for="slot-cycle-revs" class="form-label">Cycle revs</label>
|
||||
<input id="slot-cycle-revs" type="number" min="0" class="form-control" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="slot-tn-calc" class="form-label">TN calc</label>
|
||||
<input id="slot-tn-calc" type="text" class="form-control" placeholder="dd.mm.yyyy hh:mm:ss.zzz"/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="slot-duration" class="form-label">Slot duration</label>
|
||||
<input id="slot-duration" type="number" min="0" class="form-control" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="slot-duration-calc-days" class="form-label">Duration calc, days</label>
|
||||
<input id="slot-duration-calc-days" type="number" min="0" class="form-control" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="slot-max-chain-length" class="form-label">Max chain length</label>
|
||||
<input id="slot-max-chain-length" type="number" min="0" class="form-control" required/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="slot-default-angles" class="form-label">Default angles</label>
|
||||
<textarea id="slot-default-angles" class="form-control" rows="8" placeholder="18.5, 21.58 20.85, 23.82"></textarea>
|
||||
<div class="catalog-helper mt-2">Один диапазон на строку: `angleBegin, angleEnd`.</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<button id="slot-calculate" type="button" class="btn btn-outline-secondary">Расчет слотов</button>
|
||||
<button type="submit" class="btn btn-primary">Сохранить slot profile</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="slot-calculation-summary" class="catalog-slot-summary mt-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/catalog_scripts.js"></script>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,110 @@
|
||||
<!DOCTYPE HTML>
|
||||
<html xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.w3.org/1999/xhtml"
|
||||
layout:decorate="~{layouts/main-layout}">
|
||||
<head>
|
||||
<title>Станции</title>
|
||||
<link rel="stylesheet" href="/css/catalog.css"/>
|
||||
</head>
|
||||
<body>
|
||||
<th:block layout:fragment="content">
|
||||
<div id="stations-page" class="catalog-page py-3">
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
|
||||
<div>
|
||||
<h2 class="mb-1">Станции</h2>
|
||||
<div class="catalog-helper">CRUD-операции над наземными станциями через `stations-service`.</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button id="stations-refresh" type="button" class="btn btn-outline-secondary">Обновить</button>
|
||||
<button id="stations-new" type="button" class="btn btn-primary">Новая станция</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="stations-alert"></div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-12 col-xl-5">
|
||||
<div class="card catalog-card h-100">
|
||||
<div class="card-header">
|
||||
<div class="catalog-section-title">Список станций</div>
|
||||
</div>
|
||||
<div class="card-body p-3 border-bottom">
|
||||
<input id="stations-search" class="form-control" placeholder="Поиск по номеру, имени"/>
|
||||
</div>
|
||||
<div class="card-body catalog-list p-0">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Номер</th>
|
||||
<th>Название</th>
|
||||
<th>Мин.угол</th>
|
||||
<th>Макс.угол</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="stations-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xl-7">
|
||||
<div class="card catalog-card h-100">
|
||||
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<div class="catalog-section-title" id="station-form-title">Новая станция</div>
|
||||
<button id="stations-delete" type="button" class="btn btn-outline-danger btn-sm">Удалить станцию</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="station-form" class="d-flex flex-column gap-3">
|
||||
<input id="station-id" type="hidden"/>
|
||||
|
||||
<div class="catalog-form-grid">
|
||||
<div>
|
||||
<label for="station-number" class="form-label">Номер</label>
|
||||
<input id="station-number" type="number" class="form-control" min="0" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="station-name" class="form-label">Название</label>
|
||||
<input id="station-name" class="form-control" required/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="catalog-form-grid">
|
||||
<div>
|
||||
<label for="station-lat" class="form-label">Широта</label>
|
||||
<input id="station-lat" type="number" step="any" class="form-control" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="station-long" class="form-label">Долгота</label>
|
||||
<input id="station-long" type="number" step="any" class="form-control" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="station-height" class="form-label">Высота, м</label>
|
||||
<input id="station-height" type="number" step="any" min="0" class="form-control" required/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="catalog-form-grid">
|
||||
<div>
|
||||
<label for="station-elevation-min" class="form-label">Мин.угол</label>
|
||||
<input id="station-elevation-min" type="number" step="any" class="form-control" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="station-elevation-max" class="form-label">Макс.угол</label>
|
||||
<input id="station-elevation-max" type="number" step="any" class="form-control" required/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<button type="submit" class="btn btn-primary">Сохранить</button>
|
||||
<button id="stations-reset" type="reset" class="btn btn-outline-secondary">Сбросить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/stations_scripts.js"></script>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user