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) => (
+ { setScaleId(s.id); setScrollSignal((x) => x + 1); }}
+ style={{ padding: "5px 11px", borderRadius: 5, border: "none", fontSize: 11.5, fontWeight: 600, background: scaleId === s.id ? "var(--bg-3)" : "transparent", color: scaleId === s.id ? "var(--ink)" : "var(--ink-3)" }}>{s.label}
+ ))}
+
+
+
setScrollSignal((x) => x + 1)} style={{ display: "flex", alignItems: "center", gap: 6, padding: "6px 11px", borderRadius: 6, border: "1px solid var(--line)", background: "var(--bg-2)", fontSize: 11.5, fontWeight: 600, color: "var(--now)" }}>
+ Сейчас
+
+
+
+
+
+
+
+ {/* статистика */}
+
+
+
+ 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 (
+ onSelectIv(id)} style={{
+ display: "flex", alignItems: "center", gap: 8, width: "100%", textAlign: "left",
+ padding: "7px 9px", borderRadius: 6, border: `1px solid ${isSel ? "var(--accent)" : "var(--line-soft)"}`,
+ background: isSel ? "oklch(0.82 0.13 200 / 0.12)" : "var(--bg-2)",
+ }}>
+ {it.id}
+ {it.title}
+ r{it.rev}
+
+ );
+ };
+
+ return (
+
+
+
+
{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 (
+ {children}
+ );
+}
+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) => (
+ onUpdate(work.id, { modeId: m.id, label: m.label })}
+ style={{ padding: "6px 10px", borderRadius: 6, fontSize: 11.5, fontWeight: 600,
+ border: `1px solid ${work.modeId === m.id ? "var(--accent)" : "var(--line)"}`,
+ background: work.modeId === m.id ? "oklch(0.82 0.13 200 / 0.14)" : "var(--bg-2)",
+ color: work.modeId === m.id ? "var(--ink)" : "var(--ink-2)" }}>{m.label}
+ ))}
+
+ {work.targetName && цель: {work.targetName}
}
+
+ )}
+
+ {work.kind === "downlink" && (
+
+ onUpdate(work.id, { stationId: e.target.value })}
+ 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 }}>
+ {stations.map((s) => {s.name} )}
+
+
+ )}
+
+
+ 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 {children} ;
+}
+
+// ---- Флоу добавления СЪЁМКИ ----
+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 (
+ setSelectedPass(ps)} style={{
+ display: "flex", alignItems: "center", gap: 8, padding: "7px 9px", borderRadius: 6, textAlign: "left",
+ border: `1px solid ${sel ? "var(--accent)" : "var(--line-soft)"}`, background: sel ? "oklch(0.82 0.13 200 / 0.12)" : "var(--bg-2)" }}>
+ {fmtDate(ps.start)} {fmtTime(ps.start)}
+ {passDur(ps)}
+
+
+ );
+ })}
+
+ )}
+
+ )}
+
+ {target && selectedPass && (
+
+
+ {modes.map((m) => (
+ setModeId(m.id)} style={{ padding: "6px 10px", borderRadius: 6, fontSize: 11.5, fontWeight: 600,
+ border: `1px solid ${modeId === m.id ? "var(--accent)" : "var(--line)"}`, background: modeId === m.id ? "oklch(0.82 0.13 200 / 0.14)" : "var(--bg-2)", color: modeId === m.id ? "var(--ink)" : "var(--ink-2)" }}>{m.label}
+ ))}
+
+
+ )}
+
+
+ Добавить съёмку в план
+
+ );
+}
+
+// ---- Флоу добавления СБРОСА ----
+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 (
+ setSelected({ station: so.station, pass: ps })} style={{
+ display: "flex", alignItems: "center", gap: 8, padding: "6px 10px", textAlign: "left",
+ borderTop: "1px solid var(--line-soft)", background: sel ? "oklch(0.82 0.13 200 / 0.14)" : "transparent" }}>
+ {fmtDate(ps.start)} {fmtTime(ps.start)}–{fmtTime(ps.end)}
+ {passDur(ps)}
+ {sel && ✓ }
+
+ );
+ })}
+
+ )}
+
+ ))}
+
+
+ Добавить сброс в план
+
+ );
+}
+
+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 (
+
+ {/* выбор КА */}
+
+
+ onPickSc(e.target.value)}
+ style={{ padding: "6px 8px", background: "var(--bg-0)", border: "1px solid var(--line)", borderRadius: 6, color: "var(--ink)", fontFamily: "var(--mono)", fontSize: 12, fontWeight: 600, maxWidth: 180 }}>
+ {D.GROUP_DEFS.map((g) => (
+
+ {D.spacecraft.filter((s) => s.groupId === g.id).map((s) => {s.short} )}
+
+ ))}
+
+
+ {/* выбор плана */}
+
onPickPlan(e.target.value)}
+ style={{ padding: "6px 8px", background: "var(--bg-0)", border: "1px solid var(--line)", borderRadius: 6, color: "var(--ink-1)", fontFamily: "var(--mono)", fontSize: 11.5, maxWidth: 140 }}>
+ {craftPlans.map((pl) => {pl.id} · r{pl.rev} )}
+
+
+ {/* добавить включение */}
+
+ setRightMode("shoot")} style={addBtn(props.rightMode === "shoot")}>
+ + Съёмка
+
+ setRightMode("downlink")} style={addBtn(props.rightMode === "downlink", "var(--accent)")}>
+ + Сброс
+
+
+
+
+
+ {/* зум / пан окна */}
+
+ panBy(-0.25)} title="назад">◀
+ zoom(1.4)} title="отдалить">−
+ {fmtDur(win.t1 - win.t0)}
+ zoom(0.7)} title="приблизить">+
+ panBy(0.25)} title="вперёд">▶
+
+
+ {/* undo / redo */}
+
+ ↶
+ ↷
+
+
+ {/* проверка */}
+
+ {checking ? "Проверка…" : conflictCount ? `⚠ ${conflictCount}` : "✓ Проверить"}
+
+
+
+ Применить{addedCount ? ` · +${addedCount}` : ""}
+
+
+ );
+}
+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 (
+ {children}
+ );
+}
+
+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]) => (
+ setBasemap(id)} style={{ flex: 1, padding: "5px 0", borderRadius: 5, border: "none", fontSize: 11, fontWeight: 600, background: basemap === id ? "var(--bg-3)" : "transparent", color: basemap === id ? "var(--ink)" : "var(--ink-3)" }}>{lab}
+ ))}
+
+
+
+ {/* контекст: другие КА */}
+
+
+
+ ДРУГИЕ КА · КОНТЕКСТ
+ {otherShown.size} вкл.
+
+
+ {[["group", "Своя группа"], ["all", "Все"]].map(([id, lab]) => (
+ setCtxScope(id)} style={{ flex: 1, padding: "5px 0", borderRadius: 5, border: "none", fontSize: 11, fontWeight: 600, background: ctxScope === id ? "var(--bg-3)" : "transparent", color: ctxScope === id ? "var(--ink)" : "var(--ink-3)" }}>{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 (
+
toggleOther(s.id)} style={{ display: "flex", alignItems: "center", gap: 8, width: "100%", padding: "5px 7px", borderRadius: 6, border: "none", background: on ? "var(--bg-2)" : "transparent", textAlign: "left" }}>
+
+ {on && }
+
+
+ {s.short}
+
+ );
+ })}
+
+
+
+ );
+}
+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]) => (
+ setBasemap(id)} style={{ flex: 1, padding: "6px 0", borderRadius: 5, border: "none", fontSize: 11.5, fontWeight: 600, background: basemap === id ? "var(--bg-3)" : "transparent", color: basemap === id ? "var(--ink)" : "var(--ink-3)" }}>{lab}
+ ))}
+
+
+
+ {/* охват */}
+
+
ОХВАТ
+
+ {[["one", "1 КА"], ["group", "Группа"], ["all", "Все"]].map(([id, lab]) => (
+ setScope(id)} style={{ flex: 1, padding: "6px 0", borderRadius: 5, border: "none", fontSize: 11.5, fontWeight: 600, background: scope === id ? "var(--bg-3)" : "transparent", color: scope === id ? "var(--ink)" : "var(--ink-3)" }}>{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 (
+
+ {swatch}
+ {label}
+
+ {on && }
+
+
+ );
+}
+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.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 (
+ {children}
+ );
+}
+
+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 (
+
+ {on && (
+
+ )}
+
+ );
+}
+
+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 &&
setSearch("")} style={{ position: "absolute", right: 6, top: 6, background: "none", border: "none", color: "var(--ink-3)", fontSize: 14, padding: 2 }}>× }
+
+
+
+ {/* Легенда / фильтр по типам */}
+
+
АППАРАТУРА
+
+ {D.typeOrder.map((t) => {
+ const on = typeFilter[t];
+ return (
+ setTypeFilter({ ...typeFilter, [t]: !on })}
+ style={{ display: "flex", alignItems: "center", gap: 8, padding: "5px 7px", borderRadius: 6, border: "1px solid transparent", background: on ? "var(--bg-2)" : "transparent", opacity: on ? 1 : 0.45, textAlign: "left" }}>
+
+ {D.TYPES[t].label}
+ {counts.byType[t] || 0}
+
+ );
+ })}
+
+
+
+ {/* Дерево групп → КА */}
+
+
ГРУППЫ / КА
+ {groups.map((g) => {
+ const list = scByGroup[g.id] || [];
+ const isOpen = open[g.id];
+ const shownCount = list.filter((sc) => !scHidden.has(sc.id)).length;
+ return (
+
+
+
setOpen({ ...open, [g.id]: !isOpen })} style={{ background: "none", border: "none", padding: 0, width: 14, height: 14, display: "grid", placeItems: "center", color: "var(--ink-2)" }}>
+
+
+
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 (
+
+
+ {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) => (
+ onUpdate(work.id, { modeId: mode.id, label: mode.label })}
+ >
+ {mode.label}
+
+ ))}
+
+ {work.targetName && цель: {work.targetName}
}
+
+ )}
+
+ {work.kind === "downlink" && (
+
+ Станция приёма
+ onUpdate(work.id, { stationId: event.target.value })}>
+ {stations.map((station) => (
+
+ {station.name}
+
+ ))}
+
+
+ )}
+
+
+ onDuplicate(work.id)}>
+ Дублировать
+
+ onDelete(work.id)}>
+ Удалить
+
+
+
+
+
+ );
+}
+
+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 (
+ onSelectPass(pass)}
+ >
+ {formatDate(pass.startMs)} {formatTime(pass.startMs)}
+ {formatDuration(pass.endMs - pass.startMs)}
+
+ );
+ })}
+
+ )}
+
+ )}
+
+ {target && selectedPass && (
+
+ 3 · режим съёмки
+
+ {modes.map((mode) => (
+ onModeChange(mode.id)}>
+ {mode.label}
+
+ ))}
+
+
+ )}
+
+
+ + Добавить съёмку в план
+
+
+ );
+}
+
+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 (
+
onSelect({ station: option.station, pass })}
+ >
+ {formatDateTime(pass.startMs)}-{formatTime(pass.endMs)}
+ {formatDuration(pass.endMs - pass.startMs)}
+
+ );
+ })
+ )}
+
+ ))}
+
+
+
+ + Добавить сброс в план
+
+
+ );
+}
+
+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 (
+
+ {label}
+
+ change(-3_600_000)}>−ч
+ change(-5 * 60_000)}>−5м
+ {formatDateTime(value)}
+ change(5 * 60_000)}>+5м
+ change(3_600_000)}>+ч
+
+
+ );
+}
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 (
+
+
+ Редактируется
+ {currentPlatform?.name ?? selectedSpacecraftId ?? "КА не выбран"}
+
+ {selectedPlan ? `${selectedPlan.planId} · КПП ${selectedPlan.kppId}` : "План не выбран"}
+
+
+
+
+
+
+ 0 ? "var(--now)" : undefined} />
+
+
+
+
+ Контекст других КА
+
+ {contextPlatforms.length === 0 ? (
+
Нет других КА в выбранном интервале.
+ ) : (
+ contextPlatforms.map((platform) => {
+ const enabled = contextSpacecraftIds.has(platform.spacecraftId);
+ return (
+
onToggleContext(platform.spacecraftId)}
+ >
+ {enabled ? "✓" : ""}
+ {platform.name ?? platform.noradId ?? platform.spacecraftId}
+ {platform.planCount}
+
+ );
+ })
+ )}
+
+
+
+ );
+}
+
+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 (
+
+
+
+
+
{platformLabel}
+
+ {selectedPlan ? `${selectedPlan.planId} · ${selectedPlan.status}` : "Выберите план на timeline"}
+
+
+
+
+
+ onSetRightMode("shoot")}
+ disabled={!selectedSpacecraftId}
+ >
+ + Съёмка
+
+ onSetRightMode("downlink")}
+ disabled={!selectedSpacecraftId}
+ >
+ + Сброс
+
+
+
+
+
+
+ onPan(-0.25)} title="Назад по времени">◀
+ onZoom(1.4)} title="Отдалить">−
+ {windowLabel || formatDuration(0)}
+ onZoom(0.7)} title="Приблизить">+
+ onPan(0.25)} title="Вперёд по времени">▶
+
+
+
+
+ ↶
+
+
+ ↷
+
+
+
+
0 ? "tgu-editor-button--warning" : ""}`}
+ type="button"
+ onClick={onCheck}
+ disabled={checking || !selectedSpacecraftId}
+ >
+ {checking ? "Проверка..." : conflictCount > 0 ? `Конфликты · ${conflictCount}` : "Проверить"}
+
+
+
+ {applying ? "Применение..." : `Применить${addedCount > 0 ? ` · +${addedCount}` : ""}`}
+
+
+ );
+}
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" && (
+
+ {rightMode === "inspect" && selectedWork && (
+ {
+ changeDraft((current) => deleteWork(current, workId));
+ setSelectedWorkId(undefined);
+ setRightMode("none");
+ }}
+ onDuplicate={(workId) => {
+ const id = nextWorkId(idCounterRef);
+ changeDraft((current) => duplicateWork(current, workId, id));
+ setSelectedWorkId(id);
+ }}
+ onClose={() => {
+ setSelectedWorkId(undefined);
+ setRightMode("none");
+ }}
+ />
+ )}
+ {rightMode === "inspect" && !selectedWork && Включение не выбрано.
}
+ {rightMode === "shoot" && (
+ {
+ setRightMode("none");
+ setTarget(undefined);
+ setSelectedShootPass(undefined);
+ }}
+ onClearTarget={() => {
+ setTarget(undefined);
+ setSelectedShootPass(undefined);
+ }}
+ />
+ )}
+ {rightMode === "downlink" && (
+ {
+ setRightMode("none");
+ setSelectedDownlink(undefined);
+ }}
+ />
+ )}
+
+ )}
+
+
+ );
+}
+
+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
+ onTabChange("editor")}
+ >
+ Редактор
+
diff --git a/services/pcp-tgu-ops-ui/src/main.tsx b/services/pcp-tgu-ops-ui/src/main.tsx
index f3df47a..e676c79 100644
--- a/services/pcp-tgu-ops-ui/src/main.tsx
+++ b/services/pcp-tgu-ops-ui/src/main.tsx
@@ -2,7 +2,9 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./styles/theme.css";
+import "./styles/editor-tokens.css";
import "./styles/tgu-planning.css";
+import "./styles/tgu-editor.css";
createRoot(document.getElementById("root")!).render(
diff --git a/services/pcp-tgu-ops-ui/src/model/timelineTypes.ts b/services/pcp-tgu-ops-ui/src/model/timelineTypes.ts
index 1190c33..cfc1137 100644
--- a/services/pcp-tgu-ops-ui/src/model/timelineTypes.ts
+++ b/services/pcp-tgu-ops-ui/src/model/timelineTypes.ts
@@ -22,6 +22,8 @@ export type TimelineRange = {
toMs: number;
};
+export type TguActiveTab = "timeline" | "map" | "editor";
+
export type TimelineRow = {
spacecraftId: string;
platform?: TguPlatformUi;
diff --git a/services/pcp-tgu-ops-ui/src/styles/editor-tokens.css b/services/pcp-tgu-ops-ui/src/styles/editor-tokens.css
new file mode 100644
index 0000000..c24f05e
--- /dev/null
+++ b/services/pcp-tgu-ops-ui/src/styles/editor-tokens.css
@@ -0,0 +1,20 @@
+: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);
+ --grid: var(--line-soft);
+ --now: var(--warning);
+ --warn: var(--warning);
+ --mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
+ --t-optical: var(--accent);
+ --t-optical-dim: rgba(104, 195, 189, 0.18);
+ --t-radar: var(--warning);
+ --t-radar-dim: rgba(240, 173, 46, 0.16);
+ --t-combo: var(--danger);
+ --t-combo-dim: rgba(214, 69, 69, 0.16);
+}
diff --git a/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css b/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css
new file mode 100644
index 0000000..87a0f69
--- /dev/null
+++ b/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css
@@ -0,0 +1,746 @@
+.tgu-editor-workspace {
+ display: grid;
+ grid-template-rows: auto auto minmax(0, 1fr);
+ min-width: 0;
+ min-height: 0;
+ background: #121516;
+}
+
+.tgu-editor-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 0.65rem;
+ min-width: 0;
+ min-height: 3.55rem;
+ overflow-x: auto;
+ border-bottom: 1px solid var(--line);
+ padding: 0.65rem 0.8rem;
+ background: var(--bg-panel);
+}
+
+.tgu-editor-toolbar__identity {
+ display: flex;
+ align-items: center;
+ gap: 0.55rem;
+ min-width: 12rem;
+}
+
+.tgu-editor-toolbar__title {
+ overflow: hidden;
+ max-width: 14rem;
+ color: var(--text);
+ font-size: 0.88rem;
+ font-weight: 700;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.tgu-editor-toolbar__meta,
+.tgu-editor-rail__meta,
+.tgu-editor-panel__hint,
+.tgu-editor-panel__id {
+ color: var(--text-dim);
+ font-family: var(--mono);
+ font-size: 0.68rem;
+}
+
+.tgu-editor-toolbar__group {
+ display: flex;
+ gap: 0.35rem;
+}
+
+.tgu-editor-toolbar__spacer {
+ flex: 1;
+ min-width: 1rem;
+}
+
+.tgu-editor-type-dot {
+ width: 0.62rem;
+ height: 0.62rem;
+ border-radius: 3px;
+ background: var(--t-optical);
+ box-shadow: 0 0 12px rgba(104, 195, 189, 0.4);
+}
+
+.tgu-editor-window-control {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.12rem;
+ border: 1px solid var(--line);
+ border-radius: 7px;
+ padding: 0.12rem;
+ background: #101214;
+}
+
+.tgu-editor-window-control button {
+ display: grid;
+ place-items: center;
+ width: 1.7rem;
+ height: 1.7rem;
+ border: 0;
+ border-radius: 5px;
+ background: transparent;
+ color: var(--text-muted);
+}
+
+.tgu-editor-window-control span {
+ min-width: 4.2rem;
+ color: var(--text-dim);
+ font-family: var(--mono);
+ font-size: 0.64rem;
+ text-align: center;
+}
+
+.tgu-editor-button--warning {
+ color: #ffe7b5;
+}
+
+.tgu-editor-notice {
+ margin: 0.55rem 0.8rem 0;
+}
+
+.tgu-editor-layout {
+ display: grid;
+ min-width: 0;
+ min-height: 0;
+}
+
+.tgu-editor-rail {
+ min-width: 0;
+ min-height: 0;
+ overflow: auto;
+ border-right: 1px solid var(--line);
+ background: var(--bg-panel);
+}
+
+.tgu-editor-rail__section {
+ display: grid;
+ gap: 0.65rem;
+ padding: 0.8rem;
+ border-bottom: 1px solid var(--line-soft);
+}
+
+.tgu-editor-eyebrow {
+ color: var(--text-dim);
+ font-family: var(--mono);
+ font-size: 0.62rem;
+ font-weight: 700;
+ text-transform: uppercase;
+}
+
+.tgu-editor-rail__title {
+ overflow-wrap: anywhere;
+ color: var(--text);
+ font-size: 0.9rem;
+ font-weight: 700;
+}
+
+.tgu-editor-stats {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.55rem;
+}
+
+.tgu-editor-stat {
+ display: grid;
+ gap: 0.1rem;
+}
+
+.tgu-editor-stat span {
+ color: var(--text);
+ font-family: var(--mono);
+ font-size: 1rem;
+ font-weight: 700;
+}
+
+.tgu-editor-stat small {
+ color: var(--text-dim);
+ font-size: 0.62rem;
+ text-transform: uppercase;
+}
+
+.tgu-editor-context-list {
+ display: grid;
+ gap: 0.28rem;
+ max-height: 21rem;
+ overflow: auto;
+}
+
+.tgu-editor-context-item {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ gap: 0.45rem;
+ align-items: center;
+ width: 100%;
+ border: 0;
+ border-radius: 6px;
+ padding: 0.42rem 0.45rem;
+ background: transparent;
+ color: var(--text-muted);
+ text-align: left;
+}
+
+.tgu-editor-context-item.is-enabled {
+ background: var(--bg-panel-2);
+ color: var(--text);
+}
+
+.tgu-editor-context-item__check {
+ display: grid;
+ place-items: center;
+ width: 0.95rem;
+ height: 0.95rem;
+ border: 1px solid var(--line);
+ border-radius: 4px;
+ color: var(--accent);
+ font-size: 0.68rem;
+}
+
+.tgu-editor-context-item span:nth-child(2) {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.tgu-editor-context-item small {
+ color: var(--text-dim);
+ font-family: var(--mono);
+ font-size: 0.65rem;
+}
+
+.tgu-editor-center {
+ display: grid;
+ grid-template-rows: 42% minmax(0, 1fr);
+ min-width: 0;
+ min-height: 0;
+ border-right: 1px solid var(--line);
+}
+
+.tgu-editor-map-stage {
+ position: relative;
+ min-height: 13rem;
+ overflow: hidden;
+ border-bottom: 1px solid var(--line);
+ background: #0b0d0e;
+}
+
+.tgu-editor-map-stage .tgu-2d-map {
+ min-height: 13rem;
+}
+
+.tgu-2d-map.is-picking {
+ cursor: crosshair;
+}
+
+.tgu-2d-map__target circle {
+ fill: rgba(240, 173, 46, 0.18);
+ stroke: var(--now);
+ stroke-width: 2;
+}
+
+.tgu-2d-map__target line {
+ stroke: var(--now);
+ stroke-width: 1.4;
+}
+
+.tgu-2d-map__target text {
+ fill: var(--text);
+ font-family: var(--mono);
+ font-size: 11px;
+ paint-order: stroke;
+ stroke: rgba(8, 16, 15, 0.85);
+ stroke-width: 3px;
+}
+
+.tgu-editor-timeline {
+ min-width: 0;
+ min-height: 0;
+ overflow: auto;
+ background: var(--bg-base);
+}
+
+.tgu-editor-timeline__surface {
+ position: relative;
+ min-width: 46rem;
+}
+
+.tgu-editor-timeline__axis-label,
+.tgu-editor-timeline__axis,
+.tgu-editor-timeline__lane-label {
+ position: absolute;
+ border-bottom: 1px solid var(--line);
+ background: var(--bg-panel);
+}
+
+.tgu-editor-timeline__axis-label {
+ left: 0;
+ top: 0;
+ height: 40px;
+ border-right: 1px solid var(--line);
+ padding: 1.05rem 0.65rem 0;
+ color: var(--text-dim);
+ font-family: var(--mono);
+ font-size: 0.62rem;
+ text-transform: uppercase;
+}
+
+.tgu-editor-timeline__axis {
+ top: 0;
+ height: 40px;
+}
+
+.tgu-editor-timeline__day,
+.tgu-editor-timeline__tick-label {
+ position: absolute;
+ border-left: 1px solid var(--line);
+ padding-left: 0.28rem;
+}
+
+.tgu-editor-timeline__day {
+ top: 0.18rem;
+ display: flex;
+ gap: 0.25rem;
+ align-items: center;
+ height: 1rem;
+}
+
+.tgu-editor-timeline__day strong {
+ color: var(--text);
+ font-size: 0.68rem;
+}
+
+.tgu-editor-timeline__day span,
+.tgu-editor-timeline__tick-label {
+ color: var(--text-dim);
+ font-family: var(--mono);
+ font-size: 0.58rem;
+}
+
+.tgu-editor-timeline__tick-label {
+ top: 1.2rem;
+ height: 1.15rem;
+}
+
+.tgu-editor-timeline__grid {
+ position: absolute;
+ bottom: 0;
+ pointer-events: none;
+}
+
+.tgu-editor-timeline__grid > div {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ border-left: 1px solid var(--grid);
+}
+
+.tgu-editor-timeline__now {
+ position: absolute;
+ bottom: 0;
+ z-index: 8;
+ border-left: 1.5px dashed var(--now);
+ pointer-events: none;
+}
+
+.tgu-editor-timeline__now span {
+ position: absolute;
+ left: 0.18rem;
+ top: 0;
+ border-radius: 3px;
+ padding: 0 0.24rem;
+ background: var(--now);
+ color: var(--bg-base);
+ font-family: var(--mono);
+ font-size: 0.55rem;
+ font-weight: 700;
+ text-transform: uppercase;
+}
+
+.tgu-editor-timeline__lane {
+ position: absolute;
+ left: 0;
+ right: 0;
+}
+
+.tgu-editor-timeline__lane-label {
+ left: 0;
+ top: 0;
+ bottom: 0;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ gap: 0.12rem;
+ border-right: 1px solid var(--line);
+ padding: 0 0.65rem;
+}
+
+.tgu-editor-timeline__lane-label span {
+ overflow: hidden;
+ color: var(--text);
+ font-size: 0.76rem;
+ font-weight: 700;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.tgu-editor-timeline__lane-label small {
+ color: var(--accent);
+ font-size: 0.62rem;
+}
+
+.tgu-editor-timeline__lane-body {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ border-bottom: 1px solid var(--line-soft);
+}
+
+.tgu-editor-work {
+ position: absolute;
+ top: 0.75rem;
+ display: flex;
+ align-items: start;
+ gap: 0.28rem;
+ height: calc(100% - 1.5rem);
+ overflow: hidden;
+ border: 1px solid var(--line);
+ border-left: 3px solid var(--accent);
+ border-radius: 5px;
+ padding: 0.25rem 0.45rem;
+ cursor: grab;
+ user-select: none;
+}
+
+.tgu-editor-work.is-selected {
+ outline: 1.5px solid var(--accent);
+ box-shadow: 0 0 0 3px rgba(104, 195, 189, 0.18);
+}
+
+.tgu-editor-work.has-conflict {
+ border-color: var(--now);
+}
+
+.tgu-editor-work--context {
+ top: 0.45rem;
+ height: calc(100% - 0.9rem);
+ border-top: 0;
+ border-right: 0;
+ border-bottom: 0;
+ opacity: 0.62;
+ pointer-events: none;
+}
+
+.tgu-editor-work__resize {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ width: 0.45rem;
+ cursor: ew-resize;
+}
+
+.tgu-editor-work__resize--left {
+ left: 0;
+}
+
+.tgu-editor-work__resize--right {
+ right: 0;
+}
+
+.tgu-editor-work__dot {
+ flex: none;
+ width: 0.42rem;
+ height: 0.42rem;
+ margin-top: 0.2rem;
+ border-radius: 2px;
+}
+
+.tgu-editor-work__label {
+ overflow: hidden;
+ color: var(--text);
+ font-family: var(--mono);
+ font-size: 0.62rem;
+ font-weight: 700;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.tgu-editor-work small {
+ position: absolute;
+ left: 0.5rem;
+ bottom: 0.2rem;
+ color: var(--text-dim);
+ font-family: var(--mono);
+ font-size: 0.56rem;
+}
+
+.tgu-editor-work__new {
+ flex: none;
+ border: 1px solid var(--now);
+ border-radius: 3px;
+ padding: 0 0.12rem;
+ color: var(--now);
+ font-family: var(--mono);
+ font-size: 0.48rem;
+}
+
+.tgu-editor-visibility-label {
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+}
+
+.tgu-editor-visibility-label i {
+ flex: none;
+ width: 0.5rem;
+ height: 0.5rem;
+ border-radius: 2px;
+}
+
+.tgu-editor-visibility-window {
+ position: absolute;
+ top: 0.32rem;
+ height: calc(100% - 0.64rem);
+ border: 1px solid var(--accent);
+ border-radius: 4px;
+}
+
+.tgu-editor-context-divider {
+ position: absolute;
+ left: 0;
+ height: 22px;
+ border-top: 1px solid var(--line);
+ border-bottom: 1px solid var(--line);
+ padding: 0.34rem 0.65rem 0;
+ background: var(--bg-base);
+ color: var(--text-dim);
+ font-family: var(--mono);
+ font-size: 0.56rem;
+ text-transform: uppercase;
+}
+
+.tgu-editor-side-panel {
+ min-width: 0;
+ min-height: 0;
+ overflow: auto;
+ background: var(--bg-panel);
+ padding: 0.9rem;
+}
+
+.tgu-editor-panel {
+ position: relative;
+ display: grid;
+ gap: 0.9rem;
+}
+
+.tgu-editor-panel__head,
+.tgu-editor-panel__section-head,
+.tgu-editor-panel__actions {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.6rem;
+}
+
+.tgu-editor-panel__head h2 {
+ margin: 0;
+ font-size: 0.95rem;
+}
+
+.tgu-editor-panel__section {
+ display: grid;
+ gap: 0.55rem;
+}
+
+.tgu-editor-panel__placeholder,
+.tgu-editor-target {
+ border: 1px dashed var(--line);
+ border-radius: 7px;
+ padding: 0.65rem;
+ background: var(--bg-panel-2);
+ color: var(--text-muted);
+ font-size: 0.78rem;
+ line-height: 1.45;
+}
+
+.tgu-editor-target {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ gap: 0.5rem;
+ align-items: center;
+ border-style: solid;
+}
+
+.tgu-editor-target i {
+ width: 0.6rem;
+ height: 0.6rem;
+ border: 2px solid var(--now);
+ border-radius: 50%;
+}
+
+.tgu-editor-target span {
+ overflow: hidden;
+ color: var(--text);
+ font-weight: 700;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.tgu-editor-target small {
+ color: var(--text-dim);
+ font-family: var(--mono);
+ font-size: 0.64rem;
+}
+
+.tgu-editor-chip-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+}
+
+.tgu-editor-chip-row button,
+.tgu-editor-pass-list button,
+.tgu-editor-station button {
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ background: var(--bg-panel-2);
+ color: var(--text-muted);
+}
+
+.tgu-editor-chip-row button {
+ padding: 0.38rem 0.55rem;
+ font-size: 0.72rem;
+ font-weight: 700;
+}
+
+.tgu-editor-chip-row button.is-selected,
+.tgu-editor-pass-list button.is-selected,
+.tgu-editor-station button.is-selected {
+ border-color: rgba(104, 195, 189, 0.58);
+ background: rgba(104, 195, 189, 0.14);
+ color: var(--text);
+}
+
+.tgu-editor-pass-list,
+.tgu-editor-station-list {
+ display: grid;
+ gap: 0.35rem;
+ max-height: 14rem;
+ overflow: auto;
+}
+
+.tgu-editor-pass-list button,
+.tgu-editor-station button {
+ display: flex;
+ justify-content: space-between;
+ gap: 0.5rem;
+ padding: 0.45rem 0.55rem;
+ text-align: left;
+}
+
+.tgu-editor-pass-list span,
+.tgu-editor-station span {
+ color: var(--text);
+ font-family: var(--mono);
+ font-size: 0.68rem;
+}
+
+.tgu-editor-pass-list small,
+.tgu-editor-station small {
+ color: var(--text-dim);
+ font-family: var(--mono);
+ font-size: 0.62rem;
+}
+
+.tgu-editor-station {
+ overflow: hidden;
+ border: 1px solid var(--line-soft);
+ border-radius: 7px;
+}
+
+.tgu-editor-station__head {
+ display: flex;
+ justify-content: space-between;
+ gap: 0.5rem;
+ padding: 0.5rem 0.6rem;
+ background: var(--bg-panel-2);
+}
+
+.tgu-editor-panel select {
+ width: 100%;
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ padding: 0.45rem 0.5rem;
+ background: #101214;
+ color: var(--text);
+}
+
+.tgu-editor-time-stepper {
+ display: grid;
+ gap: 0.25rem;
+}
+
+.tgu-editor-time-stepper > span {
+ color: var(--text-dim);
+ font-family: var(--mono);
+ font-size: 0.6rem;
+}
+
+.tgu-editor-time-stepper div {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+}
+
+.tgu-editor-time-stepper button {
+ flex: none;
+ border: 1px solid var(--line);
+ border-radius: 5px;
+ padding: 0.32rem 0.38rem;
+ background: var(--bg-panel-2);
+ color: var(--text-muted);
+ font-family: var(--mono);
+ font-size: 0.62rem;
+}
+
+.tgu-editor-time-stepper output {
+ flex: 1;
+ min-width: 0;
+ border: 1px solid var(--line);
+ border-radius: 5px;
+ padding: 0.34rem 0.4rem;
+ background: #101214;
+ color: var(--text);
+ font-family: var(--mono);
+ font-size: 0.66rem;
+ text-align: center;
+}
+
+.tgu-editor-panel__color {
+ position: absolute;
+ top: 0.2rem;
+ left: -0.9rem;
+ width: 0.18rem;
+ height: 1.4rem;
+ border-radius: 999px;
+}
+
+@media (max-width: 1180px) {
+ .tgu-editor-layout {
+ grid-template-columns: 1fr !important;
+ }
+
+ .tgu-editor-rail,
+ .tgu-editor-center,
+ .tgu-editor-side-panel {
+ border-right: 0;
+ border-bottom: 1px solid var(--line);
+ }
+
+ .tgu-editor-rail {
+ max-height: 16rem;
+ }
+
+ .tgu-editor-side-panel {
+ max-height: 24rem;
+ }
+}