diff --git a/docs/tasks/TASK_tgu-editor-integration.md b/docs/tasks/TASK_tgu-editor-integration.md new file mode 100644 index 0000000..fe32add --- /dev/null +++ b/docs/tasks/TASK_tgu-editor-integration.md @@ -0,0 +1,219 @@ +# Задача: встроить интерфейс «Редактор включений КА» в `pcp-tgu-ops-ui` + +> Документ-ТЗ для кодинг-агента. Раздел 0 — промпт для запуска (скопировать в агента). +> Разделы 1–11 — инструкция, которую агент читает в репозитории и которой следует. +> Стиль работы, границы слоёв и формат финального ответа — по корневому `AGENTS.md`. + +--- + +## 0. Промпт для запуска агента (copy-paste) + +``` +Ты работаешь в монорепозитории observatio-terrae (pcp). + +Задача: встроить прототип интерфейса «Редактор включений КА» в существующее +фронтенд-приложение services/pcp-tgu-ops-ui (Vite + React 19 + TypeScript). + +Перед началом обязательно прочитай: +1. /AGENTS.md — правила работы, стиль, формат финального ответа. +2. /docs/tasks/TASK_tgu-editor-integration.md — полное ТЗ этой задачи (этот файл). +3. Исходный прототип: /docs/ui-prototypes/pcp-tgu-editor/ (editor.jsx и соседние). +4. Целевую структуру: services/pcp-tgu-ops-ui/src (App.tsx, features/, api/, styles/). + +Это ПОРТ, а не копирование: прототип на Babel-standalone с глобалями и мок-данными; +цель — ESM/TS-модули, реальный API-клиент, существующая тема и паттерны проекта. + +Выполняй строго по этапам из раздела 5 ТЗ. Делай минимальные ревьюопригодные изменения, +не трогай чужие сервисы и backend (Kotlin). Карту, орбитальную математику и мок-данные +из прототипа НЕ переноси — переиспользуй features/tgu-map-2d и api/tguApi (раздел 3). +После каждого этапа прогоняй `npm run test` и `npm run build` в модуле. + +В конце дай отчёт в формате из раздела «Обязательный формат финального ответа» AGENTS.md. +``` + +--- + +## 1. Контекст и цель + +`pcp-tgu-ops-ui` — операторский UI ТГУ (Mission Ops): уже работающее SPA на **Vite 7 + React 19 + TypeScript**, feature-sliced структура (`features/tgu-planning`, `features/tgu-map-2d`), централизованный API-клиент `src/api/tguApi.ts` (все вызовы через `/api/...`, dev-proxy на backend `:7008`), токены темы в `src/styles/theme.css`, юнит-тесты на Vitest. + +Прототип «Редактор» — браузерный demo (React/Babel из CDN, JSX компилируется в браузере, общие глобали `D`/`SENSOR_MODES`/`window.WorldGeo`, мок-данные из `data.jsx`). Это **другой стек**, поэтому его нельзя «подключить» файлами — нужно портировать как новую фичу приложения. + +**Цель:** редактор включений КА доступен как третья вкладка приложения (`timeline | map | editor`), работает на реальных данных через `tguApi`, переиспользует существующую карту и тему, покрыт тестами и собирается без ошибок. + +Функциональность редактора (из прототипа `editor.jsx`): выбор активного КА/плана, черновик включений (`works`: `shoot` — съёмка, `downlink` — сброс), drag/resize по таймлайну, добавление съёмки по точке на карте и сброса по сеансу со станцией, контекст соседних КА, undo/redo, проверка конфликтов (пересечения по времени — живые; видимость цели/станции — по кнопке), применение правок. + +--- + +## 2. Границы задачи (scope) + +**В scope:** +- Новая фича `src/features/tgu-editor/` в `pcp-tgu-ops-ui`. +- Третья вкладка `"editor"` в существующем переключателе вкладок. +- Перенос UI-компонентов редактора на TSX + типизация. +- Чистые модули логики (undo/redo, конфликты) + их тесты. +- Подключение к реальным данным через `tguApi` (планы/платформы). +- Перемаппинг токенов темы. + +**Вне scope (НЕ делать без отдельного запроса):** +- Изменения в любых Kotlin-сервисах, включая `pcp-tgu-service` (backend-эндпоинт сохранения правок — отдельная задача, см. раздел 7). +- Перенос карты/орбитальной математики/мок-данных из прототипа (раздел 3). +- Добавление роутера, state-менеджеров, UI-библиотек, новых рантайм-зависимостей без необходимости. +- Рефакторинг существующих фич `tgu-planning` / `tgu-map-2d` сверх минимально необходимого для переиспользования. +- Изменения `helm/`, `settings.gradle.kts`, CI (модуль фронта в них и так не участвует). + +--- + +## 3. Целевая архитектура и правила переиспользования + +Следуй существующему feature-sliced паттерну (см. `features/tgu-planning`, `features/tgu-map-2d`): UI-компоненты в `*.tsx`, типы в `model/*.ts`, нетривиальная логика — в отдельных чистых `*.ts` модулях с тестами. + +**Обязательно переиспользовать (НЕ дублировать):** +- **Карта** — `features/tgu-map-2d` (`Tgu2DMapView`, `canvas/drawTracks|drawSwath|drawStations|drawPlanWorks|drawBaseMap`, `geometry/mapProjection|spatialIndex|bbox`). Редактору нужна карта в режиме «выбор точки съёмки» и подсветки трасс — расширяй существующий компонент пропсами (`pickMode`, `onPickPoint`, `targets`), а не переноси `mapview.jsx`/`orbit.jsx`/`world.js`/`basemap.js`. +- **API** — `src/api/tguApi.ts` (`fetchPlans`, `fetchPlatforms`, паттерн `fetchJson`/`TguApiError`). Все сетевые вызовы редактора добавляй сюда же или в `features/tgu-editor/editorApi.ts` в том же стиле. +- **Тема** — `src/styles/theme.css` (см. раздел 6). +- **Модели** — `src/model/tguTypes.ts`, `src/model/timelineTypes.ts`. Переиспользуй существующие типы планов/платформ, не вводи параллельные. + +**Не переносить из прототипа:** `mapview.jsx`, `orbit.jsx`, `world.js`, `basemap.js`, `util.jsx` (дублируют то, что уже есть в TS), `data.jsx` (мок), `tabs.jsx` (заменяется вкладкой), `Editor.html`/`Map.html` (точка входа уже `index.html` + `main.tsx`). + +Орбитальные функции, которых нет в `tgu-map-2d` (`targetPasses`, `stationPasses`, `subPoint`, `nearestStation`), вынеси в чистый общий модуль `features/tgu-map-2d/geometry/passes.ts` (с тестами) и переиспользуй из карты и редактора. Не размещай бизнес/гео-логику внутри React-компонентов. + +--- + +## 4. Инвентаризация и маппинг файлов + +Источник: `docs/ui-prototypes/pcp-tgu-editor/` (положить туда содержимое архива до начала работ — по образцу уже существующих `docs/ui-prototypes/pcp-tgu-mission-ops/`). + +| Прототип | Действие | Целевой файл | +|---|---|---| +| `editor.jsx` (`EditorApp`) | Портировать, убрать `ReactDOM.createRoot`, сделать экспортируемым компонентом-вкладкой | `features/tgu-editor/TguEditorTab.tsx` | +| `editor.jsx` (`EditorToolbar`) | Портировать | `features/tgu-editor/EditorToolbar.tsx` | +| `editorrail.jsx` | Портировать | `features/tgu-editor/EditorRail.tsx` | +| `editortimeline.jsx` | Портировать | `features/tgu-editor/EditorTimeline.tsx` | +| `editorpanels.jsx` (`WorkInspector`/`ShootPanel`/`DownlinkPanel`) | Портировать | `features/tgu-editor/EditorPanels.tsx` | +| `visibility.jsx` | Портировать визуализацию; гео-расчёты вынести в `passes.ts` | `features/tgu-editor/VisibilityTracks.tsx` | +| `editor.jsx` (undo/redo, mutate, seedDraft) | Вынести в чистый модуль | `features/tgu-editor/editorDraft.ts` (+`editorDraft.test.ts`) | +| `editor.jsx` (overlaps + runCheck) | Вынести в чистый модуль | `features/tgu-editor/editorConflicts.ts` (+`editorConflicts.test.ts`) | +| типы `work`/`draft`/`target` | Создать | `features/tgu-editor/model/editorTypes.ts` | +| `mapview.jsx`, `orbit.jsx`, `world.js`, `basemap.js`, `util.jsx`, `data.jsx`, `tabs.jsx`, `*.html` | НЕ переносить | — | + +--- + +## 5. Пошаговый план (атомарные этапы) + +Каждый этап завершай прогоном `npm run test` и `npm run build` в `services/pcp-tgu-ops-ui`. + +**Этап 0 — подготовка.** Скопировать прототип в `docs/ui-prototypes/pcp-tgu-editor/`. Прочитать `App.tsx`, `TguPlanningPage.tsx`, `TguToolbar.tsx`, `tguApi.ts`, `theme.css`, `Tgu2DMapView.tsx`. Зафиксировать в отчёте точки расширения. + +**Этап 1 — типы и каркас фичи.** Создать `features/tgu-editor/model/editorTypes.ts` (`Work` с `kind: "shoot" | "downlink"`, `origin`, `modeId`, `stationId`, `targetLat/Lon`, `isNew`; `Draft`, `EditorTarget`). Создать пустой `TguEditorTab.tsx` с заголовком-заглушкой. Скомпилироваться. + +**Этап 2 — чистая логика + тесты.** Перенести в `editorDraft.ts`: `seedDraft`, `mutate`, `undo`, `redo`, `addShoot`, `addDownlink`, `updateWork`, `deleteWork`, `duplicateWork` (как чистые функции над `Draft`/`Work`, без React и без `Date.now()` внутри — id/время передавать параметром для детерминизма тестов). Перенести в `editorConflicts.ts`: `overlaps` и проверку видимости. Написать `editorDraft.test.ts` и `editorConflicts.test.ts` (undo/redo инварианты, обнаружение пересечений, отсутствие/наличие видимости). Это первоочередное — логика должна быть покрыта до UI. + +**Этап 3 — токены темы.** Реализовать перемаппинг (раздел 6). Убедиться, что портируемые стили резолвятся в палитру приложения. + +**Этап 4 — UI без карты.** Портировать `EditorToolbar`, `EditorRail`, `EditorTimeline`, `EditorPanels`, `VisibilityTracks` на TSX. Завязать их на состояние в `TguEditorTab` (`useState`/`useMemo`/`useCallback` напрямую из `react`). Drag/resize таймлайна сохранить. Вместо `alert()` использовать уведомление в стиле `decisionNotice`/`decisionError`. + +**Этап 5 — карта.** Расширить `Tgu2DMapView` пропсами `pickMode`/`onPickPoint`/`targets` (минимально, без поломки текущего использования в `Tgu2DMapTab`). Подключить в редакторе для выбора точки съёмки и подсветки трасс цели/станции через `passes.ts`. + +**Этап 6 — данные.** Заменить мок `D` на реальные данные: планы/платформы грузить через `tguApi` (как в `TguPlanningPage`). `seedDraft` строить из выбранного плана. Активный КА/план брать из пропсов (раздел 7), не из `location.search`. + +**Этап 7 — вкладка.** В `model/timelineTypes.ts`/`TguPlanningPage.tsx` расширить `activeTab` до `"timeline" | "map" | "editor"`, добавить кнопку вкладки в `TguToolbar` (`onTabChange`), отрендерить `TguEditorTab` при `activeTab === "editor"`, передав `selectedSpacecraftId`/`selectedPlan`/`appliedRange`. + +**Этап 8 — apply (заглушка).** Реализовать `applyDraft` через типизированную функцию `saveEditedPlan` в `editorApi.ts`, помеченную `// TODO(backend): эндпоинт сохранения правок плана отсутствует в pcp-tgu-service` (раздел 7). Поведение: показать понятное уведомление о том, что сохранение во внешний сервис ещё не реализовано; не падать. + +**Этап 9 — финализация.** Прогнать `npm run test` и `npm run build`. Отчёт по формату AGENTS.md. + +--- + +## 6. Перемаппинг токенов темы + +Прототип использует oklch-токены (`--bg-0..3`, `--ink..--ink-3`, `--t-optical/radar/combo`, `--now`, `--warn`, `--line`, `--shadow`), которых нет в `theme.css` приложения (там hex: `--bg-base`, `--bg-panel`, `--text`, `--accent`, `--danger`, `--warning`, ...). + +Минимально-инвазивный подход: создать `src/styles/editor-tokens.css` (импортировать в `TguEditorTab` или в `main.tsx`) с алиасами, чтобы портированный код, ссылающийся на старые имена, резолвился в палитру приложения — без переписывания каждого inline-стиля: + +```css +:root { + --bg-0: var(--bg-base); + --bg-1: var(--bg-panel); + --bg-2: var(--bg-panel-2); + --bg-3: var(--bg-elevated); + --ink: var(--text); + --ink-1: var(--text); + --ink-2: var(--text-muted); + --ink-3: var(--text-dim); + --now: var(--warning); + --warn: var(--warning); + /* --line, --line-soft, --accent, --shadow совпадают по имени — проверить значения */ + /* --t-optical/radar/combo: задать на основе --accent/--warning/--danger или вынести в палитру типов КА */ +} +``` + +Уточнить фактические значения `--line/--line-soft/--accent/--shadow` (имена совпадают, значения могут отличаться) и зафиксировать решение в отчёте. Альтернатива (если команда предпочитает) — переписать стили редактора сразу на родные токены приложения; выбрать один путь и не смешивать. + +--- + +## 7. Данные и API + +- **Чтение:** планы и платформы — через существующие `fetchPlans`/`fetchPlatforms` (`tguApi.ts`). Активный КА/план редактор получает пропсами от `TguPlanningPage` (`selectedSpacecraftId`, `selectedPlan`), а не из URL. +- **Гео/орбиты:** через `tgu-map-2d` + `passes.ts`; мок `world.js`/`data.jsx` не использовать. +- **Сохранение правок (apply):** в текущем API `pcp-tgu-service` **нет** эндпоинта сохранения отредактированного набора включений (есть только выдача плана и `decision`/`issue`). Поэтому в этой задаче `applyDraft` реализуется как заглушка с явным `TODO(backend)` и не делает реальной записи. + +**Предложение контракта для backend-команды** (вне scope, зафиксировать в отчёте, не реализовывать): +- `POST /api/tgu-planning/plans/{planId}/edits` с телом `{ works: EditedWork[], baseRevision?: string }`, идемпотентно по `operationId`, возвращает новый план/attempt. Семантику (новый attempt vs патч существующего) согласовать отдельно. + +--- + +## 8. Конвенции + +Следуй `AGENTS.md` (раздел про стиль и scope) применительно к TS/React: +- Минимальные, локальные, ревьюопригодные изменения; не переименовывать и не форматировать несвязанные файлы. +- Предпочитать `const`, маленькие функции, явные имена; не вводить «умные» абстракции на будущее. +- Бизнес/гео-логику не держать в React-компонентах — выносить в чистые `*.ts` модули. +- Комментарии — там, где помогают сопровождению (undo/redo инварианты, правила конфликтов, edge cases), а не на каждую строку. Язык комментариев — как в проекте. +- Не глотать ошибки сети молча — использовать паттерн `TguApiError`/уведомлений, как в существующем коде. +- Не добавлять новые рантайм-зависимости без явной необходимости; React/ReactDOM/Babel из CDN прототипа не тащить — они уже есть в проекте как npm-пакеты. +- TypeScript: без `any` в публичных сигнатурах; типы — в `model/`. + +--- + +## 9. Критерии приёмки и команды валидации + +Рабочая директория: `services/pcp-tgu-ops-ui`. + +``` +npm install # при необходимости +npm run test # vitest run — все тесты зелёные, включая новые editorDraft/editorConflicts/passes +npm run build # tsc -b && vite build — без ошибок типов и сборки +npm run dev # ручная проверка: вкладка «Редактор» открывается, см. ниже +``` + +Функциональные критерии: +1. В тулбаре доступна третья вкладка; переключение `timeline | map | editor` работает, существующие вкладки не сломаны. +2. Редактор открывается на реальных данных (планы/платформы из `tguApi`), без мок-`D`. +3. Работают: drag/resize включений, добавление съёмки по точке на карте, добавление сброса по станции, undo/redo, проверка конфликтов (живые пересечения + видимость по кнопке). +4. Карта в редакторе — это `tgu-map-2d` (нет второго движка карты в бандле). +5. Тема консистентна с приложением (нет «бесцветных» из-за отсутствующих токенов). +6. `applyDraft` не падает и явно сообщает про отсутствующий backend-эндпоинт. + +Если какая-то команда не запускалась — указать какая и почему, и описать остаточный риск (по AGENTS.md). + +--- + +## 10. Риски, допущения, чего не делать + +- **Допущение:** структура планов/включений из `tguApi` достаточна, чтобы построить `seedDraft`. Если в реальном ответе нет полей под `works` редактора — зафиксировать gap, сделать адаптер-маппер и описать недостающие поля в отчёте, **не** менять backend. +- **Риск:** React 19 + StrictMode даёт двойной вызов эффектов — проверить `runCheck`/`setTimeout` на отсутствие двойных срабатываний. +- **Риск:** расширение `Tgu2DMapView` может задеть `Tgu2DMapTab` — новые пропсы делать опциональными с дефолтами, существующее поведение не менять. +- **Не делать:** правки в Kotlin-сервисах, в чужих фичах сверх необходимого, новый роутинг, новые тяжёлые зависимости, перенос мок-данных и второго движка карты. + +--- + +## 11. Обязательный формат финального ответа + +По `AGENTS.md` агент завершает задачу блоками: + +1. **Changed files** — список изменённых/созданных файлов. +2. **What changed** — краткое summary реализации по этапам. +3. **Architecture compliance** — почему соблюдены границы слоёв (логика вне компонентов, переиспользование `tgu-map-2d`/`tguApi`/темы, отсутствие дублей карты и мок-данных). +4. **Validation** — какие команды (`npm run test`, `npm run build`, ручная проверка) запускались и результаты. +5. **Risks / assumptions** — что не проверено, gap по данным/backend, edge cases. diff --git a/docs/ui-prototypes/pcp-tgu-editor/.thumbnail b/docs/ui-prototypes/pcp-tgu-editor/.thumbnail new file mode 100644 index 0000000..7ebeb3a Binary files /dev/null and b/docs/ui-prototypes/pcp-tgu-editor/.thumbnail differ diff --git a/docs/ui-prototypes/pcp-tgu-editor/Editor.html b/docs/ui-prototypes/pcp-tgu-editor/Editor.html new file mode 100644 index 0000000..640c0a2 --- /dev/null +++ b/docs/ui-prototypes/pcp-tgu-editor/Editor.html @@ -0,0 +1,96 @@ + + + + + +Редактор включений КА — Mission Ops + + + + + + +
+ + + + + + + + + + + + + + + + + + + diff --git a/docs/ui-prototypes/pcp-tgu-editor/Map.html b/docs/ui-prototypes/pcp-tgu-editor/Map.html new file mode 100644 index 0000000..5042274 --- /dev/null +++ b/docs/ui-prototypes/pcp-tgu-editor/Map.html @@ -0,0 +1,83 @@ + + + + + +Карта работ КА — Mission Ops + + + + + + +
+ + + + + + + + + + + + + + + + diff --git a/docs/ui-prototypes/pcp-tgu-editor/app.jsx b/docs/ui-prototypes/pcp-tgu-editor/app.jsx new file mode 100644 index 0000000..97f7de0 --- /dev/null +++ b/docs/ui-prototypes/pcp-tgu-editor/app.jsx @@ -0,0 +1,227 @@ +// Корневой компонент: состояние, фильтры, масштаб, тулбар, сборка строк. +const { useState, useMemo, useEffect, useRef } = React; + +// Пресеты масштаба: имя → видимое окно в мс (центрируется на «сейчас» при выборе). +const HOUR = D.HOUR, DAY = D.DAY; +const SCALES = [ + { id: "week", label: "Неделя", span: 7 * DAY, minPx: 64 }, + { id: "2week", label: "2 недели", span: 14 * DAY, minPx: 70 }, + { id: "month", label: "Месяц", span: 30 * DAY, minPx: 80 }, + { id: "range", label: "Весь диапазон", span: D.RANGE_END - D.RANGE_START, minPx: 90 }, +]; + +function App() { + // Видимость групп / скрытые КА. + const [groupShown, setGroupShown] = useState(() => { + const o = {}; D.GROUP_DEFS.forEach((g) => (o[g.id] = true)); return o; + }); + const [scHidden, setScHidden] = useState(() => new Set()); + const [typeFilter, setTypeFilter] = useState({ optical: true, radar: true, combo: true }); + const [search, setSearch] = useState(""); + const [scaleId, setScaleId] = useState("week"); + const [selectedIv, setSelectedIv] = useState(() => { + const p = new URLSearchParams(location.search).get("plan"); + return p ? p : null; + }); + const [selectedSc, setSelectedSc] = useState(null); + const [timelineCollapsed, setTimelineCollapsed] = useState(() => new Set()); + const [scrollSignal, setScrollSignal] = useState(0); + const [winCenter, setWinCenter] = useState(D.NOW); + + const byId = useMemo(() => { + const m = {}; D.plans.forEach((iv) => (m[iv.id] = iv)); return m; + }, []); + const scById = useMemo(() => { + const m = {}; D.spacecraft.forEach((sc) => (m[sc.id] = sc)); return m; + }, []); + + // Масштаб → px/ms (умещаем span в условные 1100px рабочей ширины). + const scale = SCALES.find((s) => s.id === scaleId); + const VIEW_W = 1180; + const pxPerMs = VIEW_W / scale.span; + const rangeStart = D.RANGE_START; + const rangeEnd = D.RANGE_END; + + // Фильтрация КА. + const scByGroup = useMemo(() => { + const m = {}; + D.spacecraft.forEach((sc) => { (m[sc.groupId] = m[sc.groupId] || []).push(sc); }); + return m; + }, []); + + const q = search.trim().toLowerCase(); + const matchesSearch = (sc) => !q || sc.name.toLowerCase().includes(q) || sc.short.toLowerCase().includes(q); + + // Счётчики для легенды. + const counts = useMemo(() => { + const byType = {}; + D.spacecraft.forEach((sc) => { byType[sc.type] = (byType[sc.type] || 0) + 1; }); + return { byType }; + }, []); + + // Интервалы по КА (с учётом типового фильтра не трогаем — фильтр по типу скрывает строки целиком). + const ivBySc = useMemo(() => { + const m = {}; + D.plans.forEach((iv) => { (m[iv.scId] = m[iv.scId] || []).push(iv); }); + return m; + }, []); + + // Сборка строк таймлайна. + const rows = useMemo(() => { + const out = []; + for (const g of D.GROUP_DEFS) { + if (!groupShown[g.id]) continue; + const list = (scByGroup[g.id] || []) + .filter((sc) => typeFilter[sc.type]) + .filter((sc) => !scHidden.has(sc.id)) + .filter(matchesSearch); + if (list.length === 0) continue; + out.push({ kind: "group", group: g, count: list.length }); + if (timelineCollapsed.has(g.id)) continue; + for (const sc of list) { + out.push({ kind: "sc", sc, ivs: ivBySc[sc.id] || [] }); + } + } + return out; + }, [groupShown, scHidden, typeFilter, q, timelineCollapsed]); + + // Цепочка родословной выбранного интервала. + const lineageSet = useMemo(() => { + if (!selectedIv) return null; + const lin = lineageOf(selectedIv, byId); + return new Set(lin.chain); + }, [selectedIv]); + + const selIv = selectedIv ? byId[selectedIv] : null; + const selSc = selIv ? scById[selIv.scId] : null; + const selGroup = selSc ? D.GROUP_DEFS.find((g) => g.id === selSc.groupId) : null; + + const toggleGroup = (id) => setGroupShown((s) => ({ ...s, [id]: !s[id] })); + const toggleSc = (id) => setScHidden((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; }); + const toggleCollapse = (id) => setTimelineCollapsed((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; }); + + const onPickSc = (id) => { + setSelectedSc(id); + // прокрутка к первому интервалу этого КА + const ivs = ivBySc[id] || []; + if (ivs.length) { setSelectedIv(null); } + }; + + // Статистика по видимым. + const stats = useMemo(() => { + let sc = 0, iv = 0, ov = 0; + rows.forEach((r) => { + if (r.kind === "sc") { + sc++; + iv += r.ivs.length; + ov += overlapSegments(r.ivs).length; + } + }); + return { sc, iv, ov }; + }, [rows]); + + const allOn = D.GROUP_DEFS.every((g) => groupShown[g.id]) && scHidden.size === 0; + const showAll = () => { const o = {}; D.GROUP_DEFS.forEach((g) => (o[g.id] = true)); setGroupShown(o); setScHidden(new Set()); }; + + return ( +
+ +
+ + +
+ {/* ТУЛБАР */} +
+ {/* масштаб */} +
+ {SCALES.map((s) => ( + + ))} +
+ + + +
+ + +
+ + {/* статистика */} +
+ + + 0 ? "var(--now)" : null} /> +
+ + {!allOn && ( + + )} +
+ + {rows.length === 0 ? ( +
+ Нет аппаратов по текущим фильтрам. +
+ ) : ( +
{}}> + +
+ )} +
+ +
setSelectedIv(null)} /> +
+
+ ); +} + +function WorksLegend() { + const items = [ + { k: "shoot", label: "Съёмка", stripes: true }, + { k: "downlink", label: "Сброс", fill: "var(--accent)" }, + { k: "calib", label: "Калибровка", fill: "oklch(0.78 0.02 200)" }, + { k: "service", label: "Служебная", dashed: true }, + ]; + return ( +
+ РАБОТЫ: + {items.map((it) => ( +
+ + {it.label} +
+ ))} +
+ ); +} + +function Stat({ label, v, accent }) { return ( +
+ {v} + {label} +
+ ); +} + +ReactDOM.createRoot(document.getElementById("root")).render(); diff --git a/docs/ui-prototypes/pcp-tgu-editor/basemap.js b/docs/ui-prototypes/pcp-tgu-editor/basemap.js new file mode 100644 index 0000000..5d313d4 --- /dev/null +++ b/docs/ui-prototypes/pcp-tgu-editor/basemap.js @@ -0,0 +1,107 @@ +// Web Mercator проекция + слой растровых тайлов (slippy-map) + кэш загрузки. +// Подложки: тёмная (CARTO, OSM-данные), классическая OSM, без тайлов (схема). +(function () { + const TILE = 256; + const DEG = Math.PI / 180, RAD = 180 / Math.PI; + const MAX_LAT = 85.0511; + + // lon/lat -> мировые пиксели при размере мира ws (= TILE * 2^z). + function lonToWorldX(lon, ws) { return (lon + 180) / 360 * ws; } + function latToWorldY(lat, ws) { + const l = Math.max(-MAX_LAT, Math.min(MAX_LAT, lat)); + const s = Math.sin(l * DEG); + const y = 0.5 - Math.log((1 + s) / (1 - s)) / (4 * Math.PI); + return y * ws; + } + function worldXToLon(wx, ws) { return wx / ws * 360 - 180; } + function worldYToLat(wy, ws) { + const n = Math.PI - 2 * Math.PI * (wy / ws); + return RAD * Math.atan(Math.sinh(n)); + } + + // Метры на пиксель в точке широты (для перевода км -> px). + function metersPerPixel(lat, ws) { + return Math.cos(lat * DEG) * 2 * Math.PI * 6378137 / ws; + } + + const PROVIDERS = { + dark: { + id: "dark", name: "Тёмная", + url: (z, x, y) => `https://a.basemaps.cartocdn.com/dark_all/${z}/${x}/${y}.png`, + scrim: 0.0, label: "© OpenStreetMap · © CARTO", maxZoom: 18, dark: true, + }, + osm: { + id: "osm", name: "Светлая", + url: (z, x, y) => `https://a.basemaps.cartocdn.com/rastertiles/voyager/${z}/${x}/${y}.png`, + scrim: 0.34, label: "© OpenStreetMap · © CARTO", maxZoom: 19, dark: false, + }, + schema: { + id: "schema", name: "Схема", url: null, scrim: 0, label: "Схематичная основа (оффлайн)", dark: true, + }, + }; + + // Кэш тайлов: key -> {img, loaded, error}. + const cache = new Map(); + function getTile(provider, z, x, y, onLoad) { + const key = provider.id + "/" + z + "/" + x + "/" + y; + let rec = cache.get(key); + if (rec) return rec; + rec = { img: new Image(), loaded: false, error: false }; + rec.img.crossOrigin = "anonymous"; + rec.img.onload = () => { rec.loaded = true; onLoad && onLoad(); }; + rec.img.onerror = () => { rec.error = true; }; + rec.img.src = provider.url(z, x, y); + cache.set(key, rec); + return rec; + } + + // Рисует слой тайлов на ctx для текущего вида. + // view: { centerLon, centerLat, z }, size: {w,h}. onLoad — колбэк перерисовки. + function drawTiles(ctx, provider, view, size, onLoad) { + if (!provider || !provider.url) return; + const { w, h } = size; + const z = view.z; + const tz = Math.max(1, Math.min(provider.maxZoom || 18, Math.round(z))); + const ws = TILE * Math.pow(2, z); // непрерывный размер мира + const k = Math.pow(2, z - tz); // масштаб тайла tz -> экран + const tileScreen = TILE * k; + const n = Math.pow(2, tz); + + const cwx = lonToWorldX(view.centerLon, ws); + const cwy = latToWorldY(view.centerLat, ws); + const originX = cwx - w / 2; // мировой px (непрерывный) в левом верхнем углу + const originY = cwy - h / 2; + + // Видимый диапазон тайлов в tz-пространстве. + const leftTz = (originX / k) / TILE; + const topTz = (originY / k) / TILE; + const rightTz = ((originX + w) / k) / TILE; + const botTz = ((originY + h) / k) / TILE; + const x0 = Math.floor(leftTz), x1 = Math.floor(rightTz); + const y0 = Math.max(0, Math.floor(topTz)), y1 = Math.min(n - 1, Math.floor(botTz)); + + ctx.imageSmoothingEnabled = true; + for (let tx = x0; tx <= x1; tx++) { + const wrapX = ((tx % n) + n) % n; // горизонтальный повтор мира + for (let ty = y0; ty <= y1; ty++) { + const rec = getTile(provider, tz, wrapX, ty, onLoad); + const sx = tx * tileScreen - originX; + const sy = ty * tileScreen - originY; + if (rec.loaded) { + ctx.drawImage(rec.img, Math.floor(sx), Math.floor(sy), Math.ceil(tileScreen) + 1, Math.ceil(tileScreen) + 1); + } + } + } + // затемняющий scrim (для светлой OSM, чтобы overlay читался) + if (provider.scrim > 0) { + ctx.fillStyle = `rgba(8,16,12,${provider.scrim})`; + ctx.fillRect(0, 0, w, h); + } + } + + window.Basemap = { + TILE, MAX_LAT, PROVIDERS, + lonToWorldX, latToWorldY, worldXToLon, worldYToLat, + metersPerPixel, drawTiles, getTile, + }; +})(); diff --git a/docs/ui-prototypes/pcp-tgu-editor/data.jsx b/docs/ui-prototypes/pcp-tgu-editor/data.jsx new file mode 100644 index 0000000..3b6771e --- /dev/null +++ b/docs/ui-prototypes/pcp-tgu-editor/data.jsx @@ -0,0 +1,195 @@ +// Тестовые данные планов КА. Детерминированная генерация (seeded RNG), +// чтобы при перезагрузке данные были стабильны. +(function () { + function mulberry32(a) { + return function () { + a |= 0; a = (a + 0x6D2B79F5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + } + const rnd = mulberry32(20260529); + const pick = (arr) => arr[Math.floor(rnd() * arr.length)]; + const randint = (a, b) => a + Math.floor(rnd() * (b - a + 1)); + + const HOUR = 3600 * 1000; + const DAY = 24 * HOUR; + // "Сейчас" — фиксируем под текущую дату проекта. + const NOW = new Date("2026-05-29T11:20:00Z").getTime(); + + // Три типа по аппаратуре (все аппараты — ДЗЗ). + const TYPES = { + optical: { id: "optical", label: "Оптическая", short: "ОПТ" }, + radar: { id: "radar", label: "Радиолокационная", short: "РЛ" }, + combo: { id: "combo", label: "Комбинированная", short: "ОПТ+РЛ" }, + }; + + // Программы/созвездия (название группы). Каждая группа тяготеет к своим типам. + const GROUP_DEFS = [ + { id: "resurs", name: "Ресурс-ОП", code: "RSO", weights: { optical: 0.8, radar: 0.05, combo: 0.15 } }, + { id: "kanopus", name: "Канопус-В", code: "KNP", weights: { optical: 0.7, radar: 0.1, combo: 0.2 } }, + { id: "kondor", name: "Кондор-ФКА", code: "KND", weights: { optical: 0.05, radar: 0.85, combo: 0.1 } }, + { id: "obzor", name: "Обзор-Р", code: "OBZ", weights: { optical: 0.05, radar: 0.8, combo: 0.15 } }, + { id: "meteor", name: "Метеор-М", code: "MTR", weights: { optical: 0.55, radar: 0.15, combo: 0.3 } }, + { id: "aist", name: "Аист-2", code: "AST", weights: { optical: 0.6, radar: 0.1, combo: 0.3 } }, + { id: "smotr", name: "Смотр-Р", code: "SMT", weights: { optical: 0.1, radar: 0.55, combo: 0.35 } }, + ]; + + function weightedType(weights) { + const r = rnd(); + let acc = 0; + for (const k of Object.keys(weights)) { + acc += weights[k]; + if (r <= acc) return k; + } + return "optical"; + } + + // Виды РАБОТ внутри плана. У каждой работы — своя категория (kind). + const WORK_KINDS = { + shoot: { id: "shoot", label: "Съёмка" }, + downlink: { id: "downlink", label: "Сброс на НКПОР" }, + calib: { id: "calib", label: "Калибровка" }, + service: { id: "service", label: "Служебная операция" }, + }; + const WORK_KIND_ORDER = ["shoot", "downlink", "calib", "service"]; + // Подпись работы-съёмки зависит от аппаратуры КА. + const SHOOT_LABEL = { + optical: ["Панхром. съёмка", "Мультиспектр. съёмка", "Стереосъёмка", "Площадная съёмка"], + radar: ["РСА съёмка", "Интерферометрия", "Прожекторный режим", "Сканирующий режим"], + combo: ["Совмещ. съёмка", "РСА + панхром.", "Площадная съёмка", "Мониторинг ЧС"], + }; + // Названия самих ПЛАНОВ (объемлющих интервалов). + const PLAN_NAMES = ["Суточная программа", "Целевая программа", "Спецзадание", "Программа мониторинга", "Сеансовый план", "Программа ДЗЗ"]; + + function genWorks(planStart, planEnd, scType, makeId) { + const works = []; + const span = planEnd - planStart; + // Плотность работ ~ 1 на 4–9 часов плана. + let cursor = planStart + randint(0, 2) * HOUR; + let guard = 0; + while (cursor < planEnd - 1 * HOUR && guard < 40) { + guard++; + const kind = pick(WORK_KIND_ORDER); + let dur; + if (kind === "shoot") dur = randint(20, 140) * 60 * 1000; // 20м–2.3ч + else if (kind === "downlink") dur = randint(8, 35) * 60 * 1000; // сброс + else if (kind === "calib") dur = randint(30, 90) * 60 * 1000; + else dur = randint(10, 40) * 60 * 1000; // служебная + const wStart = cursor; + const wEnd = Math.min(planEnd, wStart + dur); + if (wEnd <= wStart) break; + const label = kind === "shoot" ? pick(SHOOT_LABEL[scType]) : WORK_KINDS[kind].label; + works.push({ id: makeId(), kind, label, start: wStart, end: wEnd }); + // пауза между работами + cursor = wEnd + randint(20, 180) * 60 * 1000; + } + return works; + } + + // Генерация КА. + const spacecraft = []; + let scCounter = 0; + for (const g of GROUP_DEFS) { + const count = randint(8, 16); // суммарно ~80 + for (let i = 0; i < count; i++) { + const type = weightedType(g.weights); + scCounter++; + const num = i + 1; + spacecraft.push({ + id: `${g.id}-${num}`, + name: `${g.name} №${num}`, + short: `${g.code}-${String(num).padStart(2, "0")}`, + groupId: g.id, + type, + orbit: pick(["ССО 510 км", "ССО 575 км", "ССО 700 км", "ССО 480 км"]), + }); + } + } + + // Видимый горизонт планирования: ~ -16 сут .. +30 сут от "сейчас". + const RANGE_START = NOW - 16 * DAY; + const RANGE_END = NOW + 30 * DAY; + + function statusFor(start, end) { + if (end < NOW) return "executed"; + if (start <= NOW && NOW <= end) return "active"; + return "planned"; + } + + // Генерация ПЛАНОВ (объемлющих интервалов) с родословной (ревизии плана = потомки). + // Каждый план содержит набор РАБОТ внутри своего интервала. + const plans = []; + let ivCounter = 0; + let workCounter = 0; + const makeWorkId = () => `W-${String(++workCounter).padStart(5, "0")}`; + for (const sc of spacecraft) { + // стартовая точка цепочки + let cursor = RANGE_START + randint(0, 3) * DAY + randint(0, 20) * HOUR; + let prevId = null; + const nChains = randint(2, 4); + for (let c = 0; c < nChains; c++) { + // базовый план в цепочке + const dur = randint(8, 60) * HOUR; + const start = cursor; + const end = start + dur; + ivCounter++; + const baseId = `PL-${String(ivCounter).padStart(4, "0")}`; + const planName = pick(PLAN_NAMES); + plans.push({ + id: baseId, + scId: sc.id, + start, end, + title: planName, + rev: 1, + parentId: prevId, + status: statusFor(start, end), + author: pick(["ЦУП-1", "ЦУП-2", "ПЦ Москва", "ПЦ Дубна", "Авто-планировщик"]), + works: genWorks(start, end, sc.type, makeWorkId), + }); + prevId = baseId; + + // c вероятностью — ревизия, перекрывающая базовый план (создаёт пересечение) + if (rnd() < 0.45) { + const overlapStart = start + Math.floor(dur * (0.35 + rnd() * 0.4)); + const revDur = randint(10, 50) * HOUR; + const revEnd = overlapStart + revDur; + ivCounter++; + const revId = `PL-${String(ivCounter).padStart(4, "0")}`; + plans.push({ + id: revId, + scId: sc.id, + start: overlapStart, + end: revEnd, + title: planName, + rev: 2, + parentId: baseId, + status: statusFor(overlapStart, revEnd), + author: pick(["ЦУП-1", "ЦУП-2", "ПЦ Москва", "Авто-планировщик"]), + works: genWorks(overlapStart, revEnd, sc.type, makeWorkId), + }); + prevId = revId; + cursor = revEnd + randint(4, 36) * HOUR; + } else { + cursor = end + randint(6, 48) * HOUR; + } + if (cursor > RANGE_END) break; + } + } + + // Индексы родословной: дети по родителю. + const childrenOf = {}; + for (const iv of plans) { + if (iv.parentId) { + (childrenOf[iv.parentId] = childrenOf[iv.parentId] || []).push(iv.id); + } + } + + window.MissionData = { + HOUR, DAY, NOW, RANGE_START, RANGE_END, + TYPES, GROUP_DEFS, WORK_KINDS, WORK_KIND_ORDER, + spacecraft, plans, childrenOf, + typeOrder: ["optical", "radar", "combo"], + }; +})(); diff --git a/docs/ui-prototypes/pcp-tgu-editor/details.jsx b/docs/ui-prototypes/pcp-tgu-editor/details.jsx new file mode 100644 index 0000000..f2d2a30 --- /dev/null +++ b/docs/ui-prototypes/pcp-tgu-editor/details.jsx @@ -0,0 +1,159 @@ +// Правая панель деталей выбранного интервала + лента родословной (предки/потомки). +function StatusBadge({ status }) { + const map = { + executed: { t: "ВЫПОЛНЕН", c: "var(--ink-2)", bg: "var(--bg-3)" }, + active: { t: "АКТИВЕН", c: "var(--now)", bg: "oklch(0.86 0.16 25 / 0.16)" }, + planned: { t: "ЗАПЛАНИРОВАН", c: "var(--accent)", bg: "oklch(0.82 0.13 200 / 0.14)" }, + }; + const s = map[status] || map.planned; + return {s.t}; +} + +function Field({ label, children, mono }) { + return ( +
+ {label} + {children} +
+ ); +} + +function Details({ iv, sc, group, byId, onSelectIv, onClose }) { + if (!iv) { + return ( +
+ +
Выберите план на таймлайне, чтобы увидеть его работы и цепочку ревизий.
+
+ ); + } + const lin = lineageOf(iv.id, byId); + const dur = iv.end - iv.start; + const col = TYPE_COLOR[sc.type]; + + const chainRow = (id) => { + const it = byId[id]; + const isSel = id === iv.id; + return ( + + ); + }; + + return ( +
+
+
+
+ + {iv.id} +
+ +
+
{iv.title}
+
+ + {sc.short} · {group.name} +
+
+ +
+ {/* Время */} +
+ {fmtDateTime(iv.start)} + {fmtDateTime(iv.end)} + {fmtDur(dur)} + {iv.works.length} +
+ +
+ + {/* Аппарат */} +
+ АППАРАТ +
+ +
+
{sc.name}
+
{D.TYPES[sc.type].label} · {sc.orbit}
+
+
+ {iv.author} +
+ +
+ + {/* Работы внутри плана */} +
+
+ РАБОТЫ В ПЛАНЕ + {iv.works.length} +
+
+ {iv.works.length === 0 &&
Работы не заданы.
} + {iv.works.map((wk) => { + const fill = workFill(wk.kind, sc.type); + const isService = wk.kind === "service"; + return ( +
+ + {wk.label} + {fmtTime(wk.start)}–{fmtTime(wk.end)} +
+ ); + })} +
+
+ +
+ + {/* Родословная */} +
+
+ РОДОСЛОВНАЯ ПЛАНА + {lin.chain.length} ревизий +
+ + {lin.ancestors.length > 0 && ( +
+ ◀ Предки + {lin.ancestors.map(chainRow)} +
+ )} + +
+ ● Текущий + {chainRow(iv.id)} +
+ + {lin.descendants.length > 0 && ( +
+ ▶ Потомки + {lin.descendants.map(chainRow)} +
+ )} + + {lin.ancestors.length === 0 && lin.descendants.length === 0 && ( +
Это единственная ревизия плана — без предков и потомков.
+ )} +
+
+ + +
+ ); +} + +Object.assign(window, { Details }); diff --git a/docs/ui-prototypes/pcp-tgu-editor/editor.jsx b/docs/ui-prototypes/pcp-tgu-editor/editor.jsx new file mode 100644 index 0000000..278f659 --- /dev/null +++ b/docs/ui-prototypes/pcp-tgu-editor/editor.jsx @@ -0,0 +1,250 @@ +// Корень страницы «Редактор»: выбор активного КА/плана, черновик включений, +// drag/resize, добавление съёмки (карта) и сброса (станции), контекст других КА, +// undo/redo, проверка конфликтов (симуляция сервиса), применение правок. +const { useState: useStateE, useMemo: useMemoE, useEffect: useEffectE, useRef: useRefE, useCallback: useCbE } = React; +const HOUR_E = D.HOUR, DAY_E = D.DAY; + +function guessMode(scType, label) { + const modes = SENSOR_MODES[scType] || []; + const l = (label || "").toLowerCase(); + if (l.includes("стерео")) return "stereo"; + if (l.includes("мульти")) return "ms"; + if (l.includes("прожектор")) return "spot"; + if (l.includes("скан")) return "scan"; + if (l.includes("панхром")) return "pan"; + return modes[0] ? modes[0].id : "pan"; +} +function activePlanOf(scId) { + const ps = D.plans.filter((p) => p.scId === scId); + const act = ps.find((p) => p.start <= D.NOW && D.NOW <= p.end); + return (act || ps.sort((a, b) => b.start - a.start)[0] || null); +} +function seedDraft(plan, sc) { + if (!plan) return []; + const p = orbitParams(sc); + return plan.works.map((w) => { + const base = { id: w.id, kind: w.kind, label: w.label, start: w.start, end: w.end, origin: "plan" }; + if (w.kind === "shoot") base.modeId = guessMode(sc.type, w.label); + if (w.kind === "downlink") { + const s = subPoint(p, (w.start + w.end) / 2); + base.stationId = nearestStation(s.lat, s.lon, window.WorldGeo.GROUND_STATIONS).st.id; + } + return base; + }); +} + +function EditorApp() { + const params = new URLSearchParams(location.search); + const urlPlan = params.get("plan"); + const urlSc = params.get("sc"); + const planById = useMemoE(() => { const m = {}; D.plans.forEach((p) => (m[p.id] = p)); return m; }, []); + const scById = useMemoE(() => { const m = {}; D.spacecraft.forEach((s) => (m[s.id] = s)); return m; }, []); + + const initScId = urlSc || (urlPlan && planById[urlPlan] ? planById[urlPlan].scId : D.spacecraft[0].id); + const [activeScId, setActiveScId] = useStateE(initScId); + const activeSc = scById[activeScId]; + const p = useMemoE(() => orbitParams(activeSc), [activeScId]); + + const initPlanId = (urlPlan && planById[urlPlan] && planById[urlPlan].scId === activeScId) ? urlPlan : (activePlanOf(activeScId) || {}).id; + const [planId, setPlanId] = useStateE(initPlanId); + const plan = planById[planId]; + + // Черновик + история. + const [hist, setHist] = useStateE(() => ({ draft: seedDraft(plan, activeSc), past: [], future: [] })); + const draft = hist.draft; + const mutate = useCbE((fn) => setHist((h) => ({ draft: fn(h.draft), past: [...h.past, h.draft].slice(-50), future: [] })), []); + const undo = () => setHist((h) => h.past.length ? ({ draft: h.past[h.past.length - 1], past: h.past.slice(0, -1), future: [h.draft, ...h.future] }) : h); + const redo = () => setHist((h) => h.future.length ? ({ draft: h.future[0], past: [...h.past, h.draft], future: h.future.slice(1) }) : h); + + const reseed = (sid, pid) => { + const sc = scById[sid]; const pl = planById[pid]; + setHist({ draft: seedDraft(pl, sc), past: [], future: [] }); + setSelected(null); setRightMode("none"); setTarget(null); setSelPass(null); setServiceFlags({}); + }; + const onPickSc = (sid) => { + const pid = (activePlanOf(sid) || {}).id; + setActiveScId(sid); setPlanId(pid); reseed(sid, pid); + }; + const onPickPlan = (pid) => { setPlanId(pid); reseed(activeScId, pid); }; + + // Окно планирования. + const [win, setWin] = useStateE(() => { + if (plan) return { t0: plan.start - 3 * HOUR_E, t1: plan.end + 3 * HOUR_E }; + return { t0: D.NOW - 6 * HOUR_E, t1: D.NOW + 18 * HOUR_E }; + }); + useEffectE(() => { + if (plan) setWin({ t0: plan.start - 3 * HOUR_E, t1: plan.end + 3 * HOUR_E }); + }, [planId]); + const zoom = (f) => setWin((wv) => { const c = (wv.t0 + wv.t1) / 2, half = (wv.t1 - wv.t0) / 2 * f; return { t0: c - half, t1: c + half }; }); + const panBy = (f) => setWin((wv) => { const d = (wv.t1 - wv.t0) * f; return { t0: wv.t0 + d, t1: wv.t1 + d }; }); + + // Правый режим: none | inspect | shoot | downlink. + const [rightMode, setRightMode] = useStateE("none"); + const [selected, setSelected] = useStateE(null); + const selWork = draft.find((w) => w.id === selected) || null; + + // Цели съёмки. + const [target, setTarget] = useStateE(null); + const [selPass, setSelPass] = useStateE(null); + const [modeId, setModeId] = useStateE(null); + const passes = useMemoE(() => target ? targetPasses(p, target.lat, target.lon, win.t0, win.t1) : [], [target, p, win]); + + const onPickPoint = (ll) => { + const name = "Цель " + String.fromCharCode(65 + (Math.abs(Math.round(ll.lat * 7 + ll.lon * 3)) % 26)); + setTarget({ id: "tg-" + Date.now(), name, lat: ll.lat, lon: ll.lon }); + setSelPass(null); setModeId((SENSOR_MODES[activeSc.type][0] || {}).id); + }; + + // Станции для сброса. + const stationOptions = useMemoE(() => { + if (rightMode !== "downlink") return []; + return window.WorldGeo.GROUND_STATIONS.map((st) => ({ station: st, passes: stationPasses(p, st, win.t0, win.t1) })) + .sort((a, b) => b.passes.length - a.passes.length); + }, [rightMode, p, win]); + const [dlSel, setDlSel] = useStateE(null); + + // Добавление включений. + let newCtr = useRefE(0); + const addShoot = () => { + const dur = Math.min(selPass.end - selPass.start, 30 * 60 * 1000); + const start = selPass.start + Math.floor((selPass.end - selPass.start - dur) / 2); + const w = { id: "NW-" + (++newCtr.current) + "-" + Date.now() % 10000, kind: "shoot", label: modeLabel(activeSc.type, modeId), start, end: start + dur, modeId, targetName: target.name, targetLat: target.lat, targetLon: target.lon, isNew: true, origin: "added" }; + mutate((d) => [...d, w]); setSelected(w.id); setRightMode("inspect"); setTarget(null); setSelPass(null); + }; + const addDownlink = () => { + const ps = dlSel.pass, dur = Math.min(ps.end - ps.start, 12 * 60 * 1000); + const start = ps.start + Math.floor((ps.end - ps.start - dur) / 2); + const w = { id: "NW-" + (++newCtr.current) + "-" + Date.now() % 10000, kind: "downlink", label: "Сброс на НКПОР", start, end: start + dur, stationId: dlSel.station.id, isNew: true, origin: "added" }; + mutate((d) => [...d, w]); setSelected(w.id); setRightMode("inspect"); setDlSel(null); + }; + const updateWork = (id, patch) => mutate((d) => d.map((w) => w.id === id ? { ...w, ...patch } : w)); + const onChangeWork = (id, patch) => mutate((d) => d.map((w) => w.id === id ? { ...w, ...patch } : w)); // drag/resize (история на каждый шаг) + const deleteWork = (id) => { mutate((d) => d.filter((w) => w.id !== id)); setSelected(null); setRightMode("none"); }; + const duplicateWork = (id) => mutate((d) => { const w = d.find((x) => x.id === id); if (!w) return d; const off = 20 * 60 * 1000; const nw = { ...w, id: "NW-" + (++newCtr.current) + "-" + Date.now() % 10000, start: w.start + off, end: w.end + off, isNew: true, origin: "added" }; return [...d, nw]; }); + + // Конфликты: пересечения по времени — живые; видимость — по кнопке (сервис). + const overlaps = useMemoE(() => { + const res = {}; + for (let i = 0; i < draft.length; i++) for (let j = i + 1; j < draft.length; j++) { + const a = draft[i], b = draft[j]; + if (Math.max(a.start, b.start) < Math.min(a.end, b.end)) { + (res[a.id] = res[a.id] || []).push("Пересечение по времени с «" + kindLabel(b.kind) + "»"); + (res[b.id] = res[b.id] || []).push("Пересечение по времени с «" + kindLabel(a.kind) + "»"); + } + } + return res; + }, [draft]); + const [serviceFlags, setServiceFlags] = useStateE({}); + const [checking, setChecking] = useStateE(false); + const runCheck = () => { + setChecking(true); + setTimeout(() => { + const res = {}; + for (const w of draft) { + if (w.kind === "shoot" && w.targetLat != null) { + const ok = targetPasses(p, w.targetLat, w.targetLon, w.start - 6 * HOUR_E, w.end + 6 * HOUR_E) + .some((ps) => w.start >= ps.start - 1 && w.end <= ps.end + 1); + if (!ok) (res[w.id] = res[w.id] || []).push("Съёмка вне зоны видимости цели"); + } + if (w.kind === "downlink" && w.stationId) { + const st = window.WorldGeo.GROUND_STATIONS.find((s) => s.id === w.stationId); + const ok = stationPasses(p, st, w.start - 6 * HOUR_E, w.end + 6 * HOUR_E) + .some((ps) => w.start >= ps.start - 1 && w.end <= ps.end + 1); + if (!ok) (res[w.id] = res[w.id] || []).push("Сброс вне сеанса связи со станцией"); + } + } + setServiceFlags(res); setChecking(false); + }, 450); + }; + const conflicts = useMemoE(() => { + const m = {}; + for (const k in overlaps) m[k] = [...overlaps[k]]; + for (const k in serviceFlags) m[k] = [...(m[k] || []), ...serviceFlags[k]]; + return m; + }, [overlaps, serviceFlags]); + const conflictCount = Object.keys(conflicts).length; + + // Контекст: другие КА (по умолчанию — своя группа). + const groupMates = useMemoE(() => D.spacecraft.filter((s) => s.groupId === activeSc.groupId && s.id !== activeScId), [activeScId]); + const [otherShown, setOtherShown] = useStateE(() => new Set()); + useEffectE(() => { setOtherShown(new Set(groupMates.slice(0, 6).map((s) => s.id))); }, [activeScId]); + const [ctxScope, setCtxScope] = useStateE("group"); + const [ctxSearch, setCtxSearch] = useStateE(""); + const ivBySc = useMemoE(() => { const m = {}; D.plans.forEach((pl) => pl.works.forEach((w) => { (m[pl.scId] = m[pl.scId] || []).push({ ...w, scId: pl.scId }); })); return m; }, []); + const otherLanes = useMemoE(() => [...otherShown].map((sid) => ({ sc: scById[sid], works: (ivBySc[sid] || []).filter((w) => w.end >= win.t0 && w.start <= win.t1) })).filter((l) => l.sc), [otherShown, win]); + + const ctxList = useMemoE(() => { + const base = ctxScope === "group" ? groupMates : D.spacecraft.filter((s) => s.id !== activeScId); + const q = ctxSearch.trim().toLowerCase(); + return base.filter((s) => !q || s.short.toLowerCase().includes(q) || s.name.toLowerCase().includes(q)).slice(0, 60); + }, [ctxScope, ctxSearch, activeScId, groupMates]); + const toggleOther = (id) => setOtherShown((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; }); + + // Дорожки видимости (цель + станция выбранного сброса). + const visTracks = useMemoE(() => { + const out = []; + if (target) out.push({ id: "tgt", label: target.name, kind: "target", color: "var(--now)", windows: passes }); + if (selWork && selWork.kind === "downlink" && selWork.stationId) { + const st = window.WorldGeo.GROUND_STATIONS.find((s) => s.id === selWork.stationId); + if (st) out.push({ id: "st", label: st.name, kind: "station", color: "var(--accent)", windows: stationPasses(p, st, win.t0, win.t1) }); + } + return out; + }, [target, passes, selWork, p, win]); + + const [basemap, setBasemap] = useStateE("dark"); + const addedCount = draft.filter((w) => w.isNew).length; + + const onSelectWork = (id) => { setSelected(id); setRightMode("inspect"); }; + const applyDraft = () => { + // запись в in-memory план (прототип) + if (plan) plan.works = draft.map(({ id, kind, label, start, end, modeId, stationId, targetName }) => ({ id, kind, label, start, end, modeId, stationId, targetName })); + setHist((h) => ({ ...h, past: [], future: [] })); + setServiceFlags({}); + alert("Правки применены к плану " + planId + " (прототип, без записи во внешний сервис)."); + }; + + return ( +
+ + 0, canRedo: hist.future.length > 0, + runCheck, checking, conflictCount, applyDraft, addedCount, zoom, panBy, win, setRightMode, rightMode }} /> +
+ + +
+ {/* карта (для съёмки и контекста) */} +
+ {}} paramsBy={paramsByOf([activeSc])} scById={scById} + basemap={basemap} pickMode={rightMode === "shoot"} onPickPoint={onPickPoint} targets={target ? [target] : []} /> + {rightMode === "shoot" && ( +
+ Кликните точку съёмки на карте +
+ )} +
+ {/* таймлайн */} +
{ if (rightMode === "inspect") { setSelected(null); setRightMode("none"); } }}> + +
+
+ + {rightMode !== "none" && ( +
+ {rightMode === "inspect" && selWork && { setSelected(null); setRightMode("none"); }} />} + {rightMode === "inspect" && !selWork &&
Включение не выбрано.
} + {rightMode === "shoot" && { setRightMode("none"); setTarget(null); setSelPass(null); }, onClearTarget: () => { setTarget(null); setSelPass(null); } }} />} + {rightMode === "downlink" && { setRightMode("none"); setDlSel(null); } }} />} +
+ )} +
+
+ ); +} + +function paramsByOf(scs) { const m = {}; scs.forEach((s) => { if (s) m[s.id] = orbitParams(s); }); return m; } + +ReactDOM.createRoot(document.getElementById("root")).render(); diff --git a/docs/ui-prototypes/pcp-tgu-editor/editorpanels.jsx b/docs/ui-prototypes/pcp-tgu-editor/editorpanels.jsx new file mode 100644 index 0000000..75d4b1d --- /dev/null +++ b/docs/ui-prototypes/pcp-tgu-editor/editorpanels.jsx @@ -0,0 +1,232 @@ +// Правая контекстная панель редактора: инспектор выбранного включения, +// флоу добавления съёмки (цель+проход+режим) и сброса (станция+сеанс). +const { useState: useStateEP, useMemo: useMemoEP } = React; + +function Sect({ title, right, children }) { + return ( +
+
+ {title} + {right} +
+ {children} +
+ ); +} +function Btn({ children, onClick, kind, disabled, flex }) { + const styles = { + primary: { background: "var(--accent)", color: "var(--bg-0)", border: "1px solid var(--accent)" }, + danger: { background: "transparent", color: "var(--now)", border: "1px solid color-mix(in oklch, var(--now) 50%, var(--bg-0))" }, + ghost: { background: "var(--bg-2)", color: "var(--ink-1)", border: "1px solid var(--line)" }, + }; + return ( + + ); +} +function passDur(p) { return fmtDur(p.end - p.start); } + +// ---- Инспектор выбранного включения ---- +function WorkInspector({ work, activeSc, conflicts, onUpdate, onDelete, onDuplicate, onClose }) { + const col = kindColor(work.kind, activeSc.type); + const confl = conflicts[work.id] || []; + const modes = SENSOR_MODES[activeSc.type] || []; + const stations = window.WorldGeo.GROUND_STATIONS; + return ( +
+
+
+ + + {kindLabel(work.kind)} + {work.isNew && NEW} + + +
+
{work.id}
+
+ + {confl.length > 0 && ( +
+
⚠ КОНФЛИКТ (сервис проверки)
+ {confl.map((c, i) =>
{c}
)} +
+ )} + + + onUpdate(work.id, { start: v })} /> + onUpdate(work.id, { end: v })} /> +
длительность: {fmtDur(work.end - work.start)}
+
+ + {work.kind === "shoot" && ( + +
+ {modes.map((m) => ( + + ))} +
+ {work.targetName &&
цель: {work.targetName}
} +
+ )} + + {work.kind === "downlink" && ( + + + + )} + +
+ onDuplicate(work.id)}>Дублировать + onDelete(work.id)}>Удалить +
+
+ ); +} + +// Подстройка времени степперами (±5 мин / ±1 ч) + моноширинный вывод даты/времени. +function TimeStepper({ label, value, onChange, min, max }) { + const clamp = (v) => Math.max(min != null ? min : -Infinity, Math.min(max != null ? max : Infinity, v)); + const nudge = (deltaMs) => onChange(clamp(value + deltaMs)); + const M = 60000, H = 3600000; + return ( +
+ {label} +
+ nudge(-H)}>−ч + nudge(-5 * M)}>−5м + {fmtDateTime(value)} + nudge(5 * M)}>+5м + nudge(H)}>+ч +
+
+ ); +} +function StepBtn({ children, onClick }) { + return ; +} + +// ---- Флоу добавления СЪЁМКИ ---- +function ShootPanel({ activeSc, target, passes, selectedPass, setSelectedPass, modeId, setModeId, onAdd, onClose, onClearTarget }) { + const modes = SENSOR_MODES[activeSc.type] || []; + return ( +
+ + сбросить}> + {!target ? ( +
+ Кликните точку на карте слева — запросим расписание видимости этой точки аппаратом {activeSc.short}. +
+ ) : ( +
+ + {target.name} + {target.lat.toFixed(2)}, {target.lon.toFixed(2)} +
+ )} +
+ + {target && ( + + {passes.length === 0 ? ( +
В окне планирования проходов над целью нет.
+ ) : ( +
+ {passes.map((ps, i) => { + const sel = selectedPass && selectedPass.start === ps.start; + return ( + + ); + })} +
+ )} +
+ )} + + {target && selectedPass && ( + +
+ {modes.map((m) => ( + + ))} +
+
+ )} + + + Добавить съёмку в план +
+ ); +} + +// ---- Флоу добавления СБРОСА ---- +function DownlinkPanel({ activeSc, stationOptions, selected, setSelected, onAdd, onClose }) { + return ( +
+ +
+ Расписание сеансов связи аппарата {activeSc.short} со станциями приёма. Выберите станцию и сеанс. +
+
+ {stationOptions.map((so) => ( +
+
+ + {so.station.name} + {so.passes.length} сеанс. +
+ {so.passes.length === 0 ? ( +
нет сеансов в окне
+ ) : ( +
+ {so.passes.slice(0, 5).map((ps, i) => { + const sel = selected && selected.station.id === so.station.id && selected.pass.start === ps.start; + return ( + + ); + })} +
+ )} +
+ ))} +
+ + Добавить сброс в план +
+ ); +} + +function PanelHead({ title, onClose }) { + return ( +
+ {title} + +
+ ); +} +function QualityDot({ q }) { + const c = q > 0.6 ? "var(--t-optical)" : q > 0.3 ? "var(--t-radar)" : "var(--ink-3)"; + return ; +} + +Object.assign(window, { WorkInspector, ShootPanel, DownlinkPanel }); diff --git a/docs/ui-prototypes/pcp-tgu-editor/editorrail.jsx b/docs/ui-prototypes/pcp-tgu-editor/editorrail.jsx new file mode 100644 index 0000000..c449832 --- /dev/null +++ b/docs/ui-prototypes/pcp-tgu-editor/editorrail.jsx @@ -0,0 +1,170 @@ +// Тулбар и левая панель редактора. +const { useState: useStateER } = React; + +function EditorToolbar(props) { + const { activeSc, scById, activeScId, onPickSc, planId, plan, onPickPlan, planById, + undo, redo, canUndo, canRedo, runCheck, checking, conflictCount, applyDraft, addedCount, zoom, panBy, win, setRightMode } = props; + const craftPlans = D.plans.filter((p) => p.scId === activeScId); + + return ( +
+ {/* выбор КА */} +
+ + +
+ {/* выбор плана */} + + + {/* добавить включение */} +
+ + +
+ +
+ + {/* зум / пан окна */} +
+ panBy(-0.25)} title="назад">◀ + zoom(1.4)} title="отдалить">− + {fmtDur(win.t1 - win.t0)} + zoom(0.7)} title="приблизить">+ + panBy(0.25)} title="вперёд">▶ +
+ + {/* undo / redo */} +
+ + +
+ + {/* проверка */} + + + +
+ ); +} +function addBtn(active, col) { + return { + display: "flex", alignItems: "center", gap: 5, padding: "7px 11px", borderRadius: 6, fontSize: 11.5, fontWeight: 600, + border: `1px solid ${active ? (col || "var(--t-optical)") : "var(--line)"}`, + background: active ? (col ? "oklch(0.82 0.13 200 / 0.16)" : "oklch(0.80 0.14 158 / 0.14)") : "var(--bg-2)", + color: "var(--ink-1)", + }; +} +function IconBtn({ children, onClick, disabled, title }) { + return ( + + ); +} + +function EditorRail(props) { + const { activeSc, plan, draft, conflictCount, ctxList, ctxScope, setCtxScope, ctxSearch, setCtxSearch, + otherShown, toggleOther, scById, setRightMode, basemap, setBasemap } = props; + const group = D.GROUP_DEFS.find((g) => g.id === activeSc.groupId); + const nShoot = draft.filter((w) => w.kind === "shoot").length; + const nDown = draft.filter((w) => w.kind === "downlink").length; + + return ( +
+ {/* активный КА */} +
+
РЕДАКТИРУЕТСЯ
+
+ +
+
{activeSc.name}
+
{D.TYPES[activeSc.type].label} · {group.name}
+
+
+
+ + + + +
+
+ + {/* подложка карты */} +
+
ПОДЛОЖКА КАРТЫ
+
+ {[["dark", "Тёмная"], ["osm", "Светлая"], ["schema", "Схема"]].map(([id, lab]) => ( + + ))} +
+
+ + {/* контекст: другие КА */} +
+
+
+ ДРУГИЕ КА · КОНТЕКСТ + {otherShown.size} вкл. +
+
+ {[["group", "Своя группа"], ["all", "Все"]].map(([id, lab]) => ( + + ))} +
+ {ctxScope === "all" && ( + setCtxSearch(e.target.value)} placeholder="Поиск КА…" + style={{ width: "100%", padding: "6px 8px", background: "var(--bg-0)", border: "1px solid var(--line)", borderRadius: 6, color: "var(--ink)", fontFamily: "var(--mono)", fontSize: 11.5, marginBottom: 4 }} /> + )} +
+
+ {ctxList.map((s) => { + const on = otherShown.has(s.id); + return ( + + ); + })} +
+
+
+ ); +} +function RailStat({ label, v, c }) { + return ( +
+ {v} + {label} +
+ ); +} + +Object.assign(window, { EditorToolbar, EditorRail }); diff --git a/docs/ui-prototypes/pcp-tgu-editor/editortimeline.jsx b/docs/ui-prototypes/pcp-tgu-editor/editortimeline.jsx new file mode 100644 index 0000000..fe0a0e4 --- /dev/null +++ b/docs/ui-prototypes/pcp-tgu-editor/editortimeline.jsx @@ -0,0 +1,226 @@ +// Редактируемый таймлайн: активный КА (drag/resize включений), дорожки видимости +// (цели/станции), контекст других КА (read-only). Конфликты подсвечиваются. +const { useRef: useRefET, useState: useStateET, useEffect: useEffectET, useMemo: useMemoET } = React; + +const ET_LABEL_W = 168; +const ET_AXIS_H = 40; +const ET_ACTIVE_H = 66; +const ET_VIS_H = 22; +const ET_OTHER_H = 30; +const SNAP_MS = 60 * 1000; // минута + +function kindColor(kind, scType) { + if (kind === "shoot") return TYPE_COLOR[scType]; + if (kind === "downlink") return "var(--accent)"; + if (kind === "calib") return "oklch(0.78 0.02 200)"; + return "var(--ink-3)"; +} +function kindLabel(kind) { + return kind === "shoot" ? "Съёмка" : kind === "downlink" ? "Сброс" : kind === "calib" ? "Калибровка" : "Служебная"; +} + +function EditorTimeline(props) { + const { activeSc, draft, selectedWorkId, onSelectWork, t0, t1, now, + onChangeWork, otherLanes, visTracks, conflicts } = props; + + const wrapRef = useRefET(null); + const [w, setW] = useStateET(900); + const drag = useRefET(null); + + useEffectET(() => { + const el = wrapRef.current; if (!el) return; + const ro = new ResizeObserver(() => setW(el.clientWidth - ET_LABEL_W)); + ro.observe(el); setW(el.clientWidth - ET_LABEL_W); + return () => ro.disconnect(); + }, []); + + const span = t1 - t0; + const pxPerMs = w / span; + const X = (t) => (t - t0) * pxPerMs; + const T = (x) => t0 + x / pxPerMs; + + // Засечки времени. + const ticks = useMemoET(() => niceTicks(t0, t1, pxPerMs, 72), [t0, t1, pxPerMs]); + const days = useMemoET(() => { + const out = []; const d0 = new Date(t0); + let t = Date.UTC(d0.getUTCFullYear(), d0.getUTCMonth(), d0.getUTCDate()); + while (t <= t1) { out.push(t); t += 86400000; } + return out; + }, [t0, t1]); + + // ---- drag / resize ---- + const startDrag = (e, work, mode) => { + e.stopPropagation(); e.preventDefault(); + drag.current = { id: work.id, mode, startX: e.clientX, t0: work.start, t1: work.end }; + onSelectWork(work.id); + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + }; + const onMove = (e) => { + const d = drag.current; if (!d) return; + const dt = Math.round(((e.clientX - d.startX) / pxPerMs) / SNAP_MS) * SNAP_MS; + let ns = d.t0, ne = d.t1; + if (d.mode === "move") { ns = d.t0 + dt; ne = d.t1 + dt; } + else if (d.mode === "l") { ns = Math.min(d.t1 - SNAP_MS, d.t0 + dt); } + else if (d.mode === "r") { ne = Math.max(d.t0 + SNAP_MS, d.t1 + dt); } + onChangeWork(d.id, { start: ns, end: ne }); + }; + const onUp = () => { + drag.current = null; + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + }; + + const totalOtherH = otherLanes.reduce((a) => a + ET_OTHER_H, 0); + const visH = visTracks.length * ET_VIS_H; + + const lane = (top, h, children, label, sub, bg, key) => ( + +
+ {label} + {sub} +
+
+ {children} +
+
+ ); + + let yCursor = ET_AXIS_H; + const activeTop = yCursor; yCursor += ET_ACTIVE_H; + const visTop = yCursor; yCursor += visH; + const dividerTop = yCursor; yCursor += otherLanes.length ? 22 : 0; + const othersTop = yCursor; + const totalH = ET_AXIS_H + ET_ACTIVE_H + visH + (otherLanes.length ? 22 : 0) + totalOtherH + 8; + + return ( +
+
+ {/* фон-сетка по дням/часам */} +
+ {ticks.ticks.map((tk, i) => ( +
+ ))} +
+ + {/* ОСЬ */} +
+ ВРЕМЯ (UTC) +
+
+ {days.map((d, i) => ( +
+ {pad(new Date(d).getUTCDate())} + {MONTHS[new Date(d).getUTCMonth()]} +
+ ))} + {ticks.ticks.map((tk, i) => ( +
+ {fmtTime(tk.t)} +
+ ))} +
+ + {/* линия СЕЙЧАС */} + {now >= t0 && now <= t1 && ( +
+ СЕЙЧАС +
+ )} + + {/* АКТИВНЫЙ КА */} + {lane(activeTop, ET_ACTIVE_H, + + {draft.map((wk) => { + const left = X(wk.start), bw = Math.max(6, X(wk.end) - X(wk.start)); + const col = kindColor(wk.kind, activeSc.type); + const sel = wk.id === selectedWorkId; + const confl = conflicts[wk.id]; + return ( +
startDrag(e, wk, "move")} onClick={(e) => { e.stopPropagation(); onSelectWork(wk.id); }} + title={`${kindLabel(wk.kind)} · ${fmtDateTime(wk.start)}–${fmtTime(wk.end)}`} + style={{ + position: "absolute", left, width: bw, top: 12, height: ET_ACTIVE_H - 24, + background: `color-mix(in oklch, ${col} 24%, var(--bg-1))`, + border: `1px solid ${confl ? "var(--now)" : `color-mix(in oklch, ${col} 55%, var(--bg-1))`}`, + borderLeft: `3px solid ${col}`, borderRadius: 5, cursor: "grab", zIndex: sel ? 6 : 4, + outline: sel ? "1.5px solid var(--accent)" : "none", + boxShadow: sel ? "0 0 0 3px oklch(0.82 0.13 200 / 0.18)" : "none", + overflow: "hidden", userSelect: "none", + }}> + {confl &&
} + {/* ручки resize */} +
startDrag(e, wk, "l")} style={{ position: "absolute", left: 0, top: 0, bottom: 0, width: 7, cursor: "ew-resize", zIndex: 7 }} /> +
startDrag(e, wk, "r")} style={{ position: "absolute", right: 0, top: 0, bottom: 0, width: 7, cursor: "ew-resize", zIndex: 7 }} /> +
+
+ + {bw > 56 && {wk.label}} + {wk.isNew && NEW} +
+ {bw > 70 &&
{fmtTime(wk.start)}–{fmtTime(wk.end)}
} +
+
+ ); + })} + , +
+ + {activeSc.short} +
, + редактируется, + "var(--bg-2)", "active" + )} + + {/* ДОРОЖКИ ВИДИМОСТИ */} + {visTracks.map((vt, idx) => lane(visTop + idx * ET_VIS_H, ET_VIS_H, + + {vt.windows.map((win, i) => { + const left = X(win.start), bw = Math.max(2, X(win.end) - X(win.start)); + if (win.end < t0 || win.start > t1) return null; + return ( +
+ ); + })} + , + + {vt.label} + , + {vt.kind === "target" ? "цель · видимость" : "станция · видимость"}, + "var(--bg-1)", "vis-" + vt.id + ))} + + {/* разделитель */} + {otherLanes.length > 0 && ( +
+ ДРУГИЕ КА · КОНТЕКСТ (read-only) +
+ )} + + {/* ДРУГИЕ КА */} + {otherLanes.map((ln, idx) => lane(othersTop + idx * ET_OTHER_H, ET_OTHER_H, + + {ln.works.map((wk) => { + const left = X(wk.start), bw = Math.max(3, X(wk.end) - X(wk.start)); + if (wk.end < t0 || wk.start > t1) return null; + const col = kindColor(wk.kind, ln.sc.type); + return ( +
+ ); + })} + , + + {ln.sc.short} + , + null, "var(--bg-1)", "other-" + ln.sc.id + ))} +
+
+ ); +} + +Object.assign(window, { EditorTimeline, kindColor, kindLabel }); diff --git a/docs/ui-prototypes/pcp-tgu-editor/mapapp.jsx b/docs/ui-prototypes/pcp-tgu-editor/mapapp.jsx new file mode 100644 index 0000000..367e801 --- /dev/null +++ b/docs/ui-prototypes/pcp-tgu-editor/mapapp.jsx @@ -0,0 +1,162 @@ +// Страница «Карта»: вкладки, левые контролы (охват/слои/выбор КА), детали, ?plan=. +const { useState: useStateMA, useMemo: useMemoMA, useEffect: useEffectMA } = React; + +const HOUR_MS = 3600000, DAY_MS = 86400000; +const PLAN_PAGE = "План КА — Mission Ops.html"; + +function MapApp() { + const byId = useMemoMA(() => { const m = {}; D.plans.forEach(p => m[p.id] = p); return m; }, []); + const scById = useMemoMA(() => { const m = {}; D.spacecraft.forEach(s => m[s.id] = s); return m; }, []); + const ivBySc = useMemoMA(() => { const m = {}; D.plans.forEach(p => (m[p.scId] = m[p.scId] || []).push(p)); return m; }, []); + const paramsBy = useMemoMA(() => { const m = {}; D.spacecraft.forEach(s => m[s.id] = orbitParams(s)); return m; }, []); + + // начальное состояние из URL + const urlParams = new URLSearchParams(location.search); + const urlPlan = urlParams.get("plan"); + const urlSc = urlParams.get("sc"); + const initPlan = urlPlan && byId[urlPlan] ? urlPlan : null; + const initSc = initPlan ? byId[initPlan].scId : (urlSc && scById[urlSc] ? urlSc : D.spacecraft[0].id); + + const [scope, setScope] = useStateMA(initPlan || urlSc ? "one" : "one"); + const [selSc, setSelSc] = useStateMA(initSc); + const [selPlan, setSelPlan] = useStateMA(initPlan); + const [search, setSearch] = useStateMA(""); + const [layers, setLayers] = useStateMA({ tracks: true, swath: true, shoot: true, downlink: true, stations: true, terminator: true }); + const [basemap, setBasemap] = useStateMA("dark"); + + const sc = scById[selSc]; + const plan = selPlan ? byId[selPlan] : null; + + // окно времени + const win = useMemoMA(() => { + if (plan) return { t0: plan.start, t1: Math.min(plan.end, plan.start + 3 * DAY_MS) }; + return { t0: D.NOW - 3 * HOUR_MS, t1: D.NOW + 9 * HOUR_MS }; + }, [plan]); + + // список КА в охвате + const scList = useMemoMA(() => { + if (scope === "one") return [sc]; + if (scope === "group") return D.spacecraft.filter(s => s.groupId === sc.groupId); + return D.spacecraft; + }, [scope, sc]); + + const toggleLayer = (k) => setLayers(l => ({ ...l, [k]: !l[k] })); + + const onSelectSc = (id) => { setSelSc(id); setSelPlan(null); if (scope === "all") setScope("group"); }; + const onSelectPlan = (id) => { const p = byId[id]; setSelSc(p.scId); setSelPlan(id); setScope("one"); }; + + // фильтр выбора КА + const q = search.trim().toLowerCase(); + const scResults = useMemoMA(() => D.spacecraft.filter(s => !q || s.name.toLowerCase().includes(q) || s.short.toLowerCase().includes(q)), [q]); + + return ( +
+ + +
+ {/* ЛЕВЫЕ КОНТРОЛЫ */} +
+ {/* подложка */} +
+
ПОДЛОЖКА
+
+ {[["dark", "Тёмная"], ["osm", "Светлая"], ["schema", "Схема"]].map(([id, lab]) => ( + + ))} +
+
+ + {/* охват */} +
+
ОХВАТ
+
+ {[["one", "1 КА"], ["group", "Группа"], ["all", "Все"]].map(([id, lab]) => ( + + ))} +
+
+ + {sc.short} + · {D.GROUP_DEFS.find(g => g.id === sc.groupId).name} +
+
+ + {/* окно времени */} +
+
ОКНО ВРЕМЕНИ
+
{fmtDateTime(win.t0)} → {fmtDateTime(win.t1)}
+
{plan ? `план ${plan.id}` : "сутки вокруг «сейчас»"}
+
+ + {/* слои */} +
+
СЛОИ
+
+ toggleLayer("tracks")} swatch={} label="Трассы КА" /> + toggleLayer("swath")} swatch={} label="Полосы обзора (swath)" /> + toggleLayer("shoot")} swatch={} label="Контуры маршрутов съёмки" /> + toggleLayer("downlink")} swatch={} label="Участки сброса" /> + toggleLayer("stations")} swatch={} label="Станции приёма (НКПОР)" /> + toggleLayer("terminator")} swatch={} label="Терминатор день/ночь" /> +
+
+ + {/* выбор КА */} +
+
АППАРАТ В ФОКУСЕ
+
+ setSearch(e.target.value)} placeholder="Поиск КА…" + style={{ width: "100%", padding: "7px 8px", background: "var(--bg-0)", border: "1px solid var(--line)", borderRadius: 6, color: "var(--ink)", fontFamily: "var(--mono)", fontSize: 12, outline: "none" }} /> +
+
+
+ {scResults.map((s) => ( +
onSelectSc(s.id)} + style={{ display: "flex", alignItems: "center", gap: 8, padding: "5px 8px", borderRadius: 6, cursor: "pointer", background: s.id === selSc ? "var(--bg-3)" : "transparent" }}> + + {s.short} + {(ivBySc[s.id] || []).length} пл. +
+ ))} +
+
+ + {/* КАРТА */} +
+ + {/* плашка масштаба/охвата */} +
+ ОХВАТ: + {scope === "one" ? "1 КА" : scope === "group" ? "Группа" : "Все"} + + {scList.length} КА +
+
+ + {/* ДЕТАЛИ ПЛАНА */} + {plan && setSelPlan(null)} planPage={PLAN_PAGE} />} +
+
+ ); +} + +function LayerRow({ on, onClick, swatch, label }) { + return ( + + ); +} +const LineSwatch = () => ; +const BandSwatch = () => ; +const PolySwatch = () => ; +const DownSwatch = () => ; +const StationSwatch = () => ; +const NightSwatch = () => ; + +ReactDOM.createRoot(document.getElementById("root")).render(); diff --git a/docs/ui-prototypes/pcp-tgu-editor/mapdetails.jsx b/docs/ui-prototypes/pcp-tgu-editor/mapdetails.jsx new file mode 100644 index 0000000..4f0c8be --- /dev/null +++ b/docs/ui-prototypes/pcp-tgu-editor/mapdetails.jsx @@ -0,0 +1,125 @@ +// Панель деталей плана на странице «Карта»: сводка, наземные параметры, +// работы (со «съёмка/сброс»), ссылка обратно на таймлайн. +function MapStatusBadge({ status }) { + const map = { + executed: { t: "ВЫПОЛНЕН", c: "var(--ink-2)", bg: "var(--bg-3)" }, + active: { t: "АКТИВЕН", c: "var(--now)", bg: "oklch(0.86 0.16 25 / 0.16)" }, + planned: { t: "ЗАПЛАНИРОВАН", c: "var(--accent)", bg: "oklch(0.82 0.13 200 / 0.14)" }, + }; + const s = map[status] || map.planned; + return {s.t}; +} + +function MapDetails({ plan, sc, params, byId, onSelectPlan, onClose, planPage }) { + const col = TYPE_COLOR[sc.type]; + const shoots = plan.works.filter(w => w.kind === "shoot"); + const downs = plan.works.filter(w => w.kind === "downlink"); + const STATIONS = window.WorldGeo.GROUND_STATIONS; + + // станции, задействованные в сбросах + const usedStations = []; + for (const d of downs) { + const pts = trackPoints(params, d.start, d.end, 10); + const mid = pts[Math.floor(pts.length / 2)]; + const ns = nearestStation(mid.lat, mid.lon, STATIONS); + if (ns.st && !usedStations.find(x => x.id === ns.st.id)) usedStations.push(ns.st); + } + + return ( +
+
+
+
+ + {plan.id} +
+ +
+
{plan.title}
+
+ + {sc.short} · {sc.orbit} +
+
+ +
+ {/* наземные параметры */} +
+
НАЗЕМНЫЕ ПАРАМЕТРЫ
+
+ {params.h} км + {(params.T / 60).toFixed(1)} мин + {Math.round(params.swathKm)} км + {(params.incl * RAD).toFixed(1)}° +
+
+ +
+ + {/* съёмка */} +
+
+ + КОНТУРЫ СЪЁМКИ + {shoots.length} +
+
+ {shoots.length === 0 &&
Съёмочных маршрутов нет.
} + {shoots.map(w => ( +
+ {w.label} + {fmtTime(w.start)}–{fmtTime(w.end)} +
+ ))} +
+
+ + {/* сброс */} +
+
+ + УЧАСТКИ СБРОСА + {downs.length} +
+
+ {downs.length === 0 &&
Сбросов нет.
} + {downs.map(w => { + const pts = trackPoints(params, w.start, w.end, 10); + const mid = pts[Math.floor(pts.length / 2)]; + const ns = nearestStation(mid.lat, mid.lon, STATIONS); + return ( +
+ → {ns.st.name} + {fmtTime(w.start)}–{fmtTime(w.end)} +
+ ); + })} +
+
+ + {usedStations.length > 0 && ( +
+ Задействованы станции: {usedStations.map(s => s.name).join(", ")} +
+ )} +
+ + +
+ ); +} + +function KV({ label, children }) { + return ( +
+ {label} + {children} +
+ ); +} + +Object.assign(window, { MapDetails }); diff --git a/docs/ui-prototypes/pcp-tgu-editor/mapview.jsx b/docs/ui-prototypes/pcp-tgu-editor/mapview.jsx new file mode 100644 index 0000000..592ca0e --- /dev/null +++ b/docs/ui-prototypes/pcp-tgu-editor/mapview.jsx @@ -0,0 +1,323 @@ +// Карта: Web Mercator. Canvas-основа (тайлы OSM/CARTO или векторная схема, +// сетка, трассы, swath, терминатор) + SVG-overlay (станции, «сейчас», +// контуры съёмки, сброс, hover). Зум/панорама как у slippy-карты. +const { useRef: useRefMV, useState: useStateMV, useEffect: useEffectMV, useMemo: useMemoMV, useCallback } = React; + +const STATIONS = window.WorldGeo.GROUND_STATIONS; +const BM = window.Basemap; +const TILE = BM.TILE; + +function MapView(props) { + const { scList, selectedPlan, window: win, now, layers, onSelectSc, paramsBy, scById, basemap, + pickMode, onPickPoint, targets } = props; + const wrapRef = useRefMV(null); + const canvasRef = useRefMV(null); + const [dims, setDims] = useStateMV({ w: 1000, h: 560 }); + const [view, setView] = useStateMV({ centerLon: 45, centerLat: 30, z: 2.2, init: false }); + const [hover, setHover] = useStateMV(null); + const [tileTick, setTileTick] = useStateMV(0); + const drag = useRefMV(null); + const provider = BM.PROVIDERS[basemap] || BM.PROVIDERS.dark; + + const ws = useMemoMV(() => TILE * Math.pow(2, view.z), [view.z]); + + // Инициализация: вписать мир по ширине. + useEffectMV(() => { + if (view.init || !dims.w) return; + const z = Math.max(0.7, Math.log2(dims.w / TILE) - 0.05); + setView((v) => ({ ...v, z, centerLon: 45, centerLat: 28, init: true })); + }, [dims, view.init]); + + const project = useCallback((lon, lat) => { + const cwx = BM.lonToWorldX(view.centerLon, ws); + const cwy = BM.latToWorldY(view.centerLat, ws); + let wx = BM.lonToWorldX(lon, ws); + // выбрать ближайшую копию мира по X (горизонтальный повтор) + const half = ws / 2; + while (wx - cwx > half) wx -= ws; + while (wx - cwx < -half) wx += ws; + return { + x: dims.w / 2 + (wx - cwx), + y: dims.h / 2 + (BM.latToWorldY(lat, ws) - cwy), + }; + }, [view.centerLon, view.centerLat, ws, dims]); + + const unproject = useCallback((sx, sy) => { + const cwx = BM.lonToWorldX(view.centerLon, ws); + const cwy = BM.latToWorldY(view.centerLat, ws); + return { + lon: BM.worldXToLon(cwx + (sx - dims.w / 2), ws), + lat: BM.worldYToLat(cwy + (sy - dims.h / 2), ws), + }; + }, [view.centerLon, view.centerLat, ws, dims]); + + // ResizeObserver + useEffectMV(() => { + const el = wrapRef.current; + if (!el) return; + const ro = new ResizeObserver(() => setDims({ w: el.clientWidth, h: el.clientHeight })); + ro.observe(el); + setDims({ w: el.clientWidth, h: el.clientHeight }); + return () => ro.disconnect(); + }, []); + + // Отрисовка canvas. + useEffectMV(() => { + const cv = canvasRef.current; + if (!cv) return; + const dpr = Math.min(2, window.devicePixelRatio || 1); + cv.width = dims.w * dpr; cv.height = dims.h * dpr; + cv.style.width = dims.w + "px"; cv.style.height = dims.h + "px"; + const ctx = cv.getContext("2d"); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, dims.w, dims.h); + + // фон + ctx.fillStyle = provider.dark ? "#0a1410" : "#0a1410"; + ctx.fillRect(0, 0, dims.w, dims.h); + + const P = (lon, lat) => project(lon, lat); + + if (provider.url) { + // растровая подложка (тайлы) + BM.drawTiles(ctx, provider, view, dims, () => setTileTick((t) => t + 1)); + } else { + // векторная схема (оффлайн) + drawVectorWorld(ctx, P, dims); + } + + // сетка координат поверх (тонкая) + ctx.strokeStyle = provider.dark ? "rgba(120,180,150,0.10)" : "rgba(255,255,255,0.14)"; + ctx.lineWidth = 1; + ctx.beginPath(); + for (let lon = -180; lon <= 180; lon += 30) { const a = P(lon, 84), b = P(lon, -84); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); } + for (let lat = -60; lat <= 60; lat += 30) { const a = P(-180, lat), b = P(180, lat); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); } + ctx.stroke(); + + // терминатор / ночь + if (layers.terminator) { + const tc = terminatorCurve(now); + ctx.beginPath(); + tc.curve.forEach((pt, i) => { const q = P(pt[0], pt[1]); i ? ctx.lineTo(q.x, q.y) : ctx.moveTo(q.x, q.y); }); + const topLat = tc.nightInNorth ? 84 : -84; + const c1 = P(180, topLat), c2 = P(-180, topLat); + ctx.lineTo(c1.x, c1.y); ctx.lineTo(c2.x, c2.y); + ctx.closePath(); + ctx.fillStyle = "rgba(4,8,12,0.42)"; + ctx.fill(); + ctx.strokeStyle = "rgba(255,210,120,0.30)"; + ctx.lineWidth = 1; + ctx.beginPath(); + tc.curve.forEach((pt, i) => { const q = P(pt[0], pt[1]); i ? ctx.lineTo(q.x, q.y) : ctx.moveTo(q.x, q.y); }); + ctx.stroke(); + } + + // трассы + swath + const TYPE_RGB = { optical: [126,222,180], radar: [232,200,120], combo: [240,150,165] }; + const many = scList.length; + const spanHrs = (win.t1 - win.t0) / 3600000; + const showSwath = layers.swath && many <= 8 && spanHrs <= 16; + const trackAlpha = many > 40 ? 0.14 : many > 14 ? 0.22 : many > 4 ? 0.4 : 0.72; + for (const sc of scList) { + const p = paramsBy[sc.id]; + const rgb = TYPE_RGB[sc.type]; + const segs = groundTrack(p, win.t0, win.t1, 900); + const isSelSc = selectedPlan && selectedPlan.scId === sc.id; + if (showSwath || isSelSc) { + for (const seg of segs) { + if (!seg.length) continue; + const midLat = seg[Math.floor(seg.length / 2)].lat; + const mpp = BM.metersPerPixel(midLat, ws); + const wpx = Math.max(1.5, (p.swathKm * 1000) / mpp); + ctx.strokeStyle = `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${isSelSc ? 0.16 : 0.09})`; + ctx.lineWidth = wpx; ctx.lineCap = "round"; ctx.lineJoin = "round"; + ctx.beginPath(); + seg.forEach((pt, i) => { const q = P(pt.lon, pt.lat); i ? ctx.lineTo(q.x, q.y) : ctx.moveTo(q.x, q.y); }); + ctx.stroke(); + } + } + if (layers.tracks) { + const selAlpha = spanHrs > 30 ? 0.5 : spanHrs > 14 ? 0.66 : 0.92; + ctx.strokeStyle = `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${isSelSc ? selAlpha : trackAlpha})`; + ctx.lineWidth = isSelSc ? (spanHrs > 30 ? 1 : 1.4) : many > 14 ? 0.6 : 0.9; + for (const seg of segs) { + ctx.beginPath(); + seg.forEach((pt, i) => { const q = P(pt.lon, pt.lat); i ? ctx.lineTo(q.x, q.y) : ctx.moveTo(q.x, q.y); }); + ctx.stroke(); + } + } + } + }, [dims, view, scList, selectedPlan, win, now, layers, project, ws, provider, tileTick]); + + // ---- overlay (SVG) ---- + const planOverlay = useMemoMV(() => { + if (!selectedPlan) return null; + const p = paramsBy[selectedPlan.scId]; + const sc = scById[selectedPlan.scId]; + const shoots = [], downs = []; + for (const wk of selectedPlan.works) { + if (wk.kind === "shoot" && layers.shoot) { + const pts = trackPoints(p, wk.start, wk.end, 18); + const poly = ribbon(pts, p.swathKm / 2); + shoots.push({ id: wk.id, label: wk.label, poly: poly.map(c => project(c[0], c[1])) }); + } + if (wk.kind === "downlink" && layers.downlink) { + const pts = trackPoints(p, wk.start, wk.end, 14); + const mid = pts[Math.floor(pts.length / 2)]; + const ns = nearestStation(mid.lat, mid.lon, STATIONS); + downs.push({ + id: wk.id, label: wk.label, + line: pts.map(pt => project(pt.lon, pt.lat)), + st: ns.st, stPt: project(ns.st.lon, ns.st.lat), midPt: project(mid.lon, mid.lat), + }); + } + } + return { shoots, downs, scType: sc.type }; + }, [selectedPlan, layers, project, paramsBy, scById]); + + const nowMarkers = useMemoMV(() => { + if (now < win.t0 || now > win.t1) return []; + return scList.map((sc) => { + const s = subPoint(paramsBy[sc.id], now); + return { id: sc.id, short: sc.short, type: sc.type, ...project(s.lon, s.lat) }; + }); + }, [scList, now, win, project, paramsBy]); + + const TYPE_HEX = { optical: "var(--t-optical)", radar: "var(--t-radar)", combo: "var(--t-combo)" }; + + // ---- pan / zoom ---- + const onWheel = (e) => { + e.preventDefault(); + const rect = wrapRef.current.getBoundingClientRect(); + const mx = e.clientX - rect.left, my = e.clientY - rect.top; + const before = unproject(mx, my); + setView((v) => { + const nz = Math.max(0.7, Math.min(9, v.z + (e.deltaY < 0 ? 0.4 : -0.4))); + const nws = TILE * Math.pow(2, nz); + const cwx = BM.lonToWorldX(before.lon, nws) - (mx - dims.w / 2); + const cwy = BM.latToWorldY(before.lat, nws) - (my - dims.h / 2); + return { ...v, z: nz, centerLon: BM.worldXToLon(cwx, nws), centerLat: BM.worldYToLat(cwy, nws) }; + }); + }; + const onDown = (e) => { + drag.current = { x: e.clientX, y: e.clientY, lon: view.centerLon, lat: view.centerLat, moved: false }; + }; + const onMove = (e) => { + if (!drag.current) return; + const dx = e.clientX - drag.current.x, dy = e.clientY - drag.current.y; + if (Math.abs(dx) + Math.abs(dy) > 3) drag.current.moved = true; + const cwx = BM.lonToWorldX(drag.current.lon, ws) - dx; + const cwy = BM.latToWorldY(drag.current.lat, ws) - dy; + setView((v) => ({ ...v, centerLon: BM.worldXToLon(cwx, ws), centerLat: Math.max(-82, Math.min(82, BM.worldYToLat(cwy, ws))) })); + }; + const onUp = (e) => { + if (drag.current && pickMode && !drag.current.moved && onPickPoint && e) { + const rect = wrapRef.current.getBoundingClientRect(); + const ll = unproject(e.clientX - rect.left, e.clientY - rect.top); + onPickPoint(ll); + } + drag.current = null; + }; + const zoomBy = (f) => setView((v) => ({ ...v, z: Math.max(0.7, Math.min(9, v.z + f)) })); + const resetView = () => setView((v) => ({ ...v, init: false })); + + return ( +
+ + + + {/* выбранные наземные цели */} + {targets && targets.map((tg) => { + const q = project(tg.lon, tg.lat); + return ( + + + + + + {tg.name} + + ); + })} + {planOverlay && planOverlay.shoots.map((s) => ( + `${p.x},${p.y}`).join(" ")} + fill={`color-mix(in oklch, ${TYPE_HEX[planOverlay.scType]} 32%, transparent)`} + stroke={TYPE_HEX[planOverlay.scType]} strokeWidth="1.4" style={{ pointerEvents: "all", cursor: "pointer" }} + onMouseEnter={(e) => setHover({ x: e.clientX, y: e.clientY, title: s.label, kind: "Съёмка / контур маршрута" })} + onMouseLeave={() => setHover(null)} /> + ))} + {planOverlay && planOverlay.downs.map((d) => ( + + + `${p.x},${p.y}`).join(" ")} fill="none" + stroke="var(--accent)" strokeWidth="3.4" strokeLinecap="round" + style={{ pointerEvents: "all", cursor: "pointer", filter: "drop-shadow(0 0 4px var(--accent))" }} + onMouseEnter={(e) => setHover({ x: e.clientX, y: e.clientY, title: `${d.label} → ${d.st.name}`, kind: "Сброс на НКПОР" })} + onMouseLeave={() => setHover(null)} /> + + ))} + + {layers.stations && STATIONS.map((st) => { + const q = project(st.lon, st.lat); + const active = planOverlay && planOverlay.downs.some(d => d.st.id === st.id); + return ( + setHover({ x: e.clientX, y: e.clientY, title: st.name, kind: "Станция приёма (НКПОР)" })} + onMouseLeave={() => setHover(null)}> + + {view.z > 3.6 && {st.name}} + + ); + })} + + {nowMarkers.map((m) => ( + onSelectSc(m.id)} + onMouseEnter={(e) => setHover({ x: e.clientX, y: e.clientY, title: m.short, kind: "Подспутниковая точка · сейчас" })} + onMouseLeave={() => setHover(null)}> + + + + ))} + + + {hover && ( +
+
{hover.title}
+
{hover.kind}
+
+ )} + + {/* атрибуция подложки */} +
{provider.label}
+ +
+ zoomBy(0.5)}>+ + zoomBy(-0.5)}>− + +
+
+ ); +} + +// Векторная схема континентов (режим «Схема», оффлайн). +function drawVectorWorld(ctx, P, dims) { + for (const name in window.WorldGeo.LAND) { + const poly = window.WorldGeo.LAND[name]; + ctx.beginPath(); + poly.forEach((pt, i) => { const q = P(pt[0], pt[1]); i ? ctx.lineTo(q.x, q.y) : ctx.moveTo(q.x, q.y); }); + ctx.closePath(); + ctx.fillStyle = "#173228"; ctx.fill(); + ctx.strokeStyle = "rgba(150,215,180,0.5)"; ctx.lineWidth = 0.9; ctx.stroke(); + } +} + +function ZoomBtn({ children, onClick, title }) { + return ( + + ); +} + +Object.assign(window, { MapView }); diff --git a/docs/ui-prototypes/pcp-tgu-editor/orbit.jsx b/docs/ui-prototypes/pcp-tgu-editor/orbit.jsx new file mode 100644 index 0000000..432f6d9 --- /dev/null +++ b/docs/ui-prototypes/pcp-tgu-editor/orbit.jsx @@ -0,0 +1,142 @@ +// Орбитальная геометрия: трассы КА (подспутниковая точка), полосы обзора (swath), +// контуры маршрутов съёмки, участки сброса, терминатор день/ночь. +const DEG = Math.PI / 180; +const RAD = 180 / Math.PI; +const RE_KM = 6371; +const MU = 398600.4418; // км^3/с^2 +const SIDEREAL = 86164; // период вращения Земли, с + +// Детерминированный хэш строки → [0,1). +function hash01(str, salt) { + let h = 2166136261 ^ (salt || 0); + for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); } + return ((h >>> 0) % 100000) / 100000; +} + +// Параметры орбиты КА (ССО). Высоту берём из sc.orbit, наклонение ~98°. +function orbitParams(sc) { + const m = /(\d+)\s*км/.exec(sc.orbit); + const h = m ? +m[1] : 550; + const a = RE_KM + h; + const T = 2 * Math.PI * Math.sqrt((a * a * a) / MU); // период, с + const incl = (97.4 + hash01(sc.id, 7) * 1.6) * DEG; // 97.4–99.0° + const node0 = hash01(sc.id, 3) * 360 * DEG; // долгота восх. узла @ эпоха + const u0 = hash01(sc.id, 11) * 2 * Math.PI; // фаза + // Ширина полосы обзора (км) по типу аппаратуры. + const swathKm = sc.type === "radar" ? 90 + hash01(sc.id, 5) * 110 + : sc.type === "combo" ? 50 + hash01(sc.id, 5) * 70 + : 24 + hash01(sc.id, 5) * 36; + return { h, a, T, incl, node0, u0, swathKm }; +} + +const EPOCH = (window.MissionData ? window.MissionData.RANGE_START : 0); + +// Подспутниковая точка в момент t (мс). +function subPoint(p, tMs) { + const dt = (tMs - EPOCH) / 1000; // с + const u = p.u0 + 2 * Math.PI * (dt / p.T); + const lat = Math.asin(Math.sin(p.incl) * Math.sin(u)) * RAD; + let lonRel = Math.atan2(Math.cos(p.incl) * Math.sin(u), Math.cos(u)); + let lon = (p.node0 + lonRel) * RAD - (360 / SIDEREAL) * dt; + lon = ((((lon + 180) % 360) + 360) % 360) - 180; + // курс (для перпендикуляра swath) — численно + return { lat, lon, u }; +} + +// Трасса за [t0,t1] с адаптивным шагом. Возвращает сегменты (разрыв на ±180°). +function groundTrack(p, t0, t1, maxPts) { + const span = t1 - t0; + const idealStep = p.T * 1000 / 36; // ~36 точек на виток + let step = idealStep; + const cap = maxPts || 900; + if (span / step > cap) step = span / cap; + const segs = []; + let cur = []; + let prevLon = null; + for (let t = t0; t <= t1; t += step) { + const s = subPoint(p, t); + if (prevLon !== null && Math.abs(s.lon - prevLon) > 180) { + if (cur.length) segs.push(cur); + cur = []; + } + cur.push({ t, lat: s.lat, lon: s.lon }); + prevLon = s.lon; + } + if (cur.length) segs.push(cur); + return segs; +} + +// Точки трассы строго внутри [t0,t1] (для footprint работы), без разрыва. +function trackPoints(p, t0, t1, n) { + const pts = []; + const k = Math.max(2, n || 24); + for (let i = 0; i <= k; i++) { + const t = t0 + (t1 - t0) * (i / k); + const s = subPoint(p, t); + pts.push({ t, lat: s.lat, lon: s.lon }); + } + return pts; +} + +// Лента (ribbon) шириной halfKm по обе стороны трассы → замкнутый полигон [lon,lat]. +function ribbon(points, halfKm) { + const half = halfKm / 111; // градусы широты + const left = [], right = []; + for (let i = 0; i < points.length; i++) { + const a = points[Math.max(0, i - 1)]; + const b = points[Math.min(points.length - 1, i + 1)]; + const cosLat = Math.cos(points[i].lat * DEG) || 0.01; + let dx = (b.lon - a.lon) * cosLat; + let dy = (b.lat - a.lat); + const len = Math.hypot(dx, dy) || 1; + dx /= len; dy /= len; + // перпендикуляр + const px = -dy, py = dx; + const p = points[i]; + left.push([p.lon + (px * half) / cosLat, p.lat + py * half]); + right.push([p.lon - (px * half) / cosLat, p.lat - py * half]); + } + return left.concat(right.reverse()); +} + +// Видимость станции из подспутниковой точки (грубо: по угловому расстоянию). +function nearestStation(lat, lon, stations) { + let best = null, bd = 1e9; + for (const st of stations) { + const d = Math.hypot((st.lon - lon) * Math.cos(lat * DEG), st.lat - lat); + if (d < bd) { bd = d; best = st; } + } + return { st: best, distDeg: bd }; +} + +// Подсолнечная точка (примитивно) для терминатора в момент tMs. +function subsolar(tMs) { + const d = new Date(tMs); + const dayMs = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); + const secs = (tMs - dayMs) / 1000; + const lon = 180 - (secs / 86400) * 360; // движется на запад + const N = Math.floor((tMs - Date.UTC(d.getUTCFullYear(), 0, 0)) / 86400000); + const decl = 23.44 * Math.sin(DEG * (360 / 365) * (N - 81)); // склонение + return { lon: ((lon + 180) % 360 + 360) % 360 - 180, lat: decl }; +} + +// Кривая терминатора: для каждого lon — широта раздела день/ночь. +function terminatorCurve(tMs) { + const ss = subsolar(tMs); + const decl = ss.lat * DEG; + const out = []; + for (let lon = -180; lon <= 180; lon += 4) { + const H = (lon - ss.lon) * DEG; + // граница: tan(lat) = -cos(H)/tan(decl) + let lat; + if (Math.abs(decl) < 0.01) lat = 0; + else lat = Math.atan(-Math.cos(H) / Math.tan(decl)) * RAD; + out.push([lon, lat]); + } + return { curve: out, nightInNorth: ss.lat < 0, subsolar: ss }; +} + +Object.assign(window, { + orbitParams, subPoint, groundTrack, trackPoints, ribbon, + nearestStation, subsolar, terminatorCurve, DEG, RAD, +}); diff --git a/docs/ui-prototypes/pcp-tgu-editor/screenshots/basemap.png b/docs/ui-prototypes/pcp-tgu-editor/screenshots/basemap.png new file mode 100644 index 0000000..7a1c776 Binary files /dev/null and b/docs/ui-prototypes/pcp-tgu-editor/screenshots/basemap.png differ diff --git a/docs/ui-prototypes/pcp-tgu-editor/screenshots/basemap2.png b/docs/ui-prototypes/pcp-tgu-editor/screenshots/basemap2.png new file mode 100644 index 0000000..db937c7 Binary files /dev/null and b/docs/ui-prototypes/pcp-tgu-editor/screenshots/basemap2.png differ diff --git a/docs/ui-prototypes/pcp-tgu-editor/screenshots/downlink.png b/docs/ui-prototypes/pcp-tgu-editor/screenshots/downlink.png new file mode 100644 index 0000000..1c3230b Binary files /dev/null and b/docs/ui-prototypes/pcp-tgu-editor/screenshots/downlink.png differ diff --git a/docs/ui-prototypes/pcp-tgu-editor/screenshots/downlink2.png b/docs/ui-prototypes/pcp-tgu-editor/screenshots/downlink2.png new file mode 100644 index 0000000..7fa7e1d Binary files /dev/null and b/docs/ui-prototypes/pcp-tgu-editor/screenshots/downlink2.png differ diff --git a/docs/ui-prototypes/pcp-tgu-editor/screenshots/downlink3.png b/docs/ui-prototypes/pcp-tgu-editor/screenshots/downlink3.png new file mode 100644 index 0000000..3f09a01 Binary files /dev/null and b/docs/ui-prototypes/pcp-tgu-editor/screenshots/downlink3.png differ diff --git a/docs/ui-prototypes/pcp-tgu-editor/screenshots/editor-final.png b/docs/ui-prototypes/pcp-tgu-editor/screenshots/editor-final.png new file mode 100644 index 0000000..38b8d23 Binary files /dev/null and b/docs/ui-prototypes/pcp-tgu-editor/screenshots/editor-final.png differ diff --git a/docs/ui-prototypes/pcp-tgu-editor/screenshots/editor1.png b/docs/ui-prototypes/pcp-tgu-editor/screenshots/editor1.png new file mode 100644 index 0000000..0015f4e Binary files /dev/null and b/docs/ui-prototypes/pcp-tgu-editor/screenshots/editor1.png differ diff --git a/docs/ui-prototypes/pcp-tgu-editor/screenshots/inspector.png b/docs/ui-prototypes/pcp-tgu-editor/screenshots/inspector.png new file mode 100644 index 0000000..b89d536 Binary files /dev/null and b/docs/ui-prototypes/pcp-tgu-editor/screenshots/inspector.png differ diff --git a/docs/ui-prototypes/pcp-tgu-editor/screenshots/map-all.png b/docs/ui-prototypes/pcp-tgu-editor/screenshots/map-all.png new file mode 100644 index 0000000..052b472 Binary files /dev/null and b/docs/ui-prototypes/pcp-tgu-editor/screenshots/map-all.png differ diff --git a/docs/ui-prototypes/pcp-tgu-editor/screenshots/osm.png b/docs/ui-prototypes/pcp-tgu-editor/screenshots/osm.png new file mode 100644 index 0000000..d3df602 Binary files /dev/null and b/docs/ui-prototypes/pcp-tgu-editor/screenshots/osm.png differ diff --git a/docs/ui-prototypes/pcp-tgu-editor/screenshots/planoverlay.png b/docs/ui-prototypes/pcp-tgu-editor/screenshots/planoverlay.png new file mode 100644 index 0000000..8458b33 Binary files /dev/null and b/docs/ui-prototypes/pcp-tgu-editor/screenshots/planoverlay.png differ diff --git a/docs/ui-prototypes/pcp-tgu-editor/screenshots/shoot-added.png b/docs/ui-prototypes/pcp-tgu-editor/screenshots/shoot-added.png new file mode 100644 index 0000000..94f140f Binary files /dev/null and b/docs/ui-prototypes/pcp-tgu-editor/screenshots/shoot-added.png differ diff --git a/docs/ui-prototypes/pcp-tgu-editor/screenshots/voyager.png b/docs/ui-prototypes/pcp-tgu-editor/screenshots/voyager.png new file mode 100644 index 0000000..26558ae Binary files /dev/null and b/docs/ui-prototypes/pcp-tgu-editor/screenshots/voyager.png differ diff --git a/docs/ui-prototypes/pcp-tgu-editor/sidebar.jsx b/docs/ui-prototypes/pcp-tgu-editor/sidebar.jsx new file mode 100644 index 0000000..8bdf60c --- /dev/null +++ b/docs/ui-prototypes/pcp-tgu-editor/sidebar.jsx @@ -0,0 +1,122 @@ +// Левая панель: поиск, фильтр по типам (легенда), дерево групп → КА с чекбоксами. +const { useState: useStateSB } = React; + +function TypeDot({ type, size = 9 }) { + return ( + + ); +} + +function Check({ on, onClick, dim }) { + return ( + + ); +} + +function Sidebar(props) { + const { groups, scByGroup, typeFilter, setTypeFilter, groupShown, toggleGroup, + scHidden, toggleSc, search, setSearch, counts, selectedSc, onPickSc } = props; + const [open, setOpen] = useStateSB(() => { + const o = {}; D.GROUP_DEFS.forEach((g) => (o[g.id] = true)); return o; + }); + + return ( +
+ {/* Заголовок */} +
+
+
+ +
+
+
ПЛАН КА
+
MISSION OPS · ДЗЗ
+
+
+
+ + {/* Поиск */} +
+
+ + setSearch(e.target.value)} placeholder="Поиск КА…" + style={{ width: "100%", padding: "7px 8px 7px 28px", background: "var(--bg-0)", border: "1px solid var(--line)", borderRadius: 6, color: "var(--ink)", fontFamily: "var(--mono)", fontSize: 12, outline: "none" }} /> + {search && } +
+
+ + {/* Легенда / фильтр по типам */} +
+
АППАРАТУРА
+
+ {D.typeOrder.map((t) => { + const on = typeFilter[t]; + return ( + + ); + })} +
+
+ + {/* Дерево групп → КА */} +
+
ГРУППЫ / КА
+ {groups.map((g) => { + const list = scByGroup[g.id] || []; + const isOpen = open[g.id]; + const shownCount = list.filter((sc) => !scHidden.has(sc.id)).length; + return ( +
+
+ + toggleGroup(g.id)} /> + {g.code} + {g.name} + {shownCount}/{list.length} +
+ {isOpen && ( +
+ {list.map((sc) => { + const vis = !scHidden.has(sc.id); + const sel = selectedSc === sc.id; + return ( +
onPickSc(sc.id)} + style={{ display: "flex", alignItems: "center", gap: 7, padding: "4px 7px", borderRadius: 6, cursor: "pointer", background: sel ? "var(--bg-3)" : "transparent" }}> + { e.stopPropagation(); toggleSc(sc.id); }} /> + + {sc.short} +
+ ); + })} +
+ )} +
+ ); + })} +
+
+ ); +} + +Object.assign(window, { Sidebar, TypeDot }); diff --git a/docs/ui-prototypes/pcp-tgu-editor/tabs.jsx b/docs/ui-prototypes/pcp-tgu-editor/tabs.jsx new file mode 100644 index 0000000..76630ab --- /dev/null +++ b/docs/ui-prototypes/pcp-tgu-editor/tabs.jsx @@ -0,0 +1,72 @@ +// Верхние вкладки навигации «Планы / Карта / Редактор», общие для страниц. +function TopTabs({ active, planHref, mapHref, editorHref }) { + const PH = planHref || "План КА — Mission Ops.html"; + const MH = mapHref || "Map.html"; + const EH = editorHref || "Editor.html"; + const tab = (id, label, href, icon) => { + const on = active === id; + return ( + + {icon} + {label} + + ); + }; + return ( +
+
+
+ +
+ MISSION OPS +
+ {tab("plans", "Планы", PH, )} + {tab("map", "Карта", MH, )} + {tab("editor", "Редактор", EH, )} +
+ ); +} + +// Цветная метка типа аппаратуры (общая для обеих страниц). +function TypeDot({ type, size = 9 }) { + return ( + + ); +} + +// Режимы аппаратуры для съёмки (по типу КА). +const SENSOR_MODES = { + optical: [ + { id: "pan", label: "Панхром", short: "ПХ" }, + { id: "ms", label: "Мультиспектр", short: "МС" }, + { id: "stereo", label: "Стерео", short: "СТ" }, + ], + radar: [ + { id: "spot", label: "Прожекторный", short: "ПР" }, + { id: "scan", label: "Сканирующий", short: "СК" }, + ], + combo: [ + { id: "pan", label: "Панхром", short: "ПХ" }, + { id: "ms", label: "Мультиспектр", short: "МС" }, + { id: "stereo", label: "Стерео", short: "СТ" }, + { id: "spot", label: "РСА прожектор", short: "ПР" }, + { id: "scan", label: "РСА скан", short: "СК" }, + ], +}; +function modeLabel(scType, modeId) { + const list = SENSOR_MODES[scType] || []; + const m = list.find((x) => x.id === modeId); + return m ? m.label : modeId; +} + +Object.assign(window, { TopTabs, TypeDot, SENSOR_MODES, modeLabel }); \ No newline at end of file diff --git a/docs/ui-prototypes/pcp-tgu-editor/timeline.jsx b/docs/ui-prototypes/pcp-tgu-editor/timeline.jsx new file mode 100644 index 0000000..584a1ee --- /dev/null +++ b/docs/ui-prototypes/pcp-tgu-editor/timeline.jsx @@ -0,0 +1,273 @@ +// Таймлайн (Gantt): ось времени, сетка, маркер «сейчас», секции групп, +// раскладка интервалов по лейнам, пометка пересечений, стрелки родословной. +const { useRef: useRefTL, useMemo: useMemoTL, useEffect: useEffectTL, useLayoutEffect } = React; + +const LABEL_W = 234; +const LANE_H = 38; +const ROW_PAD = 8; +const GROUP_H = 30; +const AXIS_H = 46; + +function Timeline(props) { + const { rows, pxPerMs, rangeStart, rangeEnd, now, selectedIv, onSelectIv, + lineageSet, byId, scrollSignal, timelineCollapsed, toggleCollapse } = props; + + const bodyRef = useRefTL(null); + const axisRef = useRefTL(null); + const leftRef = useRefTL(null); + + const x = (ms) => (ms - rangeStart) * pxPerMs; + const contentW = (rangeEnd - rangeStart) * pxPerMs; + + // Раскладка строк по вертикали + позиции интервалов. + const layout = useMemoTL(() => { + let y = 0; + const placed = []; + const ivPos = {}; + for (const r of rows) { + if (r.kind === "group") { + placed.push({ ...r, y, h: GROUP_H }); + y += GROUP_H; + } else { + const { laneOf, laneCount } = assignLanes(r.ivs); + const h = laneCount * LANE_H + ROW_PAD * 2; + const overlaps = overlapSegments(r.ivs); + for (const iv of r.ivs) { + const lane = laneOf[iv.id]; + ivPos[iv.id] = { + x: x(iv.start), x2: x(iv.end), + cy: y + ROW_PAD + lane * LANE_H + LANE_H / 2, + }; + } + placed.push({ ...r, y, h, laneOf, laneCount, overlaps }); + y += h; + } + } + return { placed, total: y, ivPos }; + }, [rows, pxPerMs, rangeStart]); + + // Сетка по дням + засечки оси. + const days = useMemoTL(() => { + const out = []; + const startD = new Date(rangeStart); + let t = Date.UTC(startD.getUTCFullYear(), startD.getUTCMonth(), startD.getUTCDate()); + while (t <= rangeEnd) { + const d = new Date(t); + out.push({ t, weekend: d.getUTCDay() === 0 || d.getUTCDay() === 6, dow: d.getUTCDay() }); + t += 86400000; + } + return out; + }, [rangeStart, rangeEnd]); + + const sub = useMemoTL(() => niceTicks(rangeStart, rangeEnd, pxPerMs, 64), [rangeStart, rangeEnd, pxPerMs]); + const showHourTicks = sub.step < 86400000; + + // Синхронизация скролла. + const onBodyScroll = () => { + const b = bodyRef.current; + if (axisRef.current) axisRef.current.scrollLeft = b.scrollLeft; + if (leftRef.current) leftRef.current.scrollTop = b.scrollTop; + }; + + // Прокрутка к «сейчас». + useEffectTL(() => { + const b = bodyRef.current; + if (!b) return; + const target = x(now) - b.clientWidth * 0.32; + b.scrollTo({ left: Math.max(0, target), behavior: scrollSignal === 0 ? "auto" : "smooth" }); + onBodyScroll(); + }, [scrollSignal]); + + // Стрелки родословной для выделенной цепочки. + const arrows = useMemoTL(() => { + if (!selectedIv) return []; + const lin = lineageOf(selectedIv, byId); + const segs = []; + for (const id of lin.chain) { + (D.childrenOf[id] || []).forEach((c) => { + if (lin.chain.includes(c) && layout.ivPos[id] && layout.ivPos[c]) { + segs.push({ from: layout.ivPos[id], to: layout.ivPos[c] }); + } + }); + } + return segs; + }, [selectedIv, layout, lineageSet]); + + const anySelected = !!selectedIv; + + return ( +
+ {/* ОСЬ ВРЕМЕНИ */} +
+
+ КОСМИЧЕСКИЙ АППАРАТ +
+
+
+ {/* верхний ярус — дни */} + {days.map((d, i) => { + const w = 86400000 * pxPerMs; + return ( +
+ {pad(new Date(d.t).getUTCDate())} + {MONTHS[new Date(d.t).getUTCMonth()]} + {WDAYS[d.dow]} +
+ ); + })} + {/* нижний ярус — засечки */} + {sub.ticks.map((tk, i) => ( +
+ {showHourTicks && {fmtTime(tk.t)}} +
+ ))} + {/* метка «сейчас» */} +
+ СЕЙЧАС {fmtTime(now)} +
+
+
+
+ + {/* ТЕЛО */} +
+ {/* Левая колонка подписей */} +
+
+ {layout.placed.map((r) => r.kind === "group" ? ( +
toggleCollapse(r.group.id)} + style={{ position: "absolute", top: r.y, left: 0, right: 0, height: r.h, display: "flex", alignItems: "center", gap: 7, padding: "0 12px", background: "var(--bg-2)", borderBottom: "1px solid var(--line)", borderTop: "1px solid var(--line)", cursor: "pointer" }}> + + {r.group.code} + {r.group.name} + {r.count} +
+ ) : ( +
+ +
+
{r.sc.short}
+
{r.sc.orbit}
+
+ {r.ivs.length} +
+ ))} +
+
+ + {/* Правый холст */} +
+
+ {/* фон выходных + сетка дней */} + {days.map((d, i) => ( +
+ ))} + {/* линия «сейчас» */} +
+ + {/* строки */} + {layout.placed.map((r) => r.kind === "group" ? ( +
+ ) : ( + + ))} + + {/* стрелки родословной */} + {arrows.length > 0 && ( + + + + + + + {arrows.map((a, i) => { + const x1 = a.from.x2, y1 = a.from.cy, x2 = a.to.x, y2 = a.to.cy; + const mid = x1 + Math.max(14, (x2 - x1) / 2); + const d = `M ${x1} ${y1} C ${mid} ${y1}, ${mid} ${y2}, ${x2 - 3} ${y2}`; + return ; + })} + + )} +
+
+
+
+ ); +} + +// Одна строка КА: ПЛАНЫ (объемлющие интервалы) по лейнам, внутри — РАБОТЫ. +function Row({ r, x, contentW, selectedIv, onSelectIv, lineageSet, anySelected }) { + return ( +
+ {/* пересечения планов — штриховка */} + {r.overlaps.map((ov, i) => ( +
+ ))} + {r.ivs.map((plan) => { + const lane = r.laneOf[plan.id]; + const left = x(plan.start); + const w = Math.max(4, x(plan.end) - x(plan.start)); + const inChain = lineageSet && lineageSet.has(plan.id); + const isSel = selectedIv === plan.id; + const dim = anySelected && !inChain; + const scType = r.sc.type; + const col = TYPE_COLOR[scType]; + const hasParent = !!plan.parentId; + const hasChild = (D.childrenOf[plan.id] || []).length > 0; + const bandTop = ROW_PAD + lane * LANE_H; + const bandH = LANE_H - 8; + const wide = w > 78; + const showWorks = w > 14; + const executed = plan.status === "executed"; + return ( +
{ e.stopPropagation(); onSelectIv(plan.id); }} + title={`План ${plan.id} · ${plan.title} · ${fmtDateTime(plan.start)} → ${fmtDateTime(plan.end)} · работ: ${plan.works.length}`} + style={{ + position: "absolute", left, width: w, top: bandTop, height: bandH, + background: `color-mix(in oklch, ${col} 13%, var(--bg-1))`, + border: `1px solid color-mix(in oklch, ${col} 45%, var(--bg-1))`, + borderLeft: `3px solid ${col}`, + borderRadius: 5, cursor: "pointer", zIndex: isSel ? 5 : 3, + opacity: dim ? 0.26 : executed ? 0.7 : 1, + outline: isSel ? `1.5px solid var(--accent)` : inChain ? `1px solid color-mix(in oklch, var(--accent) 55%, transparent)` : "none", + boxShadow: isSel ? "0 0 0 3px oklch(0.82 0.13 200 / 0.18), var(--shadow)" : "none", + overflow: "hidden", transition: "opacity .12s", + }}> + {/* заголовок плана */} +
+ {hasParent && } + {wide && {plan.title}} + {wide && plan.status === "active" && } + {wide && plan.rev > 1 && r{plan.rev}} + {!wide && } + {hasChild && } +
+ {/* трек работ внутри плана */} + {showWorks && ( +
+ {plan.works.map((wk) => { + const wl = x(wk.start) - left; + const ww = Math.max(1.5, x(wk.end) - x(wk.start)); + const fill = workFill(wk.kind, scType); + const isService = wk.kind === "service"; + return ( +
+ ); + })} +
+ )} +
+ ); + })} +
+ ); +} + +Object.assign(window, { Timeline }); diff --git a/docs/ui-prototypes/pcp-tgu-editor/util.jsx b/docs/ui-prototypes/pcp-tgu-editor/util.jsx new file mode 100644 index 0000000..39a3ca3 --- /dev/null +++ b/docs/ui-prototypes/pcp-tgu-editor/util.jsx @@ -0,0 +1,123 @@ +// Общие утилиты, формат времени, раскладка интервалов по лейнам. +const D = window.MissionData; + +const TYPE_COLOR = { + optical: "var(--t-optical)", + radar: "var(--t-radar)", + combo: "var(--t-combo)", +}; +const TYPE_DIM = { + optical: "var(--t-optical-dim)", + radar: "var(--t-radar-dim)", + combo: "var(--t-combo-dim)", +}; + +// Стиль работы внутри плана по её категории. shoot тонируется типом аппаратуры, +// остальные — служебной палитрой, отличной от трёх цветов аппаратуры. +const WORK_KIND_COLOR = { + shoot: null, // = цвет типа КА + downlink: "var(--accent)", + calib: "oklch(0.78 0.02 200)", + service: "var(--ink-3)", +}; +function workFill(kind, scType) { + if (kind === "shoot") return TYPE_COLOR[scType]; + return WORK_KIND_COLOR[kind]; +} + +const MONTHS = ["янв", "фев", "мар", "апр", "май", "июн", "июл", "авг", "сен", "окт", "ноя", "дек"]; +const WDAYS = ["вс", "пн", "вт", "ср", "чт", "пт", "сб"]; + +function pad(n) { return String(n).padStart(2, "0"); } +function fmtTime(ms) { + const d = new Date(ms); + return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`; +} +function fmtDate(ms) { + const d = new Date(ms); + return `${pad(d.getUTCDate())} ${MONTHS[d.getUTCMonth()]}`; +} +function fmtDateTime(ms) { + const d = new Date(ms); + return `${pad(d.getUTCDate())} ${MONTHS[d.getUTCMonth()]} ${fmtTime(ms)}`; +} +function fmtDur(ms) { + const h = ms / 3600000; + if (h < 24) return `${h % 1 === 0 ? h : h.toFixed(1)} ч`; + const d = Math.floor(h / 24); + const rh = Math.round(h - d * 24); + return rh ? `${d} сут ${rh} ч` : `${d} сут`; +} + +// Раскладка интервалов одного КА по лейнам (greedy interval graph). +function assignLanes(ivs) { + const sorted = [...ivs].sort((a, b) => a.start - b.start || a.end - b.end); + const laneEnds = []; // конец последнего интервала в каждом лейне + const out = {}; + for (const iv of sorted) { + let placed = false; + for (let l = 0; l < laneEnds.length; l++) { + if (iv.start >= laneEnds[l]) { + out[iv.id] = l; + laneEnds[l] = iv.end; + placed = true; + break; + } + } + if (!placed) { + out[iv.id] = laneEnds.length; + laneEnds.push(iv.end); + } + } + return { laneOf: out, laneCount: Math.max(1, laneEnds.length) }; +} + +// Пересечения по времени между интервалами одного КА. +function overlapSegments(ivs) { + const segs = []; + for (let i = 0; i < ivs.length; i++) { + for (let j = i + 1; j < ivs.length; j++) { + const a = ivs[i], b = ivs[j]; + const s = Math.max(a.start, b.start); + const e = Math.min(a.end, b.end); + if (e > s) segs.push({ start: s, end: e, a: a.id, b: b.id }); + } + } + return segs; +} + +// Цепочка родословной (предки + потомки) для интервала. +function lineageOf(id, byId) { + const anc = []; + let cur = byId[id]; + while (cur && cur.parentId) { + anc.unshift(cur.parentId); + cur = byId[cur.parentId]; + } + const desc = []; + const stack = [...(D.childrenOf[id] || [])]; + while (stack.length) { + const c = stack.shift(); + desc.push(c); + (D.childrenOf[c] || []).forEach((x) => stack.push(x)); + } + return { ancestors: anc, descendants: desc, chain: [...anc, id, ...desc] }; +} + +// "Хорошие" шаги сетки времени для оси. +function niceTicks(rangeStart, rangeEnd, pxPerMs, minPx) { + const HOUR = 3600000, DAY = 86400000; + const steps = [1 * HOUR, 2 * HOUR, 3 * HOUR, 6 * HOUR, 12 * HOUR, 1 * DAY, 2 * DAY, 7 * DAY]; + let step = steps[steps.length - 1]; + for (const s of steps) { if (s * pxPerMs >= minPx) { step = s; break; } } + const ticks = []; + const startTick = Math.ceil(rangeStart / step) * step; + for (let t = startTick; t <= rangeEnd; t += step) ticks.push({ t, step }); + return { ticks, step }; +} + +Object.assign(window, { + TYPE_COLOR, TYPE_DIM, MONTHS, WDAYS, WORK_KIND_COLOR, workFill, + pad, fmtTime, fmtDate, fmtDateTime, fmtDur, + assignLanes, overlapSegments, lineageOf, niceTicks, +}); diff --git a/docs/ui-prototypes/pcp-tgu-editor/visibility.jsx b/docs/ui-prototypes/pcp-tgu-editor/visibility.jsx new file mode 100644 index 0000000..91f1ded --- /dev/null +++ b/docs/ui-prototypes/pcp-tgu-editor/visibility.jsx @@ -0,0 +1,54 @@ +// Расчёт окон видимости: КА↔станция (для сброса) и КА↔наземная точка (для съёмки). +// В реальной системе это запрос к внешнему сервису; здесь — локальная модель +// на орбитальной геометрии (orbit.jsx). Возвращает интервалы [start,end] в мс. +const RE = 6371; + +function gcDistDeg(lat1, lon1, lat2, lon2) { + const a1 = lat1 * DEG, a2 = lat2 * DEG; + const dLon = (lon2 - lon1) * DEG; + const c = Math.sin(a1) * Math.sin(a2) + Math.cos(a1) * Math.cos(a2) * Math.cos(dLon); + return Math.acos(Math.max(-1, Math.min(1, c))) * RAD; +} + +// Центральный угол доступа для минимального угла места (станция приёма). +function accessAngleDeg(h, minElevDeg) { + const e = (minElevDeg || 5) * DEG; + const ratio = RE / (RE + h); + return (Math.acos(ratio * Math.cos(e)) - e) * RAD; +} + +// Общий сканер: возвращает интервалы, где расстояние ≤ thresholdDeg. +function scanWindows(p, lat, lon, t0, t1, thresholdDeg, stepMs) { + const step = stepMs || 30000; + const out = []; + let inWin = false, segStart = 0, best = 0; + for (let t = t0; t <= t1; t += step) { + const s = subPoint(p, t); + const d = gcDistDeg(s.lat, s.lon, lat, lon); + const vis = d <= thresholdDeg; + if (vis && !inWin) { inWin = true; segStart = t; best = thresholdDeg - d; } + else if (vis && inWin) { best = Math.max(best, thresholdDeg - d); } + else if (!vis && inWin) { + inWin = false; + out.push({ start: segStart, end: t, quality: best / thresholdDeg }); + } + } + if (inWin) out.push({ start: segStart, end: t1, quality: best / thresholdDeg }); + // отбросить слишком короткие (< 40 c) + return out.filter((w) => w.end - w.start >= 40000); +} + +// Окна видимости станции данным КА (для сброса). Возвращает [{start,end,quality}]. +function stationPasses(p, station, t0, t1) { + const thr = accessAngleDeg(p.h, 5); + return scanWindows(p, station.lat, station.lon, t0, t1, thr, 30000); +} + +// Окна видимости наземной точки (для съёмки). Угол меньше: ограничен агильностью +// (отклонение от надира) аппарата. ~6–7° центрального угла. +function targetPasses(p, lat, lon, t0, t1) { + const thr = 6.5; + return scanWindows(p, lat, lon, t0, t1, thr, 20000); +} + +Object.assign(window, { gcDistDeg, accessAngleDeg, scanWindows, stationPasses, targetPasses }); diff --git a/docs/ui-prototypes/pcp-tgu-editor/world.js b/docs/ui-prototypes/pcp-tgu-editor/world.js new file mode 100644 index 0000000..da5e494 --- /dev/null +++ b/docs/ui-prototypes/pcp-tgu-editor/world.js @@ -0,0 +1,41 @@ +// Схематичные контуры континентов (равнопрямоугольная проекция, [lon, lat]) +// и наземные станции приёма (НКПОР). Низкополигональный OPS-стиль, оффлайн. +(function () { + const LAND = { + "Сев. Америка": [[-166,66],[-160,70],[-150,70],[-140,70],[-125,70],[-110,71],[-95,72],[-83,73],[-70,67],[-64,60],[-56,53],[-52,47],[-60,46],[-67,44],[-70,41],[-74,39],[-76,35],[-81,31],[-80,25],[-84,30],[-90,29],[-94,29],[-97,26],[-97,22],[-105,22],[-110,29],[-114,31],[-120,34],[-124,40],[-124,48],[-130,53],[-136,58],[-145,60],[-152,59],[-160,58],[-164,60]], + "Гренландия": [[-46,60],[-32,66],[-22,70],[-20,74],[-28,79],[-40,83],[-55,82],[-60,76],[-53,70],[-50,64]], + "Юж. Америка": [[-78,9],[-70,11],[-60,10],[-51,4],[-50,-1],[-43,-3],[-35,-6],[-37,-13],[-48,-25],[-55,-34],[-62,-40],[-66,-45],[-70,-52],[-73,-54],[-74,-49],[-72,-42],[-71,-33],[-70,-24],[-71,-18],[-76,-14],[-80,-6],[-81,-3],[-79,3]], + "Африка": [[-16,15],[-13,21],[-6,27],[2,34],[10,37],[19,33],[26,32],[32,31],[35,24],[39,16],[43,12],[51,12],[44,3],[41,-3],[40,-11],[35,-18],[30,-26],[22,-34],[18,-35],[14,-24],[11,-13],[9,-1],[6,4],[-2,5],[-9,5],[-14,9]], + "Европа": [[-10,37],[-9,43],[-2,44],[-2,48],[2,51],[0,55],[5,58],[6,62],[11,64],[15,68],[21,70],[26,71],[30,67],[39,68],[42,63],[38,58],[30,59],[28,56],[24,56],[20,54],[14,54],[9,54],[4,51],[-1,49],[-4,48],[-1,46],[-2,43],[-9,40]], + "Азия": [[42,63],[55,68],[68,72],[80,74],[95,77],[110,76],[125,73],[140,72],[160,70],[172,67],[180,66],[178,62],[165,60],[160,54],[155,52],[143,53],[140,48],[135,44],[130,43],[127,38],[122,40],[121,31],[120,23],[110,21],[108,15],[105,9],[100,7],[98,11],[93,18],[88,22],[82,17],[77,8],[73,17],[68,24],[62,25],[57,25],[50,30],[47,38],[50,45],[48,50],[58,55],[50,58],[45,60]], + "Австралия": [[114,-22],[114,-32],[118,-35],[129,-32],[138,-35],[147,-38],[150,-37],[153,-31],[153,-25],[146,-18],[142,-11],[136,-12],[130,-12],[125,-14],[122,-18]], + "Н. Гвинея": [[131,-1],[141,-3],[147,-7],[143,-9],[134,-9],[131,-5]], + "Суматра": [[95,5],[100,1],[104,-5],[100,-3],[96,2]], + "Калимантан": [[109,2],[115,2],[118,-2],[114,-4],[109,-2]], + "Ява": [[105,-6],[114,-7],[110,-8]], + "Япония": [[130,32],[135,34],[140,36],[142,40],[141,45],[138,42],[135,36]], + "Британия": [[-5,50],[-2,51],[0,52],[-1,54],[-3,55],[-5,58],[-7,57],[-6,54],[-10,52]], + "Ирландия": [[-10,52],[-6,52],[-6,55],[-10,54]], + "Мадагаскар": [[44,-12],[50,-15],[49,-22],[45,-25],[44,-19],[43,-15]], + "Н. Зеландия С": [[173,-35],[178,-38],[174,-41],[172,-40],[173,-37]], + "Н. Зеландия Ю": [[170,-41],[172,-43],[170,-46],[167,-46],[168,-44]], + "Исландия": [[-24,65],[-18,66],[-14,65],[-18,64],[-23,64]], + "Антарктида": [[-180,-72],[-150,-75],[-120,-73],[-90,-72],[-60,-78],[-30,-72],[0,-70],[30,-69],[60,-67],[90,-66],[120,-66],[150,-70],[180,-72],[180,-85],[-180,-85]], + }; + + // Наземные станции приёма (НКПОР) — пункты приёма ДЗЗ. + const GROUND_STATIONS = [ + { id: "msk", name: "Москва", lon: 37.6, lat: 55.75 }, + { id: "dubna", name: "Дубна", lon: 37.17, lat: 56.73 }, + { id: "zhel", name: "Железногорск", lon: 93.5, lat: 56.25 }, + { id: "khab", name: "Хабаровск", lon: 135.07, lat: 48.48 }, + { id: "murm", name: "Мурманск", lon: 33.08, lat: 68.97 }, + { id: "anad", name: "Анадырь", lon: 177.5, lat: 64.73 }, + { id: "novo", name: "Новосибирск", lon: 82.9, lat: 55.03 }, + { id: "svalb", name: "Шпицберген", lon: 15.6, lat: 78.2 }, + { id: "magad", name: "Магадан", lon: 150.8, lat: 59.56 }, + { id: "progress", name: "Прогресс (Антарктида)", lon: 76.4, lat: -69.4 }, + ]; + + window.WorldGeo = { LAND, GROUND_STATIONS }; +})(); diff --git a/docs/ui-prototypes/pcp-tgu-editor/План КА - Mission Ops.html b/docs/ui-prototypes/pcp-tgu-editor/План КА - Mission Ops.html new file mode 100644 index 0000000..270e6ae --- /dev/null +++ b/docs/ui-prototypes/pcp-tgu-editor/План КА - Mission Ops.html @@ -0,0 +1,94 @@ + + + + + +План КА — Mission Ops + + + + + + +
+ + + + + + + + + + + + + + diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorPanels.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorPanels.tsx new file mode 100644 index 0000000..218fb1f --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorPanels.tsx @@ -0,0 +1,298 @@ +import { formatDate, formatDateTime, formatDuration, formatTime, kindColor, kindLabel } from "./editorPresentation"; +import type { + EditorGroundStation, + EditorMode, + EditorTarget, + EditorVisibilityWindow, + EditorWork, + EditorWorkPatch +} from "./model/editorTypes"; + +export type DownlinkSelection = { + station: EditorGroundStation; + pass: EditorVisibilityWindow; +}; + +export type DownlinkStationOption = { + station: EditorGroundStation; + passes: EditorVisibilityWindow[]; +}; + +type WorkInspectorProps = { + work: EditorWork; + modes: EditorMode[]; + stations: EditorGroundStation[]; + conflicts: string[]; + onUpdate: (workId: string, patch: EditorWorkPatch) => void; + onDelete: (workId: string) => void; + onDuplicate: (workId: string) => void; + onClose: () => void; +}; + +type ShootPanelProps = { + target?: EditorTarget; + passes: EditorVisibilityWindow[]; + selectedPass?: EditorVisibilityWindow; + modeId?: string; + modes: EditorMode[]; + onSelectPass: (pass?: EditorVisibilityWindow) => void; + onModeChange: (modeId: string) => void; + onAdd: () => void; + onClose: () => void; + onClearTarget: () => void; +}; + +type DownlinkPanelProps = { + stationOptions: DownlinkStationOption[]; + selected?: DownlinkSelection; + onSelect: (selection?: DownlinkSelection) => void; + onAdd: () => void; + onClose: () => void; +}; + +export function WorkInspector({ + work, + modes, + stations, + conflicts, + onUpdate, + onDelete, + onDuplicate, + onClose +}: WorkInspectorProps) { + const color = kindColor(work.kind); + + return ( +
+ +
{work.id}
+ + {conflicts.length > 0 && ( +
+ {conflicts.map((conflict) => ( +
{conflict}
+ ))} +
+ )} + +
+
Время
+ onUpdate(work.id, { startMs: value })} /> + onUpdate(work.id, { endMs: value })} /> +
длительность: {formatDuration(work.endMs - work.startMs)}
+
+ + {work.kind === "shoot" && ( +
+
Режим аппаратуры
+
+ {modes.map((mode) => ( + + ))} +
+ {work.targetName &&
цель: {work.targetName}
} +
+ )} + + {work.kind === "downlink" && ( +
+
Станция приёма
+ +
+ )} + +
+ + +
+ +
+ ); +} + +export function ShootPanel({ + target, + passes, + selectedPass, + modeId, + modes, + onSelectPass, + onModeChange, + onAdd, + onClose, + onClearTarget +}: ShootPanelProps) { + return ( +
+ + +
+
+
1 · цель съёмки
+ {target && ( + + )} +
+ {!target ? ( +
Кликните точку на карте слева, чтобы выбрать цель съёмки.
+ ) : ( +
+ + {target.name} + + {target.lat.toFixed(2)}, {target.lon.toFixed(2)} + +
+ )} +
+ + {target && ( +
+
2 · окно видимости
+ {passes.length === 0 ? ( +
В окне планирования проходов над целью нет.
+ ) : ( +
+ {passes.map((pass) => { + const selected = selectedPass?.startMs === pass.startMs && selectedPass.endMs === pass.endMs; + return ( + + ); + })} +
+ )} +
+ )} + + {target && selectedPass && ( +
+
3 · режим съёмки
+
+ {modes.map((mode) => ( + + ))} +
+
+ )} + + +
+ ); +} + +export function DownlinkPanel({ stationOptions, selected, onSelect, onAdd, onClose }: DownlinkPanelProps) { + return ( +
+ +
Выберите станцию и сеанс связи для добавления сброса в черновик.
+ +
+ {stationOptions.map((option) => ( +
+
+ {option.station.name} + {option.passes.length} сеанс. +
+ {option.passes.length === 0 ? ( +
нет сеансов в текущем окне
+ ) : ( + option.passes.slice(0, 5).map((pass) => { + const isSelected = selected?.station.id === option.station.id && selected.pass.startMs === pass.startMs; + return ( + + ); + }) + )} +
+ ))} +
+ + +
+ ); +} + +function PanelHead({ title, onClose }: { title: string; onClose: () => void }) { + return ( +
+

{title}

+ +
+ ); +} + +function TimeStepper({ + label, + value, + min, + max, + onChange +}: { + label: string; + value: number; + min?: number; + max?: number; + onChange: (value: number) => void; +}) { + const change = (deltaMs: number) => { + const next = Math.max(min ?? Number.NEGATIVE_INFINITY, Math.min(max ?? Number.POSITIVE_INFINITY, value + deltaMs)); + onChange(next); + }; + + return ( + + ); +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorRail.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorRail.tsx new file mode 100644 index 0000000..c44a959 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorRail.tsx @@ -0,0 +1,82 @@ +import type { TguPlanUi, TguPlatformUi } from "../../model/timelineTypes"; +import type { Draft, EditorWork } from "./model/editorTypes"; + +type EditorRailProps = { + selectedSpacecraftId?: string; + selectedPlan?: TguPlanUi; + platforms: TguPlatformUi[]; + draft: Draft; + conflictCount: number; + contextSpacecraftIds: Set; + onToggleContext: (spacecraftId: string) => void; +}; + +export function EditorRail({ + selectedSpacecraftId, + selectedPlan, + platforms, + draft, + conflictCount, + contextSpacecraftIds, + onToggleContext +}: EditorRailProps) { + const currentPlatform = platforms.find((platform) => platform.spacecraftId === selectedSpacecraftId); + const contextPlatforms = platforms.filter((platform) => platform.spacecraftId !== selectedSpacecraftId).slice(0, 40); + + return ( + + ); +} + +function RailStat({ label, value, color }: { label: string; value: number; color?: string }) { + return ( +
+ {value} + {label} +
+ ); +} + +function countWorks(works: EditorWork[], kind: EditorWork["kind"]): number { + return works.filter((work) => work.kind === kind).length; +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorTimeline.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorTimeline.tsx new file mode 100644 index 0000000..3d00949 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorTimeline.tsx @@ -0,0 +1,251 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { formatDate, formatDateTime, formatTime, kindColor, kindLabel, niceTicks } from "./editorPresentation"; +import type { + EditorContextLane, + EditorTimelineWindow, + EditorVisibilityTrack, + EditorWork, + EditorWorkPatch +} from "./model/editorTypes"; +import { VisibilityTracks } from "./VisibilityTracks"; + +type EditorTimelineProps = { + activeLabel: string; + draft: EditorWork[]; + selectedWorkId?: string; + window: EditorTimelineWindow; + nowMs: number; + conflicts: Record; + contextLanes: EditorContextLane[]; + visibilityTracks: EditorVisibilityTrack[]; + onSelectWork: (workId: string) => void; + onClearSelection: () => void; + onChangeWork: (workId: string, patch: EditorWorkPatch) => void; +}; + +const LABEL_WIDTH = 168; +const AXIS_HEIGHT = 40; +const ACTIVE_HEIGHT = 66; +const VISIBILITY_HEIGHT = 24; +const OTHER_HEIGHT = 30; +const SNAP_MS = 60 * 1000; + +type DragState = { + workId: string; + mode: "move" | "left" | "right"; + startX: number; + startMs: number; + endMs: number; +}; + +export function EditorTimeline({ + activeLabel, + draft, + selectedWorkId, + window: timelineWindow, + nowMs, + conflicts, + contextLanes, + visibilityTracks, + onSelectWork, + onClearSelection, + onChangeWork +}: EditorTimelineProps) { + const wrapperRef = useRef(null); + const dragRef = useRef(undefined); + const [bodyWidth, setBodyWidth] = useState(900); + const span = Math.max(1, timelineWindow.toMs - timelineWindow.fromMs); + const pxPerMs = bodyWidth / span; + const toX = (timeMs: number) => (timeMs - timelineWindow.fromMs) * pxPerMs; + const ticks = useMemo(() => niceTicks(timelineWindow.fromMs, timelineWindow.toMs, pxPerMs, 72), [pxPerMs, timelineWindow.fromMs, timelineWindow.toMs]); + const days = useMemo(() => buildDays(timelineWindow), [timelineWindow]); + const visibilityTop = AXIS_HEIGHT + ACTIVE_HEIGHT; + const dividerTop = visibilityTop + visibilityTracks.length * VISIBILITY_HEIGHT; + const contextTop = dividerTop + (contextLanes.length > 0 ? 22 : 0); + const totalHeight = + AXIS_HEIGHT + + ACTIVE_HEIGHT + + visibilityTracks.length * VISIBILITY_HEIGHT + + (contextLanes.length > 0 ? 22 : 0) + + contextLanes.length * OTHER_HEIGHT + + 8; + + useEffect(() => { + const element = wrapperRef.current; + if (!element) return; + + const observer = new ResizeObserver(() => { + setBodyWidth(Math.max(560, element.clientWidth - LABEL_WIDTH)); + }); + observer.observe(element); + setBodyWidth(Math.max(560, element.clientWidth - LABEL_WIDTH)); + + return () => observer.disconnect(); + }, []); + + useEffect(() => { + const onMove = (event: MouseEvent) => { + const drag = dragRef.current; + if (!drag) return; + + const deltaMs = Math.round(((event.clientX - drag.startX) / pxPerMs) / SNAP_MS) * SNAP_MS; + if (drag.mode === "move") { + onChangeWork(drag.workId, { startMs: drag.startMs + deltaMs, endMs: drag.endMs + deltaMs }); + } else if (drag.mode === "left") { + onChangeWork(drag.workId, { startMs: Math.min(drag.endMs - SNAP_MS, drag.startMs + deltaMs) }); + } else { + onChangeWork(drag.workId, { endMs: Math.max(drag.startMs + SNAP_MS, drag.endMs + deltaMs) }); + } + }; + + const onUp = () => { + dragRef.current = undefined; + }; + + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + return () => { + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + }; + }, [onChangeWork, pxPerMs]); + + const startDrag = (event: React.MouseEvent, work: EditorWork, mode: DragState["mode"]) => { + event.preventDefault(); + event.stopPropagation(); + dragRef.current = { + workId: work.id, + mode, + startX: event.clientX, + startMs: work.startMs, + endMs: work.endMs + }; + onSelectWork(work.id); + }; + + return ( +
+
+
+ Время (UTC) +
+
+ {days.map((day) => ( +
+ {formatDate(day).split(" ")[0]} + {formatDate(day).split(" ")[1]} +
+ ))} + {ticks.map((tick) => ( +
+ {formatTime(tick)} +
+ ))} +
+ +
+ {ticks.map((tick) => ( +
+ ))} +
+ + {nowMs >= timelineWindow.fromMs && nowMs <= timelineWindow.toMs && ( +
+ Сейчас +
+ )} + +
+
+ {activeLabel} + редактируется +
+
+ {draft.map((work) => { + const left = toX(work.startMs); + const width = Math.max(6, toX(work.endMs) - toX(work.startMs)); + const color = kindColor(work.kind); + const selected = selectedWorkId === work.id; + const hasConflict = Boolean(conflicts[work.id]?.length); + + return ( +
startDrag(event, work, "move")} + onClick={(event) => { + event.stopPropagation(); + onSelectWork(work.id); + }} + > + startDrag(event, work, "left")} /> + startDrag(event, work, "right")} /> + + {width > 54 && {work.label}} + {work.isNew && NEW} + {width > 82 && {formatTime(work.startMs)}-{formatTime(work.endMs)}} +
+ ); + })} +
+
+ + + + {contextLanes.length > 0 && ( +
+ Другие КА · контекст (read-only) +
+ )} + + {contextLanes.map((lane, index) => ( +
+
+ {lane.label} +
+
+ {lane.works.map((work) => { + if (work.endMs < timelineWindow.fromMs || work.startMs > timelineWindow.toMs) return null; + const left = toX(work.startMs); + const width = Math.max(3, toX(work.endMs) - toX(work.startMs)); + return ( +
+ ); + })} +
+
+ ))} +
+
+ ); +} + +function buildDays(window: EditorTimelineWindow): number[] { + const result: number[] = []; + const start = new Date(window.fromMs); + let dayMs = Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), start.getUTCDate()); + while (dayMs <= window.toMs) { + result.push(dayMs); + dayMs += 24 * 60 * 60 * 1000; + } + return result; +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx new file mode 100644 index 0000000..e776a79 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx @@ -0,0 +1,115 @@ +import type { TguPlanUi, TguPlatformUi } from "../../model/timelineTypes"; +import { formatDuration } from "./editorPresentation"; + +type EditorToolbarProps = { + selectedSpacecraftId?: string; + selectedPlan?: TguPlanUi; + platforms: TguPlatformUi[]; + canUndo: boolean; + canRedo: boolean; + checking: boolean; + conflictCount: number; + addedCount: number; + dirty: boolean; + applying: boolean; + windowLabel: string; + rightMode: "none" | "inspect" | "shoot" | "downlink"; + onSetRightMode: (mode: "shoot" | "downlink") => void; + onUndo: () => void; + onRedo: () => void; + onCheck: () => void; + onApply: () => void; + onZoom: (factor: number) => void; + onPan: (factor: number) => void; +}; + +export function EditorToolbar({ + selectedSpacecraftId, + selectedPlan, + platforms, + canUndo, + canRedo, + checking, + conflictCount, + addedCount, + dirty, + applying, + windowLabel, + rightMode, + onSetRightMode, + onUndo, + onRedo, + onCheck, + onApply, + onZoom, + onPan +}: EditorToolbarProps) { + const platformLabel = selectedSpacecraftId + ? platforms.find((platform) => platform.spacecraftId === selectedSpacecraftId)?.name ?? selectedSpacecraftId + : "КА не выбран"; + + return ( +
+
+
+ +
+ + +
+ +
+ +
+ + + {windowLabel || formatDuration(0)} + + +
+ +
+ + +
+ + + + +
+ ); +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx new file mode 100644 index 0000000..511811a --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx @@ -0,0 +1,514 @@ +import { useEffect, useMemo, useRef, useState, type MutableRefObject } from "react"; +import { groundTrack, stationPasses, targetPasses } from "../tgu-map-2d/geometry/passes"; +import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView"; +import type { Tgu2DMapLayersState } from "../tgu-map-2d/model/mapLayerTypes"; +import type { Tgu2DMapScene } from "../tgu-map-2d/model/mapTypes"; +import { saveEditedPlan } from "./editorApi"; +import { + addDownlink, + addShoot, + deleteWork, + duplicateWork, + mutate, + redo, + seedDraft, + undo, + updateWork +} from "./editorDraft"; +import { checkVisibility, conflictCount, mergeConflictMaps, overlaps, type EditorConflictMap } from "./editorConflicts"; +import { DEFAULT_EDITOR_MODES, formatDuration } from "./editorPresentation"; +import { DownlinkPanel, type DownlinkSelection, type DownlinkStationOption, ShootPanel, WorkInspector } from "./EditorPanels"; +import { EditorRail } from "./EditorRail"; +import { EditorTimeline } from "./EditorTimeline"; +import { EditorToolbar } from "./EditorToolbar"; +import type { + DraftHistory, + EditorGroundStation, + EditorMode, + EditorTarget, + EditorTimelineWindow, + EditorVisibilityTrack, + EditorVisibilityWindow, + EditorWorkPatch, + TguEditorTabProps +} from "./model/editorTypes"; + +type EditorRightMode = "none" | "inspect" | "shoot" | "downlink"; + +const HOUR_MS = 60 * 60 * 1000; +const EDITOR_MAP_LAYERS: Tgu2DMapLayersState = { + tracks: true, + swath: false, + planWorks: false, + stations: true, + spacecraftMarkers: false +}; + +export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, platforms }: TguEditorTabProps) { + const idCounterRef = useRef(0); + const targetCounterRef = useRef(0); + const [history, setHistory] = useState(() => ({ + draft: seedDraft(selectedPlan, selectedSpacecraftId), + past: [], + future: [] + })); + const [window, setWindow] = useState(() => defaultEditorWindow(selectedPlan, appliedRange)); + const [rightMode, setRightMode] = useState("none"); + const [selectedWorkId, setSelectedWorkId] = useState(); + const [target, setTarget] = useState(); + const [selectedShootPass, setSelectedShootPass] = useState(); + const [modeId, setModeId] = useState(DEFAULT_EDITOR_MODES[0].id); + const [selectedDownlink, setSelectedDownlink] = useState(); + const [serviceConflicts, setServiceConflicts] = useState({}); + const [checking, setChecking] = useState(false); + const [applying, setApplying] = useState(false); + const [notice, setNotice] = useState(); + const [applyError, setApplyError] = useState(); + const [contextSpacecraftIds, setContextSpacecraftIds] = useState>(new Set()); + const draft = history.draft; + const stations = useMemo(() => buildStations(selectedPlan), [selectedPlan]); + const modes: EditorMode[] = DEFAULT_EDITOR_MODES; + const liveConflicts = useMemo(() => overlaps(draft.works), [draft.works]); + const conflicts = useMemo(() => mergeConflictMaps(liveConflicts, serviceConflicts), [liveConflicts, serviceConflicts]); + const selectedWork = draft.works.find((work) => work.id === selectedWorkId); + const activeLabel = platformLabel(platforms, selectedSpacecraftId); + const shootPasses = useMemo( + () => target ? targetPasses({ spacecraftId: selectedSpacecraftId }, target, { fromMs: window.fromMs, toMs: window.toMs }) : [], + [selectedSpacecraftId, target, window.fromMs, window.toMs] + ); + const stationOptions = useMemo( + () => buildStationOptions(stations, selectedSpacecraftId, window, selectedPlan), + [selectedPlan, selectedSpacecraftId, stations, window] + ); + const visibilityTracks = useMemo( + () => buildVisibilityTracks(target, shootPasses, selectedWork, stations, selectedSpacecraftId, window), + [selectedSpacecraftId, selectedWork, shootPasses, stations, target, window] + ); + const mapScene = useMemo( + () => buildEditorMapScene(selectedSpacecraftId, window, stations), + [selectedSpacecraftId, stations, window] + ); + const contextLanes = useMemo( + () => + Array.from(contextSpacecraftIds).map((spacecraftId) => ({ + spacecraftId, + label: platformLabel(platforms, spacecraftId), + works: [] + })), + [contextSpacecraftIds, platforms] + ); + const dirty = history.past.length > 0 || draft.works.some((work) => work.isNew); + const addedCount = draft.works.filter((work) => work.isNew).length; + + useEffect(() => { + setHistory({ draft: seedDraft(selectedPlan, selectedSpacecraftId), past: [], future: [] }); + setWindow(defaultEditorWindow(selectedPlan, appliedRange)); + setSelectedWorkId(undefined); + setRightMode("none"); + setTarget(undefined); + setSelectedShootPass(undefined); + setSelectedDownlink(undefined); + setServiceConflicts({}); + setNotice(undefined); + setApplyError(undefined); + }, [appliedRange, selectedPlan, selectedSpacecraftId]); + + const changeDraft = (transform: (draft: DraftHistory["draft"]) => DraftHistory["draft"]) => { + setHistory((current) => mutate(current, transform)); + setServiceConflicts({}); + setNotice(undefined); + setApplyError(undefined); + }; + + const onChangeWork = (workId: string, patch: EditorWorkPatch) => { + changeDraft((current) => updateWork(current, workId, patch)); + }; + + const addShootWork = () => { + if (!target || !selectedShootPass || !modeId) return; + + const durationMs = Math.min(selectedShootPass.endMs - selectedShootPass.startMs, 30 * 60 * 1000); + const startMs = selectedShootPass.startMs + Math.floor((selectedShootPass.endMs - selectedShootPass.startMs - durationMs) / 2); + const mode = modes.find((item) => item.id === modeId); + const id = nextWorkId(idCounterRef); + + changeDraft((current) => + addShoot(current, { + id, + startMs, + endMs: startMs + durationMs, + label: mode?.label ?? "Съёмка", + modeId, + target + }) + ); + setSelectedWorkId(id); + setRightMode("inspect"); + setTarget(undefined); + setSelectedShootPass(undefined); + }; + + const addDownlinkWork = () => { + if (!selectedDownlink) return; + + const durationMs = Math.min(selectedDownlink.pass.endMs - selectedDownlink.pass.startMs, 12 * 60 * 1000); + const startMs = selectedDownlink.pass.startMs + Math.floor((selectedDownlink.pass.endMs - selectedDownlink.pass.startMs - durationMs) / 2); + const id = nextWorkId(idCounterRef); + + changeDraft((current) => + addDownlink(current, { + id, + startMs, + endMs: startMs + durationMs, + label: "Сброс на НКПОР", + station: selectedDownlink.station + }) + ); + setSelectedWorkId(id); + setRightMode("inspect"); + setSelectedDownlink(undefined); + }; + + const runCheck = () => { + setChecking(true); + setServiceConflicts(checkVisibility(draft, { spacecraftId: selectedSpacecraftId, stations })); + setChecking(false); + setNotice("Проверка видимости выполнена для текущего черновика."); + }; + + const applyDraft = async () => { + const planId = selectedPlan?.planId ?? draft.planId; + if (!planId) { + setApplyError("Нельзя применить черновик: план не выбран."); + setNotice(undefined); + return; + } + + setApplying(true); + setApplyError(undefined); + setNotice(undefined); + try { + const result = await saveEditedPlan({ planId, draft }); + setNotice(result.message); + setHistory((current) => ({ ...current, past: [], future: [] })); + } catch (error) { + setApplyError(error instanceof Error ? error.message : "Не удалось применить правки плана."); + } finally { + setApplying(false); + } + }; + + const zoom = (factor: number) => { + setWindow((current) => { + const center = (current.fromMs + current.toMs) / 2; + const half = ((current.toMs - current.fromMs) / 2) * factor; + return { fromMs: center - half, toMs: center + half }; + }); + }; + + const pan = (factor: number) => { + setWindow((current) => { + const delta = (current.toMs - current.fromMs) * factor; + return { fromMs: current.fromMs + delta, toMs: current.toMs + delta }; + }); + }; + + if (!selectedSpacecraftId && !selectedPlan) { + return ( +
+
Выберите КА или план, чтобы открыть редактор включений.
+
+ ); + } + + return ( +
+ 0} + canRedo={history.future.length > 0} + checking={checking} + conflictCount={conflictCount(conflicts)} + addedCount={addedCount} + dirty={dirty} + applying={applying} + windowLabel={formatDuration(window.toMs - window.fromMs)} + rightMode={rightMode} + onSetRightMode={(mode) => { + setRightMode(mode); + setSelectedWorkId(undefined); + setNotice(undefined); + }} + onUndo={() => setHistory((current) => undo(current))} + onRedo={() => setHistory((current) => redo(current))} + onCheck={runCheck} + onApply={applyDraft} + onZoom={zoom} + onPan={pan} + /> + + {notice &&
{notice}
} + {applyError &&
{applyError}
} + +
+ { + setContextSpacecraftIds((current) => { + const next = new Set(current); + if (next.has(spacecraftId)) { + next.delete(spacecraftId); + } else { + next.add(spacecraftId); + } + return next; + }); + }} + /> + +
+
+ {rightMode === "shoot" &&
Кликните точку съёмки на карте.
} + { + targetCounterRef.current += 1; + setTarget({ + id: `target-${targetCounterRef.current}`, + name: `Цель ${targetCounterRef.current}`, + lat: point.lat, + lon: point.lon + }); + setSelectedShootPass(undefined); + setModeId(DEFAULT_EDITOR_MODES[0].id); + setNotice(undefined); + }} + onObjectSelect={() => undefined} + /> +
+ { + setSelectedWorkId(workId); + setRightMode("inspect"); + setNotice(undefined); + }} + onClearSelection={() => { + if (rightMode === "inspect") { + setSelectedWorkId(undefined); + setRightMode("none"); + } + }} + onChangeWork={onChangeWork} + /> +
+ + {rightMode !== "none" && ( + + )} +
+
+ ); +} + +function defaultEditorWindow( + selectedPlan: TguEditorTabProps["selectedPlan"], + appliedRange: TguEditorTabProps["appliedRange"] +): EditorTimelineWindow { + if (selectedPlan) { + return { + fromMs: selectedPlan.startMs - 3 * HOUR_MS, + toMs: selectedPlan.endMs + 3 * HOUR_MS + }; + } + + return { fromMs: appliedRange.fromMs, toMs: appliedRange.toMs }; +} + +function buildStations(selectedPlan: TguEditorTabProps["selectedPlan"]): EditorGroundStation[] { + if (!selectedPlan?.kppId) { + return []; + } + + return [{ id: selectedPlan.kppId, name: selectedPlan.kppId }]; +} + +function buildStationOptions( + stations: EditorGroundStation[], + spacecraftId: string | undefined, + window: EditorTimelineWindow, + selectedPlan: TguEditorTabProps["selectedPlan"] +): DownlinkStationOption[] { + return stations.map((station) => { + const computedPasses = + station.lat !== undefined && station.lon !== undefined + ? stationPasses({ spacecraftId }, { lat: station.lat, lon: station.lon }, { fromMs: window.fromMs, toMs: window.toMs }) + : []; + const fallbackPass = selectedPlan && selectedPlan.kppId === station.id + ? [{ + startMs: Math.max(window.fromMs, selectedPlan.startMs), + endMs: Math.min(window.toMs, selectedPlan.endMs), + quality: 0 + }] + : []; + + return { + station, + passes: computedPasses.length > 0 ? computedPasses : fallbackPass.filter((pass) => pass.endMs > pass.startMs) + }; + }); +} + +function buildVisibilityTracks( + target: EditorTarget | undefined, + shootPasses: EditorVisibilityWindow[], + selectedWork: DraftHistory["draft"]["works"][number] | undefined, + stations: EditorGroundStation[], + spacecraftId: string | undefined, + window: EditorTimelineWindow +): EditorVisibilityTrack[] { + const tracks: EditorVisibilityTrack[] = []; + if (target) { + tracks.push({ + id: "target", + label: target.name, + kind: "target", + color: "var(--now)", + windows: shootPasses + }); + } + + if (selectedWork?.kind === "downlink" && selectedWork.stationId) { + const station = stations.find((item) => item.id === selectedWork.stationId); + if (station?.lat !== undefined && station.lon !== undefined) { + tracks.push({ + id: "station", + label: station.name, + kind: "station", + color: "var(--accent)", + windows: stationPasses({ spacecraftId }, { lat: station.lat, lon: station.lon }, { fromMs: window.fromMs, toMs: window.toMs }) + }); + } + } + + return tracks; +} + +function buildEditorMapScene( + spacecraftId: string | undefined, + window: EditorTimelineWindow, + stations: EditorGroundStation[] +): Tgu2DMapScene { + const lines = spacecraftId + ? groundTrack({ spacecraftId }, { fromMs: window.fromMs, toMs: window.toMs }, 260).map((points, index) => ({ + id: `${spacecraftId}:track:${index}`, + layer: "tracks" as const, + points, + spacecraftId, + zIndex: index + })) + : []; + const markers = stations.flatMap((station) => { + if (station.lat === undefined || station.lon === undefined) { + return []; + } + return [{ + id: station.id, + layer: "stations" as const, + point: { lat: station.lat, lon: station.lon }, + label: station.name, + zIndex: 10 + }]; + }); + + return { polygons: [], lines, markers }; +} + +function platformLabel(platforms: TguEditorTabProps["platforms"], spacecraftId?: string): string { + if (!spacecraftId) { + return "КА не выбран"; + } + const platform = platforms.find((item) => item.spacecraftId === spacecraftId); + return platform?.name ?? platform?.noradId ?? spacecraftId; +} + +function nextWorkId(ref: MutableRefObject): string { + ref.current += 1; + return `NW-${String(ref.current).padStart(4, "0")}`; +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/VisibilityTracks.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/VisibilityTracks.tsx new file mode 100644 index 0000000..d631b41 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/VisibilityTracks.tsx @@ -0,0 +1,58 @@ +import { formatTime } from "./editorPresentation"; +import type { EditorTimelineWindow, EditorVisibilityTrack } from "./model/editorTypes"; + +type VisibilityTracksProps = { + tracks: EditorVisibilityTrack[]; + window: EditorTimelineWindow; + width: number; + labelWidth: number; + top: number; + rowHeight: number; +}; + +export function VisibilityTracks({ tracks, window, width, labelWidth, top, rowHeight }: VisibilityTracksProps) { + const span = Math.max(1, window.toMs - window.fromMs); + const toX = (timeMs: number) => ((timeMs - window.fromMs) / span) * width; + + return ( + <> + {tracks.map((track, index) => { + const rowTop = top + index * rowHeight; + return ( +
+
+ + + {track.label} + + {track.kind === "target" ? "цель · видимость" : "станция · видимость"} +
+
+ {track.windows.map((visibilityWindow) => { + if (visibilityWindow.endMs < window.fromMs || visibilityWindow.startMs > window.toMs) { + return null; + } + + const left = toX(Math.max(visibilityWindow.startMs, window.fromMs)); + const right = toX(Math.min(visibilityWindow.endMs, window.toMs)); + return ( +
+ ); + })} +
+
+ ); + })} + + ); +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorApi.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorApi.ts new file mode 100644 index 0000000..670fadb --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorApi.ts @@ -0,0 +1,19 @@ +import type { Draft } from "./model/editorTypes"; + +export type SaveEditedPlanRequest = { + planId: string; + draft: Draft; +}; + +export type SaveEditedPlanResult = { + saved: false; + message: string; +}; + +export async function saveEditedPlan(_request: SaveEditedPlanRequest): Promise { + // TODO(backend): эндпоинт сохранения правок плана отсутствует в pcp-tgu-service. + return { + saved: false, + message: "Сохранение правок плана во внешний сервис ещё не реализовано в backend." + }; +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorConflicts.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorConflicts.test.ts new file mode 100644 index 0000000..57c29a7 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorConflicts.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { subPoint } from "../tgu-map-2d/geometry/passes"; +import { checkVisibility, mergeConflictMaps, overlaps } from "./editorConflicts"; +import type { Draft } from "./model/editorTypes"; + +const orbit = { spacecraftId: "56756" }; +const centerMs = Date.UTC(2026, 4, 29, 12, 0, 0); +const visiblePoint = subPoint(orbit, centerMs); + +describe("editorConflicts", () => { + it("detects overlapping works and keeps non-overlapping works clean", () => { + const conflicts = overlaps([ + { id: "shoot-1", kind: "shoot", label: "Съёмка", startMs: 1000, endMs: 5000, origin: "plan" }, + { id: "downlink-1", kind: "downlink", label: "Сброс", startMs: 4000, endMs: 7000, origin: "plan" }, + { id: "shoot-2", kind: "shoot", label: "Съёмка", startMs: 8000, endMs: 9000, origin: "plan" } + ]); + + expect(conflicts).toEqual({ + "shoot-1": ['Пересечение по времени с "Сброс"'], + "downlink-1": ['Пересечение по времени с "Съёмка"'] + }); + }); + + it("reports target visibility conflicts only when the selected interval is not covered", () => { + const draft: Draft = { + spacecraftId: orbit.spacecraftId, + works: [ + { + id: "visible-shoot", + kind: "shoot", + label: "Съёмка", + startMs: centerMs - 10_000, + endMs: centerMs + 10_000, + targetLat: visiblePoint.lat, + targetLon: visiblePoint.lon, + origin: "added" + }, + { + id: "hidden-shoot", + kind: "shoot", + label: "Съёмка", + startMs: centerMs - 10_000, + endMs: centerMs + 10_000, + targetLat: -visiblePoint.lat, + targetLon: visiblePoint.lon + 150, + origin: "added" + } + ] + }; + + expect(checkVisibility(draft, { stations: [] })).toEqual({ + "hidden-shoot": ["Съёмка вне зоны видимости цели"] + }); + }); + + it("checks downlink station visibility and missing station coordinates", () => { + const draft: Draft = { + spacecraftId: orbit.spacecraftId, + works: [ + { + id: "visible-downlink", + kind: "downlink", + label: "Сброс", + startMs: centerMs - 10_000, + endMs: centerMs + 10_000, + stationId: "KPP-1", + origin: "added" + }, + { + id: "unknown-downlink", + kind: "downlink", + label: "Сброс", + startMs: centerMs - 10_000, + endMs: centerMs + 10_000, + stationId: "KPP-2", + origin: "added" + } + ] + }; + + expect(checkVisibility(draft, { + stations: [ + { id: "KPP-1", name: "KPP-1", lat: visiblePoint.lat, lon: visiblePoint.lon }, + { id: "KPP-2", name: "KPP-2" } + ] + })).toEqual({ + "unknown-downlink": ["Нет координат станции для проверки видимости"] + }); + }); + + it("merges live and visibility conflict maps", () => { + expect(mergeConflictMaps({ "work-1": ["A"] }, { "work-1": ["B"], "work-2": ["C"] })).toEqual({ + "work-1": ["A", "B"], + "work-2": ["C"] + }); + }); +}); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorConflicts.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorConflicts.ts new file mode 100644 index 0000000..fb79498 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorConflicts.ts @@ -0,0 +1,103 @@ +import { + stationPasses, + targetPasses, + type GroundPoint, + type PassOrbit +} from "../tgu-map-2d/geometry/passes"; +import type { Draft, EditorCheckContext, EditorGroundStation, EditorWork } from "./model/editorTypes"; + +export type EditorConflictMap = Record; + +const DEFAULT_VISIBILITY_MARGIN_MS = 6 * 60 * 60 * 1000; + +export function overlaps(works: EditorWork[]): EditorConflictMap { + const result: EditorConflictMap = {}; + + for (let firstIndex = 0; firstIndex < works.length; firstIndex += 1) { + for (let secondIndex = firstIndex + 1; secondIndex < works.length; secondIndex += 1) { + const first = works[firstIndex]; + const second = works[secondIndex]; + if (Math.max(first.startMs, second.startMs) < Math.min(first.endMs, second.endMs)) { + addConflict(result, first.id, `Пересечение по времени с "${kindLabel(second.kind)}"`); + addConflict(result, second.id, `Пересечение по времени с "${kindLabel(first.kind)}"`); + } + } + } + + return result; +} + +export function checkVisibility(draft: Draft, context: EditorCheckContext): EditorConflictMap { + const result: EditorConflictMap = {}; + const orbit: PassOrbit = { spacecraftId: context.spacecraftId ?? draft.spacecraftId }; + const marginMs = context.marginMs ?? DEFAULT_VISIBILITY_MARGIN_MS; + + for (const work of draft.works) { + if (work.kind === "shoot" && work.targetLat !== undefined && work.targetLon !== undefined) { + const windows = targetPasses(orbit, { lat: work.targetLat, lon: work.targetLon }, { + fromMs: work.startMs - marginMs, + toMs: work.endMs + marginMs + }); + if (!isCovered(work, windows)) { + addConflict(result, work.id, "Съёмка вне зоны видимости цели"); + } + } + + if (work.kind === "downlink" && work.stationId) { + const station = context.stations.find((item) => item.id === work.stationId); + const point = stationPoint(station); + if (!point) { + addConflict(result, work.id, "Нет координат станции для проверки видимости"); + continue; + } + + const windows = stationPasses(orbit, point, { + fromMs: work.startMs - marginMs, + toMs: work.endMs + marginMs + }); + if (!isCovered(work, windows)) { + addConflict(result, work.id, "Сброс вне сеанса связи со станцией"); + } + } + } + + return result; +} + +export function mergeConflictMaps(...maps: EditorConflictMap[]): EditorConflictMap { + const result: EditorConflictMap = {}; + for (const map of maps) { + for (const [workId, messages] of Object.entries(map)) { + result[workId] = [...(result[workId] ?? []), ...messages]; + } + } + return result; +} + +export function conflictCount(map: EditorConflictMap): number { + return Object.keys(map).length; +} + +function isCovered(work: EditorWork, windows: Array<{ startMs: number; endMs: number }>): boolean { + return windows.some((window) => work.startMs >= window.startMs - 1 && work.endMs <= window.endMs + 1); +} + +function stationPoint(station?: EditorGroundStation): GroundPoint | undefined { + if (station?.lat === undefined || station.lon === undefined) { + return undefined; + } + return { lat: station.lat, lon: station.lon }; +} + +export function kindLabel(kind: EditorWork["kind"]): string { + switch (kind) { + case "shoot": + return "Съёмка"; + case "downlink": + return "Сброс"; + } +} + +function addConflict(map: EditorConflictMap, workId: string, message: string): void { + map[workId] = [...(map[workId] ?? []), message]; +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.test.ts new file mode 100644 index 0000000..f6ec4ec --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import type { TguPlanUi } from "../../model/timelineTypes"; +import { + addDownlink, + addShoot, + deleteWork, + duplicateWork, + mutate, + redo, + seedDraft, + undo, + updateWork +} from "./editorDraft"; +import type { DraftHistory } from "./model/editorTypes"; + +const plan: TguPlanUi = { + planId: "plan-1", + spacecraftId: "56756", + startTime: "2026-05-29T01:00:00Z", + endTime: "2026-05-29T02:00:00Z", + kppId: "KPP-1", + status: "PLANNED", + startMs: Date.parse("2026-05-29T01:00:00Z"), + endMs: Date.parse("2026-05-29T02:00:00Z") +}; + +describe("editorDraft", () => { + it("seeds a draft from the current API plan envelope when embedded works are absent", () => { + expect(seedDraft(plan)).toEqual({ + planId: "plan-1", + spacecraftId: "56756", + works: [ + { + id: "plan-1:plan-window", + kind: "downlink", + label: "План ТГУ · KPP-1", + startMs: plan.startMs, + endMs: plan.endMs, + stationId: "KPP-1", + origin: "plan" + } + ] + }); + }); + + it("adds shoot and downlink works with deterministic ids", () => { + const draft = seedDraft(undefined, "56756"); + const withShoot = addShoot(draft, { + id: "shoot-1", + label: "Панхром. съёмка", + modeId: "pan", + startMs: 1000, + endMs: 2000, + target: { id: "target-1", name: "Цель A", lat: 55.7, lon: 37.6 } + }); + const withDownlink = addDownlink(withShoot, { + id: "downlink-1", + label: "Сброс на НКПОР", + startMs: 3000, + endMs: 4000, + station: { id: "KPP-1", name: "KPP-1" } + }); + + expect(withDownlink.works.map((work) => work.id)).toEqual(["shoot-1", "downlink-1"]); + expect(withDownlink.works.every((work) => work.origin === "added" && work.isNew)).toBe(true); + }); + + it("keeps undo and redo stacks consistent across mutations", () => { + const initial: DraftHistory = { draft: seedDraft(undefined, "56756"), past: [], future: [] }; + const changed = mutate(initial, (draft) => + addDownlink(draft, { + id: "downlink-1", + label: "Сброс", + startMs: 1000, + endMs: 2000, + station: { id: "KPP-1", name: "KPP-1" } + }) + ); + + const undone = undo(changed); + const redone = redo(undone); + + expect(changed.past).toHaveLength(1); + expect(undone.draft.works).toHaveLength(0); + expect(undone.future).toHaveLength(1); + expect(redone.draft.works).toHaveLength(1); + expect(redone.future).toHaveLength(0); + }); + + it("updates, duplicates and deletes works without generating ids internally", () => { + const draft = seedDraft(plan); + const updated = updateWork(draft, "plan-1:plan-window", { label: "Сеанс", startMs: plan.startMs + 60_000 }); + const duplicated = duplicateWork(updated, "plan-1:plan-window", "copy-1", 120_000); + const deleted = deleteWork(duplicated, "plan-1:plan-window"); + + expect(updated.works[0].label).toBe("Сеанс"); + expect(duplicated.works[1]).toMatchObject({ + id: "copy-1", + startMs: updated.works[0].startMs + 120_000, + endMs: updated.works[0].endMs + 120_000, + origin: "added", + isNew: true + }); + expect(deleted.works.map((work) => work.id)).toEqual(["copy-1"]); + }); +}); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.ts new file mode 100644 index 0000000..0153d3e --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.ts @@ -0,0 +1,236 @@ +import type { TguPlanUi } from "../../model/timelineTypes"; +import type { + Draft, + DraftHistory, + EditorDownlinkWork, + EditorGroundStation, + EditorShootWork, + EditorTarget, + EditorWork, + EditorWorkPatch +} from "./model/editorTypes"; + +const DEFAULT_DUPLICATE_OFFSET_MS = 20 * 60 * 1000; +const DEFAULT_HISTORY_LIMIT = 50; + +export type AddShootInput = { + id: string; + startMs: number; + endMs: number; + label: string; + modeId?: string; + target: EditorTarget; +}; + +export type AddDownlinkInput = { + id: string; + startMs: number; + endMs: number; + label: string; + station: EditorGroundStation; +}; + +export function seedDraft(plan?: TguPlanUi, spacecraftId?: string): Draft { + if (!plan) { + return { spacecraftId, works: [] }; + } + + const embeddedWorks = readEmbeddedWorks(plan); + return { + planId: plan.planId, + spacecraftId: plan.spacecraftId, + works: embeddedWorks.length > 0 ? embeddedWorks : [fallbackPlanWork(plan)] + }; +} + +export function mutate( + history: DraftHistory, + transform: (draft: Draft) => Draft, + historyLimit = DEFAULT_HISTORY_LIMIT +): DraftHistory { + const nextDraft = transform(history.draft); + if (nextDraft === history.draft) { + return history; + } + + return { + draft: nextDraft, + past: [...history.past, history.draft].slice(-historyLimit), + future: [] + }; +} + +export function undo(history: DraftHistory): DraftHistory { + if (history.past.length === 0) { + return history; + } + + return { + draft: history.past[history.past.length - 1], + past: history.past.slice(0, -1), + future: [history.draft, ...history.future] + }; +} + +export function redo(history: DraftHistory): DraftHistory { + if (history.future.length === 0) { + return history; + } + + return { + draft: history.future[0], + past: [...history.past, history.draft], + future: history.future.slice(1) + }; +} + +export function addShoot(draft: Draft, input: AddShootInput): Draft { + const work: EditorShootWork = { + id: input.id, + kind: "shoot", + label: input.label, + startMs: input.startMs, + endMs: input.endMs, + modeId: input.modeId, + targetName: input.target.name, + targetLat: input.target.lat, + targetLon: input.target.lon, + origin: "added", + isNew: true + }; + + return { ...draft, works: [...draft.works, work] }; +} + +export function addDownlink(draft: Draft, input: AddDownlinkInput): Draft { + const work: EditorDownlinkWork = { + id: input.id, + kind: "downlink", + label: input.label, + startMs: input.startMs, + endMs: input.endMs, + stationId: input.station.id, + origin: "added", + isNew: true + }; + + return { ...draft, works: [...draft.works, work] }; +} + +export function updateWork(draft: Draft, workId: string, patch: EditorWorkPatch): Draft { + return { + ...draft, + works: draft.works.map((work) => (work.id === workId ? ({ ...work, ...patch } as EditorWork) : work)) + }; +} + +export function deleteWork(draft: Draft, workId: string): Draft { + return { + ...draft, + works: draft.works.filter((work) => work.id !== workId) + }; +} + +export function duplicateWork( + draft: Draft, + workId: string, + newId: string, + offsetMs = DEFAULT_DUPLICATE_OFFSET_MS +): Draft { + const source = draft.works.find((work) => work.id === workId); + if (!source) { + return draft; + } + + const duplicated: EditorWork = { + ...source, + id: newId, + startMs: source.startMs + offsetMs, + endMs: source.endMs + offsetMs, + origin: "added", + isNew: true + }; + + return { ...draft, works: [...draft.works, duplicated] }; +} + +function fallbackPlanWork(plan: TguPlanUi): EditorDownlinkWork { + return { + id: `${plan.planId}:plan-window`, + kind: "downlink", + label: `План ТГУ · ${plan.kppId}`, + startMs: plan.startMs, + endMs: plan.endMs, + stationId: plan.kppId, + origin: "plan" + }; +} + +function readEmbeddedWorks(plan: TguPlanUi): EditorWork[] { + const rawWorks = (plan as TguPlanUi & { works?: unknown }).works; + if (!Array.isArray(rawWorks)) { + return []; + } + + return rawWorks.flatMap((value, index): EditorWork[] => { + if (!isRecord(value)) return []; + const kind = value.kind === "shoot" || value.kind === "downlink" ? value.kind : undefined; + const startMs = readTime(value, "startMs") ?? readTime(value, "start"); + const endMs = readTime(value, "endMs") ?? readTime(value, "end"); + if (!kind || startMs === undefined || endMs === undefined || startMs >= endMs) { + return []; + } + + const id = readString(value.id) ?? `${plan.planId}:work-${index + 1}`; + const label = readString(value.label) ?? (kind === "shoot" ? "Съёмка" : "Сброс на НКПОР"); + const base = { + id, + kind, + label, + startMs, + endMs, + origin: "plan" as const + }; + + if (kind === "shoot") { + return [{ + ...base, + kind, + modeId: readString(value.modeId), + targetName: readString(value.targetName), + targetLat: readNumber(value.targetLat), + targetLon: readNumber(value.targetLon) + }]; + } + + return [{ + ...base, + kind, + stationId: readString(value.stationId) ?? readString(value.kppId) + }]; + }); +} + +function readTime(record: Record, key: string): number | undefined { + const value = record[key]; + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string") { + const parsed = new Date(value).getTime(); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value : undefined; +} + +function readNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorPresentation.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorPresentation.ts new file mode 100644 index 0000000..17a93f2 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorPresentation.ts @@ -0,0 +1,67 @@ +import type { EditorWorkKind } from "./model/editorTypes"; + +export const EDITOR_MONTHS = ["янв", "фев", "мар", "апр", "май", "июн", "июл", "авг", "сен", "окт", "ноя", "дек"]; + +export const DEFAULT_EDITOR_MODES = [ + { id: "pan", label: "Панхром." }, + { id: "ms", label: "Мультиспектр." }, + { id: "stereo", label: "Стерео" }, + { id: "scan", label: "Скан" } +]; + +export function kindLabel(kind: EditorWorkKind): string { + switch (kind) { + case "shoot": + return "Съёмка"; + case "downlink": + return "Сброс"; + } +} + +export function kindColor(kind: EditorWorkKind): string { + return kind === "shoot" ? "var(--t-optical)" : "var(--accent)"; +} + +export function formatTime(ms: number): string { + const date = new Date(ms); + return `${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}`; +} + +export function formatDate(ms: number): string { + const date = new Date(ms); + return `${pad(date.getUTCDate())} ${EDITOR_MONTHS[date.getUTCMonth()]}`; +} + +export function formatDateTime(ms: number): string { + return `${formatDate(ms)} ${formatTime(ms)}`; +} + +export function formatDuration(ms: number): string { + const hours = ms / 3_600_000; + if (hours < 24) { + return `${Number.isInteger(hours) ? hours : hours.toFixed(1)} ч`; + } + + const days = Math.floor(hours / 24); + const restHours = Math.round(hours - days * 24); + return restHours > 0 ? `${days} сут ${restHours} ч` : `${days} сут`; +} + +export function niceTicks(fromMs: number, toMs: number, pxPerMs: number, minPx: number): number[] { + const hourMs = 3_600_000; + const dayMs = 24 * hourMs; + const steps = [hourMs, 2 * hourMs, 3 * hourMs, 6 * hourMs, 12 * hourMs, dayMs, 2 * dayMs, 7 * dayMs]; + const step = steps.find((item) => item * pxPerMs >= minPx) ?? steps[steps.length - 1]; + const ticks: number[] = []; + const firstTick = Math.ceil(fromMs / step) * step; + + for (let timeMs = firstTick; timeMs <= toMs; timeMs += step) { + ticks.push(timeMs); + } + + return ticks; +} + +function pad(value: number): string { + return String(value).padStart(2, "0"); +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/model/editorTypes.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/model/editorTypes.ts new file mode 100644 index 0000000..af6c0a5 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/model/editorTypes.ts @@ -0,0 +1,104 @@ +import type { TguPlanUi, TguPlatformUi, TimelineRange } from "../../../model/timelineTypes"; + +export type EditorWorkKind = "shoot" | "downlink"; + +export type EditorWorkOrigin = "plan" | "added"; + +export type EditorWorkBase = { + id: string; + kind: EditorWorkKind; + label: string; + startMs: number; + endMs: number; + origin: EditorWorkOrigin; + isNew?: boolean; +}; + +export type EditorShootWork = EditorWorkBase & { + kind: "shoot"; + modeId?: string; + targetName?: string; + targetLat?: number; + targetLon?: number; +}; + +export type EditorDownlinkWork = EditorWorkBase & { + kind: "downlink"; + stationId?: string; +}; + +export type EditorWork = EditorShootWork | EditorDownlinkWork; + +export type EditorWorkPatch = + Partial> & + Partial> & + Partial>; + +export type Draft = { + planId?: string; + spacecraftId?: string; + works: EditorWork[]; +}; + +export type DraftHistory = { + draft: Draft; + past: Draft[]; + future: Draft[]; +}; + +export type EditorTarget = { + id: string; + name: string; + lat: number; + lon: number; +}; + +export type EditorGroundStation = { + id: string; + name: string; + lat?: number; + lon?: number; +}; + +export type EditorVisibilityWindow = { + startMs: number; + endMs: number; + quality: number; +}; + +export type EditorVisibilityTrack = { + id: string; + label: string; + kind: "target" | "station"; + color: string; + windows: EditorVisibilityWindow[]; +}; + +export type EditorTimelineWindow = { + fromMs: number; + toMs: number; +}; + +export type EditorContextLane = { + spacecraftId: string; + label: string; + works: EditorWork[]; +}; + +export type EditorMode = { + id: string; + label: string; +}; + +export type EditorCheckContext = { + spacecraftId?: string; + stations: EditorGroundStation[]; + marginMs?: number; +}; + +export type TguEditorTabProps = { + selectedSpacecraftId?: string; + selectedPlan?: TguPlanUi; + appliedRange: TimelineRange; + platforms: TguPlatformUi[]; +}; diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx index 30bd7cf..2d55dd4 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx @@ -15,6 +15,16 @@ type Tgu2DMapViewProps = { layers: Tgu2DMapLayersState; redrawToken: number; onObjectSelect: (selection?: Tgu2DMapSelection) => void; + pickMode?: boolean; + onPickPoint?: (point: { lat: number; lon: number }) => void; + targets?: Tgu2DMapTarget[]; +}; + +export type Tgu2DMapTarget = { + id: string; + name: string; + lat: number; + lon: number; }; type DragState = { @@ -39,7 +49,15 @@ const INITIAL_VIEW: MapViewState = { zoom: 2 }; -export function Tgu2DMapView({ scene, layers, redrawToken, onObjectSelect }: Tgu2DMapViewProps) { +export function Tgu2DMapView({ + scene, + layers, + redrawToken, + onObjectSelect, + pickMode = false, + onPickPoint, + targets = [] +}: Tgu2DMapViewProps) { const wrapperRef = useRef(null); const canvasRef = useRef(null); const dragRef = useRef(undefined); @@ -199,6 +217,12 @@ export function Tgu2DMapView({ scene, layers, redrawToken, onObjectSelect }: Tgu const onClick = (event: React.MouseEvent) => { const screenPoint = screenPointFromEvent(event); + if (pickMode) { + onPickPoint?.(projection.unproject(screenPoint)); + onObjectSelect(undefined); + return; + } + const polygon = hitTestPolygons(polygonIndex, projection.unproject(screenPoint)); selectedPolygonRef.current = polygon; @@ -239,7 +263,7 @@ export function Tgu2DMapView({ scene, layers, redrawToken, onObjectSelect }: Tgu return (
))} + {targets.map((target) => { + const screen = projection.project(target); + return ( + + + + + {target.name} + + ); + })} {tooltip && (
diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/passes.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/passes.test.ts new file mode 100644 index 0000000..3a7a3a4 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/passes.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { groundTrack, nearestStation, stationPasses, subPoint, targetPasses } from "./passes"; + +const orbit = { spacecraftId: "56756" }; +const centerMs = Date.UTC(2026, 4, 29, 12, 0, 0); + +describe("passes", () => { + it("finds target and station visibility around the sub-satellite point", () => { + const point = subPoint(orbit, centerMs); + const range = { fromMs: centerMs - 15 * 60 * 1000, toMs: centerMs + 15 * 60 * 1000 }; + + expect(targetPasses(orbit, point, range).length).toBeGreaterThan(0); + expect(stationPasses(orbit, point, range).length).toBeGreaterThan(0); + }); + + it("selects the nearest station by great-circle distance", () => { + const point = { lat: 55.75, lon: 37.62 }; + + expect(nearestStation(point, [ + { id: "far", lat: -30, lon: -120 }, + { id: "near", lat: 55.8, lon: 37.7 } + ])?.id).toBe("near"); + }); + + it("builds ground track segments in the requested range", () => { + const segments = groundTrack(orbit, { + fromMs: centerMs, + toMs: centerMs + 90 * 60 * 1000 + }); + + expect(segments.length).toBeGreaterThan(0); + expect(segments.every((segment) => segment.length > 1)).toBe(true); + }); +}); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/passes.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/passes.ts new file mode 100644 index 0000000..17deb6e --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/passes.ts @@ -0,0 +1,200 @@ +import { normalizeLon } from "./mapProjection"; + +const DEG = Math.PI / 180; +const RAD = 180 / Math.PI; +const EARTH_RADIUS_KM = 6371; +const EARTH_MU_KM3_S2 = 398600.4418; +const SIDEREAL_DAY_SECONDS = 86164; +const DEFAULT_ALTITUDE_KM = 550; +const DEFAULT_TARGET_THRESHOLD_DEG = 6.5; +const TARGET_SCAN_STEP_MS = 20 * 1000; +const STATION_SCAN_STEP_MS = 30 * 1000; +const MIN_WINDOW_MS = 40 * 1000; + +export type GroundPoint = { + lat: number; + lon: number; +}; + +export type GroundStationPoint = GroundPoint & { + id: string; +}; + +export type PassOrbit = { + spacecraftId?: string; + altitudeKm?: number; +}; + +export type PassRange = { + fromMs: number; + toMs: number; +}; + +export type PassWindow = { + startMs: number; + endMs: number; + quality: number; +}; + +export function subPoint(orbit: PassOrbit, timeMs: number): GroundPoint { + const params = orbitParams(orbit); + const dtSeconds = timeMs / 1000; + const argument = params.phaseRad + 2 * Math.PI * (dtSeconds / params.periodSeconds); + const lat = Math.asin(Math.sin(params.inclinationRad) * Math.sin(argument)) * RAD; + const relativeLon = Math.atan2(Math.cos(params.inclinationRad) * Math.sin(argument), Math.cos(argument)); + const lon = normalizeLon((params.nodeRad + relativeLon) * RAD - (360 / SIDEREAL_DAY_SECONDS) * dtSeconds); + + return { lat, lon }; +} + +export function targetPasses(orbit: PassOrbit, target: GroundPoint, range: PassRange): PassWindow[] { + return scanWindows(orbit, target, range, DEFAULT_TARGET_THRESHOLD_DEG, TARGET_SCAN_STEP_MS); +} + +export function stationPasses(orbit: PassOrbit, station: GroundPoint, range: PassRange, minElevDeg = 5): PassWindow[] { + const thresholdDeg = accessAngleDeg(orbit.altitudeKm ?? DEFAULT_ALTITUDE_KM, minElevDeg); + return scanWindows(orbit, station, range, thresholdDeg, STATION_SCAN_STEP_MS); +} + +export function nearestStation(point: GroundPoint, stations: GroundStationPoint[]): GroundStationPoint | undefined { + let bestStation: GroundStationPoint | undefined; + let bestDistance = Number.POSITIVE_INFINITY; + + for (const station of stations) { + const distance = greatCircleDistanceDeg(point, station); + if (distance < bestDistance) { + bestDistance = distance; + bestStation = station; + } + } + + return bestStation; +} + +export function groundTrack(orbit: PassOrbit, range: PassRange, maxPoints = 240): GroundPoint[][] { + if (range.toMs <= range.fromMs) { + return []; + } + + const spanMs = range.toMs - range.fromMs; + const stepMs = Math.max(60 * 1000, Math.ceil(spanMs / Math.max(2, maxPoints))); + const segments: GroundPoint[][] = []; + let current: GroundPoint[] = []; + let previousLon: number | undefined; + + for (let timeMs = range.fromMs; timeMs <= range.toMs; timeMs += stepMs) { + const point = subPoint(orbit, timeMs); + if (previousLon !== undefined && Math.abs(point.lon - previousLon) > 180) { + if (current.length > 1) { + segments.push(current); + } + current = []; + } + current.push(point); + previousLon = point.lon; + } + + const endPoint = subPoint(orbit, range.toMs); + if (current.length === 0 || current[current.length - 1].lat !== endPoint.lat || current[current.length - 1].lon !== endPoint.lon) { + current.push(endPoint); + } + if (current.length > 1) { + segments.push(current); + } + + return segments; +} + +export function greatCircleDistanceDeg(first: GroundPoint, second: GroundPoint): number { + const firstLat = first.lat * DEG; + const secondLat = second.lat * DEG; + const deltaLon = (second.lon - first.lon) * DEG; + const cosine = + Math.sin(firstLat) * Math.sin(secondLat) + + Math.cos(firstLat) * Math.cos(secondLat) * Math.cos(deltaLon); + + return Math.acos(Math.max(-1, Math.min(1, cosine))) * RAD; +} + +export function accessAngleDeg(altitudeKm: number, minElevDeg: number): number { + const minElevRad = minElevDeg * DEG; + const ratio = EARTH_RADIUS_KM / (EARTH_RADIUS_KM + altitudeKm); + return (Math.acos(ratio * Math.cos(minElevRad)) - minElevRad) * RAD; +} + +function scanWindows( + orbit: PassOrbit, + target: GroundPoint, + range: PassRange, + thresholdDeg: number, + stepMs: number +): PassWindow[] { + if (range.toMs <= range.fromMs) { + return []; + } + + const windows: PassWindow[] = []; + let inside = false; + let startMs = range.fromMs; + let bestMargin = 0; + + for (let timeMs = range.fromMs; timeMs <= range.toMs; timeMs += stepMs) { + const distance = greatCircleDistanceDeg(subPoint(orbit, timeMs), target); + const visible = distance <= thresholdDeg; + + if (visible && !inside) { + inside = true; + startMs = timeMs; + bestMargin = thresholdDeg - distance; + } else if (visible) { + bestMargin = Math.max(bestMargin, thresholdDeg - distance); + } else if (inside) { + windows.push(toWindow(startMs, timeMs, bestMargin, thresholdDeg)); + inside = false; + } + } + + if (inside) { + windows.push(toWindow(startMs, range.toMs, bestMargin, thresholdDeg)); + } + + return windows.filter((window) => window.endMs - window.startMs >= MIN_WINDOW_MS); +} + +function toWindow(startMs: number, endMs: number, bestMargin: number, thresholdDeg: number): PassWindow { + return { + startMs, + endMs, + quality: Math.max(0, Math.min(1, bestMargin / thresholdDeg)) + }; +} + +function orbitParams(orbit: PassOrbit): { + altitudeKm: number; + periodSeconds: number; + inclinationRad: number; + nodeRad: number; + phaseRad: number; +} { + const altitudeKm = orbit.altitudeKm ?? DEFAULT_ALTITUDE_KM; + const semiMajorAxis = EARTH_RADIUS_KM + altitudeKm; + const periodSeconds = 2 * Math.PI * Math.sqrt((semiMajorAxis * semiMajorAxis * semiMajorAxis) / EARTH_MU_KM3_S2); + const key = orbit.spacecraftId ?? "default-spacecraft"; + + return { + altitudeKm, + periodSeconds, + inclinationRad: (97.4 + hash01(key, 7) * 1.6) * DEG, + nodeRad: hash01(key, 3) * 360 * DEG, + phaseRad: hash01(key, 11) * 2 * Math.PI + }; +} + +function hash01(value: string, salt: number): number { + let hash = 2166136261 ^ salt; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return ((hash >>> 0) % 100000) / 100000; +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx index 617ac93..fff4c19 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx @@ -1,7 +1,8 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { fetchPlans, fetchPlatforms, sendPlanDecision } from "../../api/tguApi"; import type { TguPlanDecision, TguPlatform } from "../../model/tguTypes"; -import type { TguPlanUi, TimelineRange } from "../../model/timelineTypes"; +import type { TguActiveTab, TguPlanUi, TimelineRange } from "../../model/timelineTypes"; +import { TguEditorTab } from "../tgu-editor/TguEditorTab"; import { Tgu2DMapTab } from "../tgu-map-2d/Tgu2DMapTab"; import { TguPlanDetails } from "./TguPlanDetails"; import { TguPlanningLayout } from "./TguPlanningLayout"; @@ -25,7 +26,7 @@ export function TguPlanningPage() { const [search, setSearch] = useState(""); const [selectedSpacecraftId, setSelectedSpacecraftId] = useState(); const [selectedPlanId, setSelectedPlanId] = useState(); - const [activeTab, setActiveTab] = useState<"timeline" | "map">("timeline"); + const [activeTab, setActiveTab] = useState("timeline"); const [decisionInFlight, setDecisionInFlight] = useState(false); const [decisionNotice, setDecisionNotice] = useState(); const [decisionError, setDecisionError] = useState(); @@ -197,7 +198,7 @@ export function TguPlanningPage() { onClose={() => setSelectedPlanId(undefined)} /> - ) : ( + ) : activeTab === "map" ? ( + ) : ( + )} ); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguToolbar.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguToolbar.tsx index f1412d7..9c1eb9f 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguToolbar.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguToolbar.tsx @@ -1,12 +1,13 @@ import { TguLegend } from "./TguLegend"; +import type { TguActiveTab } from "../../model/timelineTypes"; type TguToolbarProps = { fromValue: string; toValue: string; loading: boolean; - activeTab: "timeline" | "map"; + activeTab: TguActiveTab; error?: string; - onTabChange: (tab: "timeline" | "map") => void; + onTabChange: (tab: TguActiveTab) => void; onFromChange: (value: string) => void; onToChange: (value: string) => void; onApply: () => void; @@ -57,6 +58,15 @@ export function TguToolbar({ > Карта 2D +