feat(tgu-ops-ui): add spacecraft work editor tab
Port the TGU editor prototype into pcp-tgu-ops-ui as a new editor tab, add draft/conflict/pass logic with tests, extend the 2D map picking API, and include editor styles/theme aliases and task/prototype docs. Validation: npm run test; npm run build in services/pcp-tgu-ops-ui.
@@ -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.
|
||||
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,96 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Редактор включений КА — Mission Ops</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<style>
|
||||
:root {
|
||||
--bg-0: oklch(0.18 0.012 165);
|
||||
--bg-1: oklch(0.215 0.013 165);
|
||||
--bg-2: oklch(0.255 0.014 165);
|
||||
--bg-3: oklch(0.30 0.015 165);
|
||||
--line: oklch(0.34 0.014 165);
|
||||
--line-soft: oklch(0.28 0.013 165);
|
||||
--grid: oklch(0.26 0.012 165);
|
||||
|
||||
--ink: oklch(0.95 0.01 150);
|
||||
--ink-1: oklch(0.80 0.012 160);
|
||||
--ink-2: oklch(0.64 0.012 165);
|
||||
--ink-3: oklch(0.50 0.012 165);
|
||||
|
||||
--t-optical: oklch(0.80 0.14 158);
|
||||
--t-radar: oklch(0.82 0.13 82);
|
||||
--t-combo: oklch(0.74 0.15 6);
|
||||
--t-optical-dim: oklch(0.80 0.14 158 / 0.16);
|
||||
--t-radar-dim: oklch(0.82 0.13 82 / 0.16);
|
||||
--t-combo-dim: oklch(0.74 0.15 6 / 0.16);
|
||||
|
||||
--accent: oklch(0.82 0.13 200);
|
||||
--warn: oklch(0.80 0.15 55);
|
||||
--now: oklch(0.86 0.16 25);
|
||||
|
||||
--radius: 7px;
|
||||
--mono: "IBM Plex Mono", ui-monospace, monospace;
|
||||
--sans: "IBM Plex Sans", system-ui, sans-serif;
|
||||
--shadow: 0 8px 30px oklch(0.12 0.01 165 / 0.55);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; margin: 0; }
|
||||
body {
|
||||
font-family: var(--sans);
|
||||
background: var(--bg-0);
|
||||
color: var(--ink);
|
||||
font-size: 13px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow: hidden;
|
||||
}
|
||||
#root { height: 100vh; }
|
||||
|
||||
::-webkit-scrollbar { width: 11px; height: 11px; }
|
||||
::-webkit-scrollbar-track { background: var(--bg-0); }
|
||||
::-webkit-scrollbar-thumb { background: var(--bg-3); border-radius: 6px; border: 2px solid var(--bg-0); }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--line); }
|
||||
::-webkit-scrollbar-corner { background: var(--bg-0); }
|
||||
|
||||
.mono { font-family: var(--mono); }
|
||||
.tnum { font-variant-numeric: tabular-nums; }
|
||||
button { font-family: inherit; color: inherit; cursor: pointer; }
|
||||
a { -webkit-tap-highlight-color: transparent; }
|
||||
input, select { outline: none; }
|
||||
.hatch {
|
||||
background-image: repeating-linear-gradient(
|
||||
-45deg,
|
||||
oklch(0.86 0.16 25 / 0.55) 0px,
|
||||
oklch(0.86 0.16 25 / 0.55) 2px,
|
||||
transparent 2px,
|
||||
transparent 6px
|
||||
);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
|
||||
<script src="data.jsx"></script>
|
||||
<script src="world.js"></script>
|
||||
<script src="basemap.js"></script>
|
||||
<script type="text/babel" src="util.jsx"></script>
|
||||
<script type="text/babel" src="orbit.jsx"></script>
|
||||
<script type="text/babel" src="visibility.jsx"></script>
|
||||
<script type="text/babel" src="tabs.jsx"></script>
|
||||
<script type="text/babel" src="mapview.jsx"></script>
|
||||
<script type="text/babel" src="editortimeline.jsx"></script>
|
||||
<script type="text/babel" src="editorpanels.jsx"></script>
|
||||
<script type="text/babel" src="editorrail.jsx"></script>
|
||||
<script type="text/babel" src="editor.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,83 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Карта работ КА — Mission Ops</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<style>
|
||||
:root {
|
||||
--bg-0: oklch(0.18 0.012 165);
|
||||
--bg-1: oklch(0.215 0.013 165);
|
||||
--bg-2: oklch(0.255 0.014 165);
|
||||
--bg-3: oklch(0.30 0.015 165);
|
||||
--line: oklch(0.34 0.014 165);
|
||||
--line-soft: oklch(0.28 0.013 165);
|
||||
--grid: oklch(0.26 0.012 165);
|
||||
|
||||
--ink: oklch(0.95 0.01 150);
|
||||
--ink-1: oklch(0.80 0.012 160);
|
||||
--ink-2: oklch(0.64 0.012 165);
|
||||
--ink-3: oklch(0.50 0.012 165);
|
||||
|
||||
--t-optical: oklch(0.80 0.14 158);
|
||||
--t-radar: oklch(0.82 0.13 82);
|
||||
--t-combo: oklch(0.74 0.15 6);
|
||||
--t-optical-dim: oklch(0.80 0.14 158 / 0.16);
|
||||
--t-radar-dim: oklch(0.82 0.13 82 / 0.16);
|
||||
--t-combo-dim: oklch(0.74 0.15 6 / 0.16);
|
||||
|
||||
--accent: oklch(0.82 0.13 200);
|
||||
--warn: oklch(0.80 0.15 55);
|
||||
--now: oklch(0.86 0.16 25);
|
||||
|
||||
--radius: 7px;
|
||||
--mono: "IBM Plex Mono", ui-monospace, monospace;
|
||||
--sans: "IBM Plex Sans", system-ui, sans-serif;
|
||||
--shadow: 0 8px 30px oklch(0.12 0.01 165 / 0.55);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; margin: 0; }
|
||||
body {
|
||||
font-family: var(--sans);
|
||||
background: var(--bg-0);
|
||||
color: var(--ink);
|
||||
font-size: 13px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow: hidden;
|
||||
}
|
||||
#root { height: 100vh; }
|
||||
|
||||
::-webkit-scrollbar { width: 11px; height: 11px; }
|
||||
::-webkit-scrollbar-track { background: var(--bg-0); }
|
||||
::-webkit-scrollbar-thumb { background: var(--bg-3); border-radius: 6px; border: 2px solid var(--bg-0); }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--line); }
|
||||
::-webkit-scrollbar-corner { background: var(--bg-0); }
|
||||
|
||||
.mono { font-family: var(--mono); }
|
||||
.tnum { font-variant-numeric: tabular-nums; }
|
||||
button { font-family: inherit; color: inherit; cursor: pointer; }
|
||||
a { -webkit-tap-highlight-color: transparent; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
|
||||
<script src="data.jsx"></script>
|
||||
<script src="world.js"></script>
|
||||
<script src="basemap.js"></script>
|
||||
<script type="text/babel" src="util.jsx"></script>
|
||||
<script type="text/babel" src="orbit.jsx"></script>
|
||||
<script type="text/babel" src="tabs.jsx"></script>
|
||||
<script type="text/babel" src="mapview.jsx"></script>
|
||||
<script type="text/babel" src="mapdetails.jsx"></script>
|
||||
<script type="text/babel" src="mapapp.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100vh" }}>
|
||||
<TopTabs active="plans" mapHref="Map.html" />
|
||||
<div style={{ flex: 1, display: "grid", gridTemplateColumns: `268px 1fr ${selIv ? "340px" : "0px"}`, minHeight: 0, transition: "grid-template-columns .18s" }}>
|
||||
<Sidebar
|
||||
groups={D.GROUP_DEFS} scByGroup={scByGroup}
|
||||
typeFilter={typeFilter} setTypeFilter={setTypeFilter}
|
||||
groupShown={groupShown} toggleGroup={toggleGroup}
|
||||
scHidden={scHidden} toggleSc={toggleSc}
|
||||
search={search} setSearch={setSearch}
|
||||
counts={counts} selectedSc={selectedSc} onPickSc={onPickSc}
|
||||
/>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", minWidth: 0 }}>
|
||||
{/* ТУЛБАР */}
|
||||
<div style={{ height: 50, flex: "none", display: "flex", alignItems: "center", gap: 14, padding: "0 16px", borderBottom: "1px solid var(--line)", background: "var(--bg-1)" }}>
|
||||
{/* масштаб */}
|
||||
<div style={{ display: "flex", gap: 2, background: "var(--bg-0)", borderRadius: 7, padding: 3, border: "1px solid var(--line)" }}>
|
||||
{SCALES.map((s) => (
|
||||
<button key={s.id} onClick={() => { 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}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button onClick={() => 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)" }}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: 9, background: "var(--now)", boxShadow: "0 0 6px var(--now)" }} />Сейчас
|
||||
</button>
|
||||
|
||||
<div style={{ width: 1, height: 22, background: "var(--line)" }} />
|
||||
<WorksLegend />
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{/* статистика */}
|
||||
<div style={{ display: "flex", gap: 16, alignItems: "center" }}>
|
||||
<Stat label="АППАРАТОВ" v={stats.sc} />
|
||||
<Stat label="ПЛАНОВ" v={stats.iv} />
|
||||
<Stat label="ПЕРЕСЕЧЕНИЙ" v={stats.ov} accent={stats.ov > 0 ? "var(--now)" : null} />
|
||||
</div>
|
||||
|
||||
{!allOn && (
|
||||
<button onClick={showAll} style={{ padding: "6px 11px", borderRadius: 6, border: "1px solid var(--line)", background: "var(--bg-2)", fontSize: 11.5, color: "var(--ink-1)" }}>Показать все</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<div style={{ flex: 1, display: "grid", placeItems: "center", color: "var(--ink-3)", fontSize: 13 }}>
|
||||
Нет аппаратов по текущим фильтрам.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ flex: 1, minHeight: 0 }} onClick={() => {}}>
|
||||
<Timeline
|
||||
rows={rows} pxPerMs={pxPerMs} rangeStart={rangeStart} rangeEnd={rangeEnd}
|
||||
now={D.NOW} selectedIv={selectedIv} onSelectIv={setSelectedIv}
|
||||
lineageSet={lineageSet} byId={byId} scrollSignal={scrollSignal}
|
||||
timelineCollapsed={timelineCollapsed} toggleCollapse={toggleCollapse}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Details iv={selIv} sc={selSc} group={selGroup} byId={byId} onSelectIv={setSelectedIv} onClose={() => setSelectedIv(null)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 11 }}>
|
||||
<span className="mono" style={{ fontSize: 8.5, color: "var(--ink-3)", letterSpacing: ".08em" }}>РАБОТЫ:</span>
|
||||
{items.map((it) => (
|
||||
<div key={it.k} style={{ display: "flex", alignItems: "center", gap: 5 }}>
|
||||
<span style={{
|
||||
width: 11, height: 9, borderRadius: 2, flex: "none",
|
||||
background: it.stripes
|
||||
? "linear-gradient(90deg, var(--t-optical) 0 33%, var(--t-radar) 33% 66%, var(--t-combo) 66% 100%)"
|
||||
: it.dashed ? "transparent" : it.fill,
|
||||
border: it.dashed ? "1px dashed var(--ink-3)" : "none",
|
||||
}} />
|
||||
<span style={{ fontSize: 10.5, color: "var(--ink-2)" }}>{it.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, v, accent }) { return (
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", lineHeight: 1.1 }}>
|
||||
<span className="mono tnum" style={{ fontSize: 15, fontWeight: 600, color: accent || "var(--ink)" }}>{v}</span>
|
||||
<span className="mono" style={{ fontSize: 8, color: "var(--ink-3)", letterSpacing: ".08em" }}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
|
||||
@@ -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,
|
||||
};
|
||||
})();
|
||||
@@ -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"],
|
||||
};
|
||||
})();
|
||||
@@ -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 <span className="mono" style={{ fontSize: 9.5, fontWeight: 600, letterSpacing: ".06em", color: s.c, background: s.bg, padding: "2px 7px", borderRadius: 4 }}>{s.t}</span>;
|
||||
}
|
||||
|
||||
function Field({ label, children, mono }) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
|
||||
<span className="mono" style={{ fontSize: 9, color: "var(--ink-3)", letterSpacing: ".08em" }}>{label}</span>
|
||||
<span className={mono ? "mono tnum" : ""} style={{ fontSize: 12.5, color: "var(--ink)" }}>{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Details({ iv, sc, group, byId, onSelectIv, onClose }) {
|
||||
if (!iv) {
|
||||
return (
|
||||
<div style={{ height: "100%", background: "var(--bg-1)", borderLeft: "1px solid var(--line)", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 10, padding: 24, textAlign: "center" }}>
|
||||
<svg width="34" height="34" viewBox="0 0 24 24" fill="none" style={{ opacity: .3 }}><rect x="3" y="6" width="18" height="3.4" rx="1.5" stroke="var(--ink-2)" strokeWidth="1.6" /><rect x="3" y="13" width="11" height="3.4" rx="1.5" stroke="var(--ink-2)" strokeWidth="1.6" /></svg>
|
||||
<div style={{ fontSize: 12, color: "var(--ink-3)", maxWidth: 180 }}>Выберите план на таймлайне, чтобы увидеть его работы и цепочку ревизий.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<button key={id} onClick={() => 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)",
|
||||
}}>
|
||||
<span className="mono tnum" style={{ fontSize: 9.5, color: "var(--ink-3)", flex: "none" }}>{it.id}</span>
|
||||
<span style={{ flex: 1, fontSize: 11.5, color: isSel ? "var(--ink)" : "var(--ink-1)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{it.title}</span>
|
||||
<span className="mono" style={{ fontSize: 8.5, color: "var(--ink-3)", border: "1px solid var(--line)", borderRadius: 3, padding: "0 4px", flex: "none" }}>r{it.rev}</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ height: "100%", background: "var(--bg-1)", borderLeft: "1px solid var(--line)", display: "flex", flexDirection: "column" }}>
|
||||
<div style={{ padding: "14px 16px 12px", borderBottom: "1px solid var(--line-soft)" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 8 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
|
||||
<span style={{ width: 10, height: 10, borderRadius: 3, background: col, boxShadow: `0 0 8px ${col}`, flex: "none" }} />
|
||||
<span className="mono" style={{ fontSize: 10, color: "var(--ink-3)" }}>{iv.id}</span>
|
||||
</div>
|
||||
<button onClick={onClose} style={{ background: "none", border: "none", color: "var(--ink-3)", fontSize: 18, lineHeight: 1, padding: 0 }}>×</button>
|
||||
</div>
|
||||
<div style={{ fontSize: 17, fontWeight: 700, marginTop: 8, lineHeight: 1.2 }}>{iv.title}</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 9 }}>
|
||||
<StatusBadge status={iv.status} />
|
||||
<span className="mono" style={{ fontSize: 10.5, color: "var(--ink-2)" }}>{sc.short} · {group.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "14px 16px 20px", display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
{/* Время */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
|
||||
<Field label="НАЧАЛО" mono>{fmtDateTime(iv.start)}</Field>
|
||||
<Field label="КОНЕЦ" mono>{fmtDateTime(iv.end)}</Field>
|
||||
<Field label="ДЛИТЕЛЬНОСТЬ" mono>{fmtDur(dur)}</Field>
|
||||
<Field label="РАБОТ В ПЛАНЕ" mono>{iv.works.length}</Field>
|
||||
</div>
|
||||
|
||||
<div style={{ height: 1, background: "var(--line-soft)" }} />
|
||||
|
||||
{/* Аппарат */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<span className="mono" style={{ fontSize: 9, color: "var(--ink-3)", letterSpacing: ".08em" }}>АППАРАТ</span>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 9 }}>
|
||||
<TypeDot type={sc.type} size={9} />
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>{sc.name}</div>
|
||||
<div style={{ fontSize: 10.5, color: "var(--ink-3)" }}>{D.TYPES[sc.type].label} · {sc.orbit}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Field label="ИСТОЧНИК ПЛАНА">{iv.author}</Field>
|
||||
</div>
|
||||
|
||||
<div style={{ height: 1, background: "var(--line-soft)" }} />
|
||||
|
||||
{/* Работы внутри плана */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 9 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<span className="mono" style={{ fontSize: 9, color: "var(--ink-3)", letterSpacing: ".08em" }}>РАБОТЫ В ПЛАНЕ</span>
|
||||
<span className="mono" style={{ fontSize: 9, color: "var(--ink-3)" }}>{iv.works.length}</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
|
||||
{iv.works.length === 0 && <div style={{ fontSize: 11, color: "var(--ink-3)" }}>Работы не заданы.</div>}
|
||||
{iv.works.map((wk) => {
|
||||
const fill = workFill(wk.kind, sc.type);
|
||||
const isService = wk.kind === "service";
|
||||
return (
|
||||
<div key={wk.id} style={{ display: "flex", alignItems: "center", gap: 8, padding: "5px 8px", borderRadius: 6, background: "var(--bg-2)" }}>
|
||||
<span style={{ width: 9, height: 9, borderRadius: 2, flex: "none", background: isService ? "transparent" : fill, border: isService ? `1px dashed ${fill}` : "none" }} />
|
||||
<span style={{ flex: 1, fontSize: 11.5, color: "var(--ink-1)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{wk.label}</span>
|
||||
<span className="mono tnum" style={{ fontSize: 10, color: "var(--ink-3)", flex: "none" }}>{fmtTime(wk.start)}–{fmtTime(wk.end)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ height: 1, background: "var(--line-soft)" }} />
|
||||
|
||||
{/* Родословная */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 9 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<span className="mono" style={{ fontSize: 9, color: "var(--ink-3)", letterSpacing: ".08em" }}>РОДОСЛОВНАЯ ПЛАНА</span>
|
||||
<span className="mono" style={{ fontSize: 9, color: "var(--ink-3)" }}>{lin.chain.length} ревизий</span>
|
||||
</div>
|
||||
|
||||
{lin.ancestors.length > 0 && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
|
||||
<span style={{ fontSize: 9.5, color: "var(--ink-3)" }}>◀ Предки</span>
|
||||
{lin.ancestors.map(chainRow)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
|
||||
<span style={{ fontSize: 9.5, color: "var(--accent)" }}>● Текущий</span>
|
||||
{chainRow(iv.id)}
|
||||
</div>
|
||||
|
||||
{lin.descendants.length > 0 && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
|
||||
<span style={{ fontSize: 9.5, color: "var(--ink-3)" }}>▶ Потомки</span>
|
||||
{lin.descendants.map(chainRow)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lin.ancestors.length === 0 && lin.descendants.length === 0 && (
|
||||
<div style={{ fontSize: 11, color: "var(--ink-3)", padding: "4px 0" }}>Это единственная ревизия плана — без предков и потомков.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: "12px 16px", borderTop: "1px solid var(--line-soft)" }}>
|
||||
<a href={`Map.html?plan=${iv.id}`} style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 7, padding: "9px 0", borderRadius: 7, border: "1px solid var(--line)", background: "var(--bg-2)", color: "var(--ink-1)", fontSize: 12, fontWeight: 600, textDecoration: "none" }}>
|
||||
Показать на карте
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2" /><path d="M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18" stroke="currentColor" strokeWidth="1.4" fill="none" /></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { Details });
|
||||
@@ -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 (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100vh" }}>
|
||||
<TopTabs active="editor" />
|
||||
<EditorToolbar {...{ activeSc, scById, activeScId, onPickSc, planId, plan, onPickPlan, planById,
|
||||
undo, redo, canUndo: hist.past.length > 0, canRedo: hist.future.length > 0,
|
||||
runCheck, checking, conflictCount, applyDraft, addedCount, zoom, panBy, win, setRightMode, rightMode }} />
|
||||
<div style={{ flex: 1, display: "grid", gridTemplateColumns: `230px 1fr ${rightMode === "none" ? "0px" : "340px"}`, minHeight: 0 }}>
|
||||
<EditorRail {...{ activeSc, plan, draft, conflictCount, ctxList, ctxScope, setCtxScope, ctxSearch, setCtxSearch,
|
||||
otherShown, toggleOther, scById, setRightMode, basemap, setBasemap }} />
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", minWidth: 0, borderRight: rightMode === "none" ? "none" : "1px solid var(--line)" }}>
|
||||
{/* карта (для съёмки и контекста) */}
|
||||
<div style={{ height: "44%", position: "relative", borderBottom: "1px solid var(--line)", background: "#0a1410" }}>
|
||||
<MapView scList={[activeSc]} selectedPlan={{ scId: activeScId, works: draft }}
|
||||
window={win} now={D.NOW} layers={{ tracks: true, swath: true, shoot: true, downlink: true, stations: true, terminator: true }}
|
||||
onSelectSc={() => {}} paramsBy={paramsByOf([activeSc])} scById={scById}
|
||||
basemap={basemap} pickMode={rightMode === "shoot"} onPickPoint={onPickPoint} targets={target ? [target] : []} />
|
||||
{rightMode === "shoot" && (
|
||||
<div className="mono" style={{ position: "absolute", top: 10, left: "50%", transform: "translateX(-50%)", background: "var(--now)", color: "var(--bg-0)", fontSize: 11, fontWeight: 600, padding: "4px 12px", borderRadius: 20, boxShadow: "0 0 12px var(--now)", pointerEvents: "none" }}>
|
||||
Кликните точку съёмки на карте
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* таймлайн */}
|
||||
<div style={{ flex: 1, minHeight: 0 }} onClick={() => { if (rightMode === "inspect") { setSelected(null); setRightMode("none"); } }}>
|
||||
<EditorTimeline {...{ activeSc, draft, selectedWorkId: selected, onSelectWork, t0: win.t0, t1: win.t1, now: D.NOW, onChangeWork, otherLanes, visTracks, conflicts }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rightMode !== "none" && (
|
||||
<div style={{ background: "var(--bg-1)", overflowY: "auto", padding: "16px 16px 28px" }}>
|
||||
{rightMode === "inspect" && selWork && <WorkInspector work={selWork} activeSc={activeSc} conflicts={conflicts} onUpdate={updateWork} onDelete={deleteWork} onDuplicate={duplicateWork} onClose={() => { setSelected(null); setRightMode("none"); }} />}
|
||||
{rightMode === "inspect" && !selWork && <div style={{ color: "var(--ink-3)", fontSize: 12 }}>Включение не выбрано.</div>}
|
||||
{rightMode === "shoot" && <ShootPanel {...{ activeSc, target, passes, selectedPass: selPass, setSelectedPass: setSelPass, modeId, setModeId, onAdd: addShoot, onClose: () => { setRightMode("none"); setTarget(null); setSelPass(null); }, onClearTarget: () => { setTarget(null); setSelPass(null); } }} />}
|
||||
{rightMode === "downlink" && <DownlinkPanel {...{ activeSc, stationOptions, selected: dlSel, setSelected: setDlSel, onAdd: addDownlink, onClose: () => { setRightMode("none"); setDlSel(null); } }} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function paramsByOf(scs) { const m = {}; scs.forEach((s) => { if (s) m[s.id] = orbitParams(s); }); return m; }
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(<EditorApp />);
|
||||
@@ -0,0 +1,232 @@
|
||||
// Правая контекстная панель редактора: инспектор выбранного включения,
|
||||
// флоу добавления съёмки (цель+проход+режим) и сброса (станция+сеанс).
|
||||
const { useState: useStateEP, useMemo: useMemoEP } = React;
|
||||
|
||||
function Sect({ title, right, children }) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<span className="mono" style={{ fontSize: 9, color: "var(--ink-3)", letterSpacing: ".08em" }}>{title}</span>
|
||||
{right}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<button onClick={onClick} disabled={disabled} style={{
|
||||
...(styles[kind] || styles.ghost), padding: "7px 11px", borderRadius: 6, fontSize: 12, fontWeight: 600,
|
||||
opacity: disabled ? 0.4 : 1, cursor: disabled ? "not-allowed" : "pointer", flex: flex ? 1 : "none",
|
||||
}}>{children}</button>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<div>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 7 }}>
|
||||
<span style={{ width: 9, height: 9, borderRadius: 2, background: col }} />
|
||||
<span style={{ fontSize: 14, fontWeight: 700 }}>{kindLabel(work.kind)}</span>
|
||||
{work.isNew && <span className="mono" style={{ fontSize: 8, color: "var(--now)", border: "1px solid var(--now)", borderRadius: 3, padding: "0 3px" }}>NEW</span>}
|
||||
</span>
|
||||
<button onClick={onClose} style={{ background: "none", border: "none", color: "var(--ink-3)", fontSize: 17, padding: 0 }}>×</button>
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 10, color: "var(--ink-3)", marginTop: 3 }}>{work.id}</div>
|
||||
</div>
|
||||
|
||||
{confl.length > 0 && (
|
||||
<div style={{ background: "oklch(0.86 0.16 25 / 0.12)", border: "1px solid color-mix(in oklch, var(--now) 50%, transparent)", borderRadius: 6, padding: "8px 10px" }}>
|
||||
<div className="mono" style={{ fontSize: 9, color: "var(--now)", letterSpacing: ".06em", marginBottom: 4 }}>⚠ КОНФЛИКТ (сервис проверки)</div>
|
||||
{confl.map((c, i) => <div key={i} style={{ fontSize: 11, color: "var(--ink-1)" }}>{c}</div>)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Sect title="ВРЕМЯ">
|
||||
<TimeStepper label="начало" value={work.start} max={work.end - 60000} onChange={(v) => onUpdate(work.id, { start: v })} />
|
||||
<TimeStepper label="конец" value={work.end} min={work.start + 60000} onChange={(v) => onUpdate(work.id, { end: v })} />
|
||||
<div className="mono" style={{ fontSize: 10, color: "var(--ink-3)" }}>длительность: {fmtDur(work.end - work.start)}</div>
|
||||
</Sect>
|
||||
|
||||
{work.kind === "shoot" && (
|
||||
<Sect title="РЕЖИМ АППАРАТУРЫ">
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 5 }}>
|
||||
{modes.map((m) => (
|
||||
<button key={m.id} onClick={() => 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}</button>
|
||||
))}
|
||||
</div>
|
||||
{work.targetName && <div className="mono" style={{ fontSize: 10.5, color: "var(--ink-2)" }}>цель: {work.targetName}</div>}
|
||||
</Sect>
|
||||
)}
|
||||
|
||||
{work.kind === "downlink" && (
|
||||
<Sect title="СТАНЦИЯ ПРИЁМА">
|
||||
<select value={work.stationId || ""} onChange={(e) => 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) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select>
|
||||
</Sect>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Btn kind="ghost" flex onClick={() => onDuplicate(work.id)}>Дублировать</Btn>
|
||||
<Btn kind="danger" flex onClick={() => onDelete(work.id)}>Удалить</Btn>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Подстройка времени степперами (±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 (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span className="mono" style={{ fontSize: 8.5, color: "var(--ink-3)" }}>{label}</span>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||
<StepBtn onClick={() => nudge(-H)}>−ч</StepBtn>
|
||||
<StepBtn onClick={() => nudge(-5 * M)}>−5м</StepBtn>
|
||||
<span className="mono tnum" style={{ flex: 1, textAlign: "center", fontSize: 11.5, color: "var(--ink)", background: "var(--bg-0)", border: "1px solid var(--line)", borderRadius: 5, padding: "5px 4px" }}>{fmtDateTime(value)}</span>
|
||||
<StepBtn onClick={() => nudge(5 * M)}>+5м</StepBtn>
|
||||
<StepBtn onClick={() => nudge(H)}>+ч</StepBtn>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function StepBtn({ children, onClick }) {
|
||||
return <button onClick={onClick} style={{ flex: "none", padding: "5px 6px", borderRadius: 5, border: "1px solid var(--line)", background: "var(--bg-2)", color: "var(--ink-1)", fontFamily: "var(--mono)", fontSize: 10, fontWeight: 600 }}>{children}</button>;
|
||||
}
|
||||
|
||||
// ---- Флоу добавления СЪЁМКИ ----
|
||||
function ShootPanel({ activeSc, target, passes, selectedPass, setSelectedPass, modeId, setModeId, onAdd, onClose, onClearTarget }) {
|
||||
const modes = SENSOR_MODES[activeSc.type] || [];
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<PanelHead title="Новая съёмка" onClose={onClose} />
|
||||
<Sect title="1 · ЦЕЛЬ СЪЁМКИ" right={target && <button onClick={onClearTarget} style={{ background: "none", border: "none", color: "var(--ink-3)", fontSize: 10 }}>сбросить</button>}>
|
||||
{!target ? (
|
||||
<div style={{ fontSize: 11.5, color: "var(--ink-2)", lineHeight: 1.5, background: "var(--bg-2)", borderRadius: 6, padding: "9px 11px", border: "1px dashed var(--line)" }}>
|
||||
Кликните точку на карте слева — запросим расписание видимости этой точки аппаратом {activeSc.short}.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, background: "var(--bg-2)", borderRadius: 6, padding: "8px 10px" }}>
|
||||
<span style={{ width: 9, height: 9, borderRadius: 9, border: "2px solid var(--now)", flex: "none" }} />
|
||||
<span style={{ flex: 1, fontSize: 12, fontWeight: 600 }}>{target.name}</span>
|
||||
<span className="mono" style={{ fontSize: 10, color: "var(--ink-3)" }}>{target.lat.toFixed(2)}, {target.lon.toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
</Sect>
|
||||
|
||||
{target && (
|
||||
<Sect title="2 · ОКНО ВИДИМОСТИ (ПРОХОД)">
|
||||
{passes.length === 0 ? (
|
||||
<div style={{ fontSize: 11.5, color: "var(--ink-3)" }}>В окне планирования проходов над целью нет.</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4, maxHeight: 188, overflowY: "auto" }}>
|
||||
{passes.map((ps, i) => {
|
||||
const sel = selectedPass && selectedPass.start === ps.start;
|
||||
return (
|
||||
<button key={i} onClick={() => 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)" }}>
|
||||
<span className="mono tnum" style={{ fontSize: 11, color: "var(--ink-1)", flex: 1 }}>{fmtDate(ps.start)} {fmtTime(ps.start)}</span>
|
||||
<span className="mono" style={{ fontSize: 9.5, color: "var(--ink-3)" }}>{passDur(ps)}</span>
|
||||
<QualityDot q={ps.quality} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Sect>
|
||||
)}
|
||||
|
||||
{target && selectedPass && (
|
||||
<Sect title="3 · РЕЖИМ СЪЁМКИ">
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 5 }}>
|
||||
{modes.map((m) => (
|
||||
<button key={m.id} onClick={() => 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}</button>
|
||||
))}
|
||||
</div>
|
||||
</Sect>
|
||||
)}
|
||||
|
||||
<Btn kind="primary" disabled={!target || !selectedPass || !modeId} onClick={onAdd}>+ Добавить съёмку в план</Btn>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Флоу добавления СБРОСА ----
|
||||
function DownlinkPanel({ activeSc, stationOptions, selected, setSelected, onAdd, onClose }) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<PanelHead title="Новый сброс на НКПОР" onClose={onClose} />
|
||||
<div style={{ fontSize: 11.5, color: "var(--ink-2)", lineHeight: 1.5 }}>
|
||||
Расписание сеансов связи аппарата {activeSc.short} со станциями приёма. Выберите станцию и сеанс.
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10, maxHeight: 460, overflowY: "auto" }}>
|
||||
{stationOptions.map((so) => (
|
||||
<div key={so.station.id} style={{ border: "1px solid var(--line-soft)", borderRadius: 8, overflow: "hidden", flexShrink: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 7, padding: "7px 10px", background: "var(--bg-2)" }}>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24"><path d="M0,-6 L5,4 L-5,4 Z" transform="translate(12 13)" fill="var(--accent)" /></svg>
|
||||
<span style={{ flex: 1, fontSize: 12.5, fontWeight: 600 }}>{so.station.name}</span>
|
||||
<span className="mono" style={{ fontSize: 9.5, color: "var(--ink-3)" }}>{so.passes.length} сеанс.</span>
|
||||
</div>
|
||||
{so.passes.length === 0 ? (
|
||||
<div style={{ fontSize: 10.5, color: "var(--ink-3)", padding: "6px 10px" }}>нет сеансов в окне</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||
{so.passes.slice(0, 5).map((ps, i) => {
|
||||
const sel = selected && selected.station.id === so.station.id && selected.pass.start === ps.start;
|
||||
return (
|
||||
<button key={i} onClick={() => 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" }}>
|
||||
<span className="mono tnum" style={{ fontSize: 10.5, color: "var(--ink-1)", flex: 1 }}>{fmtDate(ps.start)} {fmtTime(ps.start)}–{fmtTime(ps.end)}</span>
|
||||
<span className="mono" style={{ fontSize: 9, color: "var(--ink-3)" }}>{passDur(ps)}</span>
|
||||
{sel && <span style={{ color: "var(--accent)", fontSize: 12 }}>✓</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Btn kind="primary" disabled={!selected} onClick={onAdd}>+ Добавить сброс в план</Btn>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelHead({ title, onClose }) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 700 }}>{title}</span>
|
||||
<button onClick={onClose} style={{ background: "none", border: "none", color: "var(--ink-3)", fontSize: 17, padding: 0 }}>×</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function QualityDot({ q }) {
|
||||
const c = q > 0.6 ? "var(--t-optical)" : q > 0.3 ? "var(--t-radar)" : "var(--ink-3)";
|
||||
return <span title={`качество прохода ${(q * 100) | 0}%`} style={{ width: 7, height: 7, borderRadius: 9, background: c, flex: "none" }} />;
|
||||
}
|
||||
|
||||
Object.assign(window, { WorkInspector, ShootPanel, DownlinkPanel });
|
||||
@@ -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 (
|
||||
<div style={{ height: 52, flex: "none", display: "flex", alignItems: "center", gap: 8, padding: "0 12px", borderBottom: "1px solid var(--line)", background: "var(--bg-1)", overflow: "hidden" }}>
|
||||
{/* выбор КА */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 7 }}>
|
||||
<TypeDot type={activeSc.type} size={9} />
|
||||
<select value={activeScId} onChange={(e) => 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) => (
|
||||
<optgroup key={g.id} label={g.name}>
|
||||
{D.spacecraft.filter((s) => s.groupId === g.id).map((s) => <option key={s.id} value={s.id}>{s.short}</option>)}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{/* выбор плана */}
|
||||
<select value={planId || ""} onChange={(e) => 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) => <option key={pl.id} value={pl.id}>{pl.id} · r{pl.rev}</option>)}
|
||||
</select>
|
||||
|
||||
{/* добавить включение */}
|
||||
<div style={{ display: "flex", gap: 6, marginLeft: 4 }}>
|
||||
<button onClick={() => setRightMode("shoot")} style={addBtn(props.rightMode === "shoot")}>
|
||||
<span style={{ fontSize: 14, lineHeight: 1 }}>+</span> Съёмка
|
||||
</button>
|
||||
<button onClick={() => setRightMode("downlink")} style={addBtn(props.rightMode === "downlink", "var(--accent)")}>
|
||||
<span style={{ fontSize: 14, lineHeight: 1 }}>+</span> Сброс
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{/* зум / пан окна */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 2, background: "var(--bg-0)", border: "1px solid var(--line)", borderRadius: 7, padding: 2 }}>
|
||||
<IconBtn onClick={() => panBy(-0.25)} title="назад">◀</IconBtn>
|
||||
<IconBtn onClick={() => zoom(1.4)} title="отдалить">−</IconBtn>
|
||||
<span className="mono" style={{ fontSize: 9.5, color: "var(--ink-3)", padding: "0 4px", minWidth: 64, textAlign: "center" }}>{fmtDur(win.t1 - win.t0)}</span>
|
||||
<IconBtn onClick={() => zoom(0.7)} title="приблизить">+</IconBtn>
|
||||
<IconBtn onClick={() => panBy(0.25)} title="вперёд">▶</IconBtn>
|
||||
</div>
|
||||
|
||||
{/* undo / redo */}
|
||||
<div style={{ display: "flex", gap: 2 }}>
|
||||
<IconBtn onClick={undo} disabled={!canUndo} title="отменить">↶</IconBtn>
|
||||
<IconBtn onClick={redo} disabled={!canRedo} title="вернуть">↷</IconBtn>
|
||||
</div>
|
||||
|
||||
{/* проверка */}
|
||||
<button onClick={runCheck} disabled={checking} style={{
|
||||
display: "flex", alignItems: "center", gap: 6, padding: "7px 11px", borderRadius: 6, fontSize: 11.5, fontWeight: 600,
|
||||
border: "1px solid var(--line)", background: "var(--bg-2)", color: conflictCount ? "var(--now)" : "var(--ink-1)" }}>
|
||||
{checking ? "Проверка…" : conflictCount ? `⚠ ${conflictCount}` : "✓ Проверить"}
|
||||
</button>
|
||||
|
||||
<button onClick={applyDraft} disabled={!addedCount && !canUndo} style={{
|
||||
padding: "7px 13px", borderRadius: 6, fontSize: 12, fontWeight: 700,
|
||||
border: "1px solid var(--accent)", background: "var(--accent)", color: "var(--bg-0)",
|
||||
opacity: (!addedCount && !canUndo) ? 0.45 : 1 }}>
|
||||
Применить{addedCount ? ` · +${addedCount}` : ""}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<button onClick={onClick} disabled={disabled} title={title} style={{
|
||||
width: 28, height: 28, borderRadius: 5, border: "none", background: "transparent",
|
||||
color: disabled ? "var(--ink-3)" : "var(--ink-1)", fontSize: 13, opacity: disabled ? 0.4 : 1,
|
||||
display: "grid", placeItems: "center", cursor: disabled ? "default" : "pointer" }}>{children}</button>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={{ background: "var(--bg-1)", borderRight: "1px solid var(--line)", display: "flex", flexDirection: "column", minHeight: 0 }}>
|
||||
{/* активный КА */}
|
||||
<div style={{ padding: "12px 14px", borderBottom: "1px solid var(--line-soft)" }}>
|
||||
<div className="mono" style={{ fontSize: 9, color: "var(--ink-3)", letterSpacing: ".1em", marginBottom: 7 }}>РЕДАКТИРУЕТСЯ</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<TypeDot type={activeSc.type} size={10} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13.5, fontWeight: 700 }}>{activeSc.name}</div>
|
||||
<div className="mono" style={{ fontSize: 10, color: "var(--ink-3)" }}>{D.TYPES[activeSc.type].label} · {group.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 14, marginTop: 10 }}>
|
||||
<RailStat label="ВКЛЮЧЕНИЙ" v={draft.length} />
|
||||
<RailStat label="СЪЁМКА" v={nShoot} c="var(--t-optical)" />
|
||||
<RailStat label="СБРОС" v={nDown} c="var(--accent)" />
|
||||
<RailStat label="КОНФЛ." v={conflictCount} c={conflictCount ? "var(--now)" : null} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* подложка карты */}
|
||||
<div style={{ padding: "10px 14px", borderBottom: "1px solid var(--line-soft)" }}>
|
||||
<div className="mono" style={{ fontSize: 9, color: "var(--ink-3)", letterSpacing: ".1em", marginBottom: 6 }}>ПОДЛОЖКА КАРТЫ</div>
|
||||
<div style={{ display: "flex", gap: 3, background: "var(--bg-0)", borderRadius: 7, padding: 3, border: "1px solid var(--line)" }}>
|
||||
{[["dark", "Тёмная"], ["osm", "Светлая"], ["schema", "Схема"]].map(([id, lab]) => (
|
||||
<button key={id} onClick={() => 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}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* контекст: другие КА */}
|
||||
<div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
||||
<div style={{ padding: "11px 14px 8px" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 8 }}>
|
||||
<span className="mono" style={{ fontSize: 9, color: "var(--ink-3)", letterSpacing: ".1em" }}>ДРУГИЕ КА · КОНТЕКСТ</span>
|
||||
<span className="mono" style={{ fontSize: 9, color: "var(--accent)" }}>{otherShown.size} вкл.</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 3, background: "var(--bg-0)", borderRadius: 7, padding: 3, border: "1px solid var(--line)", marginBottom: 8 }}>
|
||||
{[["group", "Своя группа"], ["all", "Все"]].map(([id, lab]) => (
|
||||
<button key={id} onClick={() => 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}</button>
|
||||
))}
|
||||
</div>
|
||||
{ctxScope === "all" && (
|
||||
<input value={ctxSearch} onChange={(e) => 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 }} />
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "0 8px 14px" }}>
|
||||
{ctxList.map((s) => {
|
||||
const on = otherShown.has(s.id);
|
||||
return (
|
||||
<button key={s.id} onClick={() => 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" }}>
|
||||
<span style={{ width: 15, height: 15, flex: "none", borderRadius: 4, border: `1.5px solid ${on ? "var(--accent)" : "var(--line)"}`, background: on ? "var(--accent)" : "transparent", display: "grid", placeItems: "center" }}>
|
||||
{on && <svg width="9" height="9" viewBox="0 0 10 10"><path d="M1.5 5.2 4 7.6 8.6 2.4" stroke="var(--bg-0)" strokeWidth="1.8" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>}
|
||||
</span>
|
||||
<TypeDot type={s.type} size={7} />
|
||||
<span className="mono" style={{ flex: 1, fontSize: 11, color: on ? "var(--ink-1)" : "var(--ink-3)" }}>{s.short}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function RailStat({ label, v, c }) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", lineHeight: 1.1 }}>
|
||||
<span className="mono tnum" style={{ fontSize: 16, fontWeight: 600, color: c || "var(--ink)" }}>{v}</span>
|
||||
<span className="mono" style={{ fontSize: 7.5, color: "var(--ink-3)", letterSpacing: ".06em" }}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { EditorToolbar, EditorRail });
|
||||
@@ -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) => (
|
||||
<React.Fragment key={key}>
|
||||
<div style={{ position: "absolute", left: 0, top, width: ET_LABEL_W, height: h, borderBottom: "1px solid var(--line-soft)", borderRight: "1px solid var(--line)", background: bg || "var(--bg-1)", display: "flex", flexDirection: "column", justifyContent: "center", padding: "0 10px", gap: 1 }}>
|
||||
{label}
|
||||
{sub}
|
||||
</div>
|
||||
<div style={{ position: "absolute", left: ET_LABEL_W, top, width: w, height: h, borderBottom: "1px solid var(--line-soft)" }}>
|
||||
{children}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
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 (
|
||||
<div ref={wrapRef} style={{ position: "relative", width: "100%", height: "100%", overflow: "auto", background: "var(--bg-0)" }}>
|
||||
<div style={{ position: "relative", width: ET_LABEL_W + w, height: Math.max(totalH, 200) }}>
|
||||
{/* фон-сетка по дням/часам */}
|
||||
<div style={{ position: "absolute", left: ET_LABEL_W, top: ET_AXIS_H, width: w, bottom: 0 }}>
|
||||
{ticks.ticks.map((tk, i) => (
|
||||
<div key={i} style={{ position: "absolute", left: X(tk.t), top: 0, bottom: 0, borderLeft: "1px solid var(--grid)" }} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ОСЬ */}
|
||||
<div style={{ position: "absolute", left: 0, top: 0, width: ET_LABEL_W, height: ET_AXIS_H, background: "var(--bg-1)", borderRight: "1px solid var(--line)", borderBottom: "1px solid var(--line)", display: "flex", alignItems: "flex-end", padding: "0 10px 6px" }}>
|
||||
<span className="mono" style={{ fontSize: 9, color: "var(--ink-3)", letterSpacing: ".08em" }}>ВРЕМЯ (UTC)</span>
|
||||
</div>
|
||||
<div style={{ position: "absolute", left: ET_LABEL_W, top: 0, width: w, height: ET_AXIS_H, background: "var(--bg-1)", borderBottom: "1px solid var(--line)" }}>
|
||||
{days.map((d, i) => (
|
||||
<div key={"d" + i} style={{ position: "absolute", left: X(d), top: 2, borderLeft: "1px solid var(--line)", paddingLeft: 5, height: 14, display: "flex", alignItems: "center", gap: 4 }}>
|
||||
<span style={{ fontSize: 10, fontWeight: 600, color: "var(--ink-1)" }}>{pad(new Date(d).getUTCDate())}</span>
|
||||
<span style={{ fontSize: 9, color: "var(--ink-3)" }}>{MONTHS[new Date(d).getUTCMonth()]}</span>
|
||||
</div>
|
||||
))}
|
||||
{ticks.ticks.map((tk, i) => (
|
||||
<div key={i} style={{ position: "absolute", left: X(tk.t), top: 18, height: 20, borderLeft: "1px solid var(--grid)", paddingLeft: 3, display: "flex", alignItems: "center" }}>
|
||||
<span className="mono" style={{ fontSize: 8.5, color: "var(--ink-3)" }}>{fmtTime(tk.t)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* линия СЕЙЧАС */}
|
||||
{now >= t0 && now <= t1 && (
|
||||
<div style={{ position: "absolute", left: ET_LABEL_W + X(now), top: ET_AXIS_H, height: totalH - ET_AXIS_H, width: 0, borderLeft: "1.5px dashed var(--now)", zIndex: 8, pointerEvents: "none" }}>
|
||||
<span className="mono" style={{ position: "absolute", top: -1, left: 3, fontSize: 8, fontWeight: 600, color: "var(--bg-0)", background: "var(--now)", padding: "0 4px", borderRadius: 3, whiteSpace: "nowrap" }}>СЕЙЧАС</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* АКТИВНЫЙ КА */}
|
||||
{lane(activeTop, ET_ACTIVE_H,
|
||||
<React.Fragment>
|
||||
{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 (
|
||||
<div key={wk.id} onMouseDown={(e) => 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 && <div className="hatch" style={{ position: "absolute", inset: 0, opacity: 0.5, pointerEvents: "none" }} />}
|
||||
{/* ручки resize */}
|
||||
<div onMouseDown={(e) => startDrag(e, wk, "l")} style={{ position: "absolute", left: 0, top: 0, bottom: 0, width: 7, cursor: "ew-resize", zIndex: 7 }} />
|
||||
<div onMouseDown={(e) => startDrag(e, wk, "r")} style={{ position: "absolute", right: 0, top: 0, bottom: 0, width: 7, cursor: "ew-resize", zIndex: 7 }} />
|
||||
<div style={{ padding: "3px 8px", pointerEvents: "none" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: 2, background: col, flex: "none" }} />
|
||||
{bw > 56 && <span className="mono" style={{ fontSize: 9.5, color: "var(--ink)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{wk.label}</span>}
|
||||
{wk.isNew && <span className="mono" style={{ fontSize: 7.5, color: "var(--now)", border: "1px solid var(--now)", borderRadius: 3, padding: "0 2px", flex: "none" }}>NEW</span>}
|
||||
</div>
|
||||
{bw > 70 && <div className="mono tnum" style={{ fontSize: 8.5, color: "var(--ink-3)", marginTop: 2 }}>{fmtTime(wk.start)}–{fmtTime(wk.end)}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>,
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<TypeDot type={activeSc.type} size={8} />
|
||||
<span className="mono" style={{ fontSize: 11.5, fontWeight: 600, color: "var(--ink)" }}>{activeSc.short}</span>
|
||||
</div>,
|
||||
<span style={{ fontSize: 9, color: "var(--accent)" }}>редактируется</span>,
|
||||
"var(--bg-2)", "active"
|
||||
)}
|
||||
|
||||
{/* ДОРОЖКИ ВИДИМОСТИ */}
|
||||
{visTracks.map((vt, idx) => lane(visTop + idx * ET_VIS_H, ET_VIS_H,
|
||||
<React.Fragment>
|
||||
{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 (
|
||||
<div key={i} title={`Видимость ${vt.label}: ${fmtTime(win.start)}–${fmtTime(win.end)}`}
|
||||
style={{ position: "absolute", left, width: bw, top: 4, height: ET_VIS_H - 8, borderRadius: 3,
|
||||
background: `color-mix(in oklch, ${vt.color} 28%, transparent)`, border: `1px solid ${vt.color}` }} />
|
||||
);
|
||||
})}
|
||||
</React.Fragment>,
|
||||
<span style={{ fontSize: 10, color: "var(--ink-2)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", display: "flex", alignItems: "center", gap: 5 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: 2, background: vt.color, flex: "none" }} />{vt.label}
|
||||
</span>,
|
||||
<span style={{ fontSize: 8, color: "var(--ink-3)" }}>{vt.kind === "target" ? "цель · видимость" : "станция · видимость"}</span>,
|
||||
"var(--bg-1)", "vis-" + vt.id
|
||||
))}
|
||||
|
||||
{/* разделитель */}
|
||||
{otherLanes.length > 0 && (
|
||||
<div style={{ position: "absolute", left: 0, top: dividerTop, width: ET_LABEL_W + w, height: 22, background: "var(--bg-0)", borderBottom: "1px solid var(--line)", borderTop: "1px solid var(--line)", display: "flex", alignItems: "center", padding: "0 10px" }}>
|
||||
<span className="mono" style={{ fontSize: 8.5, color: "var(--ink-3)", letterSpacing: ".1em" }}>ДРУГИЕ КА · КОНТЕКСТ (read-only)</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ДРУГИЕ КА */}
|
||||
{otherLanes.map((ln, idx) => lane(othersTop + idx * ET_OTHER_H, ET_OTHER_H,
|
||||
<React.Fragment>
|
||||
{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 (
|
||||
<div key={wk.id} title={`${ln.sc.short} · ${kindLabel(wk.kind)} · ${fmtTime(wk.start)}–${fmtTime(wk.end)}`}
|
||||
style={{ position: "absolute", left, width: bw, top: 7, height: ET_OTHER_H - 14, borderRadius: 3,
|
||||
background: `color-mix(in oklch, ${col} 16%, var(--bg-1))`, borderLeft: `2px solid ${col}`, opacity: 0.62 }} />
|
||||
);
|
||||
})}
|
||||
</React.Fragment>,
|
||||
<span className="mono" style={{ fontSize: 10.5, color: "var(--ink-2)", display: "flex", alignItems: "center", gap: 5 }}>
|
||||
<TypeDot type={ln.sc.type} size={6} />{ln.sc.short}
|
||||
</span>,
|
||||
null, "var(--bg-1)", "other-" + ln.sc.id
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { EditorTimeline, kindColor, kindLabel });
|
||||
@@ -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 (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100vh" }}>
|
||||
<TopTabs active="map" planHref={PLAN_PAGE} />
|
||||
|
||||
<div style={{ flex: 1, display: "grid", gridTemplateColumns: `288px 1fr ${plan ? "330px" : "0px"}`, minHeight: 0, transition: "grid-template-columns .18s" }}>
|
||||
{/* ЛЕВЫЕ КОНТРОЛЫ */}
|
||||
<div style={{ background: "var(--bg-1)", borderRight: "1px solid var(--line)", display: "flex", flexDirection: "column", minHeight: 0 }}>
|
||||
{/* подложка */}
|
||||
<div style={{ padding: "12px 14px", borderBottom: "1px solid var(--line-soft)" }}>
|
||||
<div className="mono" style={{ fontSize: 9.5, color: "var(--ink-3)", letterSpacing: ".1em", marginBottom: 7 }}>ПОДЛОЖКА</div>
|
||||
<div style={{ display: "flex", gap: 3, background: "var(--bg-0)", borderRadius: 7, padding: 3, border: "1px solid var(--line)" }}>
|
||||
{[["dark", "Тёмная"], ["osm", "Светлая"], ["schema", "Схема"]].map(([id, lab]) => (
|
||||
<button key={id} onClick={() => 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}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* охват */}
|
||||
<div style={{ padding: "12px 14px", borderBottom: "1px solid var(--line-soft)" }}>
|
||||
<div className="mono" style={{ fontSize: 9.5, color: "var(--ink-3)", letterSpacing: ".1em", marginBottom: 7 }}>ОХВАТ</div>
|
||||
<div style={{ display: "flex", gap: 3, background: "var(--bg-0)", borderRadius: 7, padding: 3, border: "1px solid var(--line)" }}>
|
||||
{[["one", "1 КА"], ["group", "Группа"], ["all", "Все"]].map(([id, lab]) => (
|
||||
<button key={id} onClick={() => 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}</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: 8, fontSize: 11, color: "var(--ink-2)", display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<TypeDot type={sc.type} size={8} />
|
||||
<span className="mono">{sc.short}</span>
|
||||
<span style={{ color: "var(--ink-3)" }}>· {D.GROUP_DEFS.find(g => g.id === sc.groupId).name}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* окно времени */}
|
||||
<div style={{ padding: "10px 14px", borderBottom: "1px solid var(--line-soft)" }}>
|
||||
<div className="mono" style={{ fontSize: 9.5, color: "var(--ink-3)", letterSpacing: ".1em", marginBottom: 5 }}>ОКНО ВРЕМЕНИ</div>
|
||||
<div className="mono" style={{ fontSize: 11, color: "var(--ink-1)" }}>{fmtDateTime(win.t0)} → {fmtDateTime(win.t1)}</div>
|
||||
<div style={{ fontSize: 10, color: "var(--ink-3)", marginTop: 3 }}>{plan ? `план ${plan.id}` : "сутки вокруг «сейчас»"}</div>
|
||||
</div>
|
||||
|
||||
{/* слои */}
|
||||
<div style={{ padding: "10px 14px", borderBottom: "1px solid var(--line-soft)" }}>
|
||||
<div className="mono" style={{ fontSize: 9.5, color: "var(--ink-3)", letterSpacing: ".1em", marginBottom: 7 }}>СЛОИ</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<LayerRow on={layers.tracks} onClick={() => toggleLayer("tracks")} swatch={<LineSwatch />} label="Трассы КА" />
|
||||
<LayerRow on={layers.swath} onClick={() => toggleLayer("swath")} swatch={<BandSwatch />} label="Полосы обзора (swath)" />
|
||||
<LayerRow on={layers.shoot} onClick={() => toggleLayer("shoot")} swatch={<PolySwatch />} label="Контуры маршрутов съёмки" />
|
||||
<LayerRow on={layers.downlink} onClick={() => toggleLayer("downlink")} swatch={<DownSwatch />} label="Участки сброса" />
|
||||
<LayerRow on={layers.stations} onClick={() => toggleLayer("stations")} swatch={<StationSwatch />} label="Станции приёма (НКПОР)" />
|
||||
<LayerRow on={layers.terminator} onClick={() => toggleLayer("terminator")} swatch={<NightSwatch />} label="Терминатор день/ночь" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* выбор КА */}
|
||||
<div style={{ padding: "10px 14px 6px" }}>
|
||||
<div className="mono" style={{ fontSize: 9.5, color: "var(--ink-3)", letterSpacing: ".1em", marginBottom: 6 }}>АППАРАТ В ФОКУСЕ</div>
|
||||
<div style={{ position: "relative" }}>
|
||||
<input value={search} onChange={(e) => 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" }} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "4px 8px 12px" }}>
|
||||
{scResults.map((s) => (
|
||||
<div key={s.id} onClick={() => 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" }}>
|
||||
<TypeDot type={s.type} size={7} />
|
||||
<span className="mono" style={{ flex: 1, fontSize: 11, color: s.id === selSc ? "var(--ink)" : "var(--ink-1)" }}>{s.short}</span>
|
||||
<span className="mono" style={{ fontSize: 9.5, color: "var(--ink-3)" }}>{(ivBySc[s.id] || []).length} пл.</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* КАРТА */}
|
||||
<div style={{ position: "relative", minWidth: 0 }}>
|
||||
<MapView scList={scList} selectedPlan={plan} window={win} now={D.NOW} layers={layers}
|
||||
onSelectSc={onSelectSc} paramsBy={paramsBy} scById={scById} basemap={basemap} />
|
||||
{/* плашка масштаба/охвата */}
|
||||
<div style={{ position: "absolute", left: 12, top: 12, display: "flex", gap: 8, alignItems: "center", background: "var(--bg-1)", border: "1px solid var(--line)", borderRadius: 7, padding: "6px 10px" }}>
|
||||
<span className="mono" style={{ fontSize: 10, color: "var(--ink-3)" }}>ОХВАТ:</span>
|
||||
<span style={{ fontSize: 11.5, fontWeight: 600 }}>{scope === "one" ? "1 КА" : scope === "group" ? "Группа" : "Все"}</span>
|
||||
<span style={{ width: 1, height: 14, background: "var(--line)" }} />
|
||||
<span className="mono tnum" style={{ fontSize: 11.5, color: "var(--ink-1)" }}>{scList.length} КА</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ДЕТАЛИ ПЛАНА */}
|
||||
{plan && <MapDetails plan={plan} sc={scById[plan.scId]} params={paramsBy[plan.scId]} byId={byId} onSelectPlan={onSelectPlan} onClose={() => setSelPlan(null)} planPage={PLAN_PAGE} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LayerRow({ on, onClick, swatch, label }) {
|
||||
return (
|
||||
<button onClick={onClick} style={{ display: "flex", alignItems: "center", gap: 8, padding: "5px 6px", borderRadius: 6, border: "1px solid transparent", background: on ? "var(--bg-2)" : "transparent", opacity: on ? 1 : 0.4, textAlign: "left" }}>
|
||||
<span style={{ width: 18, flex: "none", display: "grid", placeItems: "center" }}>{swatch}</span>
|
||||
<span style={{ flex: 1, fontSize: 11.5, color: "var(--ink-1)" }}>{label}</span>
|
||||
<span style={{ width: 14, height: 14, borderRadius: 4, flex: "none", border: `1.5px solid ${on ? "var(--accent)" : "var(--line)"}`, background: on ? "var(--accent)" : "transparent", display: "grid", placeItems: "center" }}>
|
||||
{on && <svg width="9" height="9" viewBox="0 0 10 10"><path d="M1.5 5.2 4 7.6 8.6 2.4" stroke="var(--bg-0)" strokeWidth="1.8" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
const LineSwatch = () => <svg width="18" height="10"><path d="M0 8 Q6 0 12 5 T18 3" stroke="var(--t-optical)" strokeWidth="1.4" fill="none" /></svg>;
|
||||
const BandSwatch = () => <svg width="18" height="10"><path d="M0 8 Q6 0 12 5 T18 3" stroke="var(--t-optical)" strokeWidth="5" fill="none" opacity="0.25" /></svg>;
|
||||
const PolySwatch = () => <svg width="18" height="10"><rect x="2" y="2" width="14" height="6" fill="color-mix(in oklch, var(--t-optical) 30%, transparent)" stroke="var(--t-optical)" strokeWidth="1" /></svg>;
|
||||
const DownSwatch = () => <svg width="18" height="10"><path d="M1 5 H17" stroke="var(--accent)" strokeWidth="3" strokeLinecap="round" /></svg>;
|
||||
const StationSwatch = () => <svg width="18" height="10"><path d="M9 1 L13 8 L5 8 Z" fill="rgba(180,200,210,0.9)" /></svg>;
|
||||
const NightSwatch = () => <svg width="18" height="10"><rect x="0" y="0" width="9" height="10" fill="rgba(4,8,12,0.6)" /><rect x="9" y="0" width="9" height="10" fill="#15291f" /></svg>;
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(<MapApp />);
|
||||
@@ -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 <span className="mono" style={{ fontSize: 9.5, fontWeight: 600, letterSpacing: ".06em", color: s.c, background: s.bg, padding: "2px 7px", borderRadius: 4 }}>{s.t}</span>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={{ background: "var(--bg-1)", borderLeft: "1px solid var(--line)", display: "flex", flexDirection: "column", minHeight: 0 }}>
|
||||
<div style={{ padding: "14px 16px 12px", borderBottom: "1px solid var(--line-soft)" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 8 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
|
||||
<span style={{ width: 10, height: 10, borderRadius: 3, background: col, boxShadow: `0 0 8px ${col}`, flex: "none" }} />
|
||||
<span className="mono" style={{ fontSize: 10, color: "var(--ink-3)" }}>{plan.id}</span>
|
||||
</div>
|
||||
<button onClick={onClose} style={{ background: "none", border: "none", color: "var(--ink-3)", fontSize: 18, lineHeight: 1, padding: 0 }}>×</button>
|
||||
</div>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, marginTop: 8, lineHeight: 1.2 }}>{plan.title}</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 9 }}>
|
||||
<MapStatusBadge status={plan.status} />
|
||||
<span className="mono" style={{ fontSize: 10.5, color: "var(--ink-2)" }}>{sc.short} · {sc.orbit}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "14px 16px 20px", display: "flex", flexDirection: "column", gap: 15 }}>
|
||||
{/* наземные параметры */}
|
||||
<div>
|
||||
<div className="mono" style={{ fontSize: 9, color: "var(--ink-3)", letterSpacing: ".08em", marginBottom: 8 }}>НАЗЕМНЫЕ ПАРАМЕТРЫ</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
|
||||
<KV label="ВЫСОТА ОРБИТЫ">{params.h} км</KV>
|
||||
<KV label="ПЕРИОД ВИТКА">{(params.T / 60).toFixed(1)} мин</KV>
|
||||
<KV label="ПОЛОСА ОБЗОРА">{Math.round(params.swathKm)} км</KV>
|
||||
<KV label="НАКЛОНЕНИЕ">{(params.incl * RAD).toFixed(1)}°</KV>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ height: 1, background: "var(--line-soft)" }} />
|
||||
|
||||
{/* съёмка */}
|
||||
<div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 7, marginBottom: 8 }}>
|
||||
<span style={{ width: 10, height: 8, background: col, borderRadius: 2, flex: "none" }} />
|
||||
<span className="mono" style={{ fontSize: 9, color: "var(--ink-3)", letterSpacing: ".08em" }}>КОНТУРЫ СЪЁМКИ</span>
|
||||
<span className="mono" style={{ fontSize: 9, color: "var(--ink-3)", marginLeft: "auto" }}>{shoots.length}</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
|
||||
{shoots.length === 0 && <div style={{ fontSize: 11, color: "var(--ink-3)" }}>Съёмочных маршрутов нет.</div>}
|
||||
{shoots.map(w => (
|
||||
<div key={w.id} style={{ display: "flex", alignItems: "center", gap: 8, padding: "5px 8px", borderRadius: 6, background: "var(--bg-2)" }}>
|
||||
<span style={{ flex: 1, fontSize: 11.5, color: "var(--ink-1)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{w.label}</span>
|
||||
<span className="mono tnum" style={{ fontSize: 10, color: "var(--ink-3)" }}>{fmtTime(w.start)}–{fmtTime(w.end)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* сброс */}
|
||||
<div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 7, marginBottom: 8 }}>
|
||||
<span style={{ width: 12, height: 3, background: "var(--accent)", borderRadius: 2, flex: "none", boxShadow: "0 0 5px var(--accent)" }} />
|
||||
<span className="mono" style={{ fontSize: 9, color: "var(--ink-3)", letterSpacing: ".08em" }}>УЧАСТКИ СБРОСА</span>
|
||||
<span className="mono" style={{ fontSize: 9, color: "var(--ink-3)", marginLeft: "auto" }}>{downs.length}</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
|
||||
{downs.length === 0 && <div style={{ fontSize: 11, color: "var(--ink-3)" }}>Сбросов нет.</div>}
|
||||
{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 (
|
||||
<div key={w.id} style={{ display: "flex", alignItems: "center", gap: 8, padding: "5px 8px", borderRadius: 6, background: "var(--bg-2)" }}>
|
||||
<span style={{ flex: 1, fontSize: 11.5, color: "var(--ink-1)" }}>→ {ns.st.name}</span>
|
||||
<span className="mono tnum" style={{ fontSize: 10, color: "var(--ink-3)" }}>{fmtTime(w.start)}–{fmtTime(w.end)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{usedStations.length > 0 && (
|
||||
<div style={{ fontSize: 10.5, color: "var(--ink-3)", lineHeight: 1.5 }}>
|
||||
Задействованы станции: <span style={{ color: "var(--ink-1)" }}>{usedStations.map(s => s.name).join(", ")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: "12px 16px", borderTop: "1px solid var(--line-soft)" }}>
|
||||
<a href={`${planPage}?plan=${plan.id}`} style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 7, padding: "9px 0", borderRadius: 7, border: "1px solid var(--line)", background: "var(--bg-2)", color: "var(--ink-1)", fontSize: 12, fontWeight: 600, textDecoration: "none" }}>
|
||||
← Открыть в таймлайне планов
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KV({ label, children }) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
|
||||
<span className="mono" style={{ fontSize: 9, color: "var(--ink-3)", letterSpacing: ".06em" }}>{label}</span>
|
||||
<span className="mono tnum" style={{ fontSize: 12.5, color: "var(--ink)" }}>{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { MapDetails });
|
||||
@@ -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 (
|
||||
<div ref={wrapRef} style={{ position: "absolute", inset: 0, overflow: "hidden", cursor: pickMode ? "crosshair" : (drag.current ? "grabbing" : "grab"), background: "#0a1410" }}
|
||||
onWheel={onWheel} onMouseDown={onDown} onMouseMove={onMove} onMouseUp={onUp} onMouseLeave={onUp}>
|
||||
<canvas ref={canvasRef} style={{ position: "absolute", inset: 0 }} />
|
||||
|
||||
<svg style={{ position: "absolute", inset: 0, width: "100%", height: "100%", pointerEvents: "none", overflow: "visible" }}>
|
||||
{/* выбранные наземные цели */}
|
||||
{targets && targets.map((tg) => {
|
||||
const q = project(tg.lon, tg.lat);
|
||||
return (
|
||||
<g key={tg.id} transform={`translate(${q.x},${q.y})`}>
|
||||
<circle r="9" fill="none" stroke="var(--now)" strokeWidth="1.4" opacity="0.7" />
|
||||
<line x1="-13" y1="0" x2="13" y2="0" stroke="var(--now)" strokeWidth="1" />
|
||||
<line x1="0" y1="-13" x2="0" y2="13" stroke="var(--now)" strokeWidth="1" />
|
||||
<circle r="2.5" fill="var(--now)" />
|
||||
<text x="11" y="-7" fontSize="10" fill="var(--now)" style={{ fontFamily: "var(--mono)", paintOrder: "stroke", stroke: "#0a1410", strokeWidth: 3 }}>{tg.name}</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{planOverlay && planOverlay.shoots.map((s) => (
|
||||
<polygon key={s.id} points={s.poly.map(p => `${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) => (
|
||||
<g key={d.id}>
|
||||
<line x1={d.midPt.x} y1={d.midPt.y} x2={d.stPt.x} y2={d.stPt.y} stroke="var(--accent)" strokeWidth="1" strokeDasharray="2 3" opacity="0.6" />
|
||||
<polyline points={d.line.map(p => `${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)} />
|
||||
</g>
|
||||
))}
|
||||
|
||||
{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 (
|
||||
<g key={st.id} transform={`translate(${q.x},${q.y})`} style={{ pointerEvents: "all", cursor: "default" }}
|
||||
onMouseEnter={(e) => setHover({ x: e.clientX, y: e.clientY, title: st.name, kind: "Станция приёма (НКПОР)" })}
|
||||
onMouseLeave={() => setHover(null)}>
|
||||
<path d="M0,-6 L5,4 L-5,4 Z" fill={active ? "var(--accent)" : "rgba(190,210,220,0.92)"} stroke="#0a1410" strokeWidth="1" />
|
||||
{view.z > 3.6 && <text x="7" y="3" fontSize="9" fill={provider.dark ? "var(--ink-2)" : "#dfeaf0"} style={{ fontFamily: "var(--mono)", paintOrder: "stroke", stroke: "#0a1410", strokeWidth: 2 }}>{st.name}</text>}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{nowMarkers.map((m) => (
|
||||
<g key={m.id} transform={`translate(${m.x},${m.y})`} style={{ pointerEvents: "all", cursor: "pointer" }}
|
||||
onClick={() => onSelectSc(m.id)}
|
||||
onMouseEnter={(e) => setHover({ x: e.clientX, y: e.clientY, title: m.short, kind: "Подспутниковая точка · сейчас" })}
|
||||
onMouseLeave={() => setHover(null)}>
|
||||
<circle r="4.5" fill={TYPE_HEX[m.type]} stroke="#0a1410" strokeWidth="1.5" />
|
||||
<circle r="8" fill="none" stroke={TYPE_HEX[m.type]} strokeWidth="1" opacity="0.5" />
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
{hover && (
|
||||
<div style={{ position: "fixed", left: hover.x + 12, top: hover.y + 12, zIndex: 50, pointerEvents: "none",
|
||||
background: "var(--bg-0)", border: "1px solid var(--line)", borderRadius: 6, padding: "6px 9px", boxShadow: "var(--shadow)", maxWidth: 220 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: "var(--ink)" }}>{hover.title}</div>
|
||||
<div className="mono" style={{ fontSize: 9.5, color: "var(--ink-3)", marginTop: 2 }}>{hover.kind}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* атрибуция подложки */}
|
||||
<div className="mono" style={{ position: "absolute", left: 8, bottom: 6, fontSize: 9, color: provider.dark ? "var(--ink-3)" : "rgba(255,255,255,0.8)", background: "rgba(10,20,16,0.55)", padding: "2px 6px", borderRadius: 4, pointerEvents: "none" }}>{provider.label}</div>
|
||||
|
||||
<div style={{ position: "absolute", right: 12, bottom: 12, display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<ZoomBtn onClick={() => zoomBy(0.5)}>+</ZoomBtn>
|
||||
<ZoomBtn onClick={() => zoomBy(-0.5)}>−</ZoomBtn>
|
||||
<ZoomBtn onClick={resetView} title="Сбросить вид">⊡</ZoomBtn>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Векторная схема континентов (режим «Схема», оффлайн).
|
||||
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 (
|
||||
<button onClick={onClick} title={title} style={{ width: 30, height: 30, borderRadius: 6, border: "1px solid var(--line)", background: "var(--bg-1)", color: "var(--ink-1)", fontSize: 16, lineHeight: 1, display: "grid", placeItems: "center" }}>{children}</button>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { MapView });
|
||||
@@ -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,
|
||||
});
|
||||
|
After Width: | Height: | Size: 218 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 64 KiB |
@@ -0,0 +1,122 @@
|
||||
// Левая панель: поиск, фильтр по типам (легенда), дерево групп → КА с чекбоксами.
|
||||
const { useState: useStateSB } = React;
|
||||
|
||||
function TypeDot({ type, size = 9 }) {
|
||||
return (
|
||||
<span style={{
|
||||
width: size, height: size, borderRadius: 2, flex: "none",
|
||||
background: TYPE_COLOR[type], display: "inline-block",
|
||||
boxShadow: `0 0 6px ${TYPE_COLOR[type]}`,
|
||||
}} />
|
||||
);
|
||||
}
|
||||
|
||||
function Check({ on, onClick, dim }) {
|
||||
return (
|
||||
<button onClick={onClick} aria-label="toggle" style={{
|
||||
width: 16, height: 16, flex: "none", borderRadius: 4,
|
||||
border: `1.5px solid ${on ? "var(--accent)" : "var(--line)"}`,
|
||||
background: on ? "var(--accent)" : "transparent",
|
||||
opacity: dim ? 0.4 : 1,
|
||||
display: "grid", placeItems: "center", padding: 0, transition: "all .12s",
|
||||
}}>
|
||||
{on && (
|
||||
<svg width="10" height="10" viewBox="0 0 10 10"><path d="M1.5 5.2 4 7.6 8.6 2.4" stroke="var(--bg-0)" strokeWidth="1.8" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%", background: "var(--bg-1)", borderRight: "1px solid var(--line)" }}>
|
||||
{/* Заголовок */}
|
||||
<div style={{ padding: "14px 14px 10px", borderBottom: "1px solid var(--line-soft)" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<div style={{ width: 22, height: 22, borderRadius: 5, background: "var(--accent)", display: "grid", placeItems: "center", boxShadow: "0 0 12px var(--accent)" }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="3" fill="var(--bg-0)" /><ellipse cx="12" cy="12" rx="10" ry="4.3" stroke="var(--bg-0)" strokeWidth="1.7" transform="rotate(28 12 12)" /></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, letterSpacing: ".02em", fontSize: 13 }}>ПЛАН КА</div>
|
||||
<div className="mono" style={{ fontSize: 9.5, color: "var(--ink-3)", letterSpacing: ".08em" }}>MISSION OPS · ДЗЗ</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Поиск */}
|
||||
<div style={{ padding: "10px 12px 8px" }}>
|
||||
<div style={{ position: "relative" }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" style={{ position: "absolute", left: 9, top: 9, opacity: .5 }}><circle cx="11" cy="11" r="7" stroke="var(--ink-2)" strokeWidth="2" /><path d="m20 20-3.5-3.5" stroke="var(--ink-2)" strokeWidth="2" strokeLinecap="round" /></svg>
|
||||
<input value={search} onChange={(e) => 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 && <button onClick={() => setSearch("")} style={{ position: "absolute", right: 6, top: 6, background: "none", border: "none", color: "var(--ink-3)", fontSize: 14, padding: 2 }}>×</button>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Легенда / фильтр по типам */}
|
||||
<div style={{ padding: "4px 12px 10px", borderBottom: "1px solid var(--line-soft)" }}>
|
||||
<div className="mono" style={{ fontSize: 9.5, color: "var(--ink-3)", letterSpacing: ".1em", marginBottom: 6 }}>АППАРАТУРА</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
|
||||
{D.typeOrder.map((t) => {
|
||||
const on = typeFilter[t];
|
||||
return (
|
||||
<button key={t} onClick={() => 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" }}>
|
||||
<TypeDot type={t} />
|
||||
<span style={{ flex: 1, fontSize: 12 }}>{D.TYPES[t].label}</span>
|
||||
<span className="mono tnum" style={{ fontSize: 10.5, color: "var(--ink-3)" }}>{counts.byType[t] || 0}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Дерево групп → КА */}
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "8px 8px 16px" }}>
|
||||
<div className="mono" style={{ fontSize: 9.5, color: "var(--ink-3)", letterSpacing: ".1em", margin: "2px 6px 6px" }}>ГРУППЫ / КА</div>
|
||||
{groups.map((g) => {
|
||||
const list = scByGroup[g.id] || [];
|
||||
const isOpen = open[g.id];
|
||||
const shownCount = list.filter((sc) => !scHidden.has(sc.id)).length;
|
||||
return (
|
||||
<div key={g.id} style={{ marginBottom: 2 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, padding: "5px 6px", borderRadius: 6, background: "var(--bg-2)" }}>
|
||||
<button onClick={() => setOpen({ ...open, [g.id]: !isOpen })} style={{ background: "none", border: "none", padding: 0, width: 14, height: 14, display: "grid", placeItems: "center", color: "var(--ink-2)" }}>
|
||||
<svg width="9" height="9" viewBox="0 0 10 10" style={{ transform: isOpen ? "rotate(90deg)" : "none", transition: "transform .12s" }}><path d="M3 1.5 7 5 3 8.5" stroke="currentColor" strokeWidth="1.6" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
||||
</button>
|
||||
<Check on={groupShown[g.id]} onClick={() => toggleGroup(g.id)} />
|
||||
<span className="mono" style={{ fontSize: 10, color: "var(--ink-3)" }}>{g.code}</span>
|
||||
<span style={{ flex: 1, fontSize: 12, fontWeight: 600, color: groupShown[g.id] ? "var(--ink)" : "var(--ink-3)" }}>{g.name}</span>
|
||||
<span className="mono tnum" style={{ fontSize: 10, color: "var(--ink-3)" }}>{shownCount}/{list.length}</span>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div style={{ marginLeft: 14, borderLeft: "1px solid var(--line-soft)", paddingLeft: 4, marginTop: 2 }}>
|
||||
{list.map((sc) => {
|
||||
const vis = !scHidden.has(sc.id);
|
||||
const sel = selectedSc === sc.id;
|
||||
return (
|
||||
<div key={sc.id} onClick={() => onPickSc(sc.id)}
|
||||
style={{ display: "flex", alignItems: "center", gap: 7, padding: "4px 7px", borderRadius: 6, cursor: "pointer", background: sel ? "var(--bg-3)" : "transparent" }}>
|
||||
<Check on={vis} dim={!groupShown[g.id]} onClick={(e) => { e.stopPropagation(); toggleSc(sc.id); }} />
|
||||
<TypeDot type={sc.type} size={7} />
|
||||
<span className="mono" style={{ flex: 1, fontSize: 11, color: vis && groupShown[g.id] ? "var(--ink-1)" : "var(--ink-3)" }}>{sc.short}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { Sidebar, TypeDot });
|
||||
@@ -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 (
|
||||
<a href={href} style={{
|
||||
display: "flex", alignItems: "center", gap: 7, padding: "0 16px", height: "100%",
|
||||
textDecoration: "none", fontSize: 13, fontWeight: 600,
|
||||
color: on ? "var(--ink)" : "var(--ink-3)",
|
||||
borderBottom: `2px solid ${on ? "var(--accent)" : "transparent"}`,
|
||||
background: on ? "var(--bg-1)" : "transparent",
|
||||
}}>
|
||||
<span style={{ display: "grid", placeItems: "center", opacity: on ? 1 : 0.7 }}>{icon}</span>
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<div style={{ height: 46, flex: "none", display: "flex", alignItems: "stretch", background: "var(--bg-0)", borderBottom: "1px solid var(--line)" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: "0 16px", borderRight: "1px solid var(--line)" }}>
|
||||
<div style={{ width: 20, height: 20, borderRadius: 5, background: "var(--accent)", display: "grid", placeItems: "center", boxShadow: "0 0 12px var(--accent)" }}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="3" fill="var(--bg-0)" /><ellipse cx="12" cy="12" rx="10" ry="4.3" stroke="var(--bg-0)" strokeWidth="1.7" transform="rotate(28 12 12)" /></svg>
|
||||
</div>
|
||||
<span className="mono" style={{ fontSize: 11, fontWeight: 600, letterSpacing: ".05em", color: "var(--ink-1)" }}>MISSION OPS</span>
|
||||
</div>
|
||||
{tab("plans", "Планы", PH, <svg width="14" height="14" viewBox="0 0 24 24" fill="none"><rect x="3" y="6" width="14" height="3" rx="1.5" fill="currentColor" /><rect x="6" y="12" width="14" height="3" rx="1.5" fill="currentColor" /><rect x="3" y="18" width="9" height="3" rx="1.5" fill="currentColor" /></svg>)}
|
||||
{tab("map", "Карта", MH, <svg width="14" height="14" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2" /><path d="M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18" stroke="currentColor" strokeWidth="1.5" fill="none" /></svg>)}
|
||||
{tab("editor", "Редактор", EH, <svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M4 20l4-1L19 8a2 2 0 0 0-3-3L5 16l-1 4z" stroke="currentColor" strokeWidth="1.8" fill="none" strokeLinejoin="round" /><path d="M14 7l3 3" stroke="currentColor" strokeWidth="1.8" /></svg>)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Цветная метка типа аппаратуры (общая для обеих страниц).
|
||||
function TypeDot({ type, size = 9 }) {
|
||||
return (
|
||||
<span style={{
|
||||
width: size, height: size, borderRadius: 2, flex: "none",
|
||||
background: TYPE_COLOR[type], display: "inline-block",
|
||||
boxShadow: `0 0 6px ${TYPE_COLOR[type]}`,
|
||||
}} />
|
||||
);
|
||||
}
|
||||
|
||||
// Режимы аппаратуры для съёмки (по типу КА).
|
||||
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 });
|
||||
@@ -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 (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%", background: "var(--bg-0)", minWidth: 0 }}>
|
||||
{/* ОСЬ ВРЕМЕНИ */}
|
||||
<div style={{ display: "flex", height: AXIS_H, flex: "none", borderBottom: "1px solid var(--line)", background: "var(--bg-1)" }}>
|
||||
<div style={{ width: LABEL_W, flex: "none", borderRight: "1px solid var(--line)", display: "flex", alignItems: "flex-end", padding: "0 12px 7px" }}>
|
||||
<span className="mono" style={{ fontSize: 9.5, color: "var(--ink-3)", letterSpacing: ".1em" }}>КОСМИЧЕСКИЙ АППАРАТ</span>
|
||||
</div>
|
||||
<div ref={axisRef} style={{ flex: 1, overflow: "hidden", position: "relative" }}>
|
||||
<div style={{ width: contentW, height: "100%", position: "relative" }}>
|
||||
{/* верхний ярус — дни */}
|
||||
{days.map((d, i) => {
|
||||
const w = 86400000 * pxPerMs;
|
||||
return (
|
||||
<div key={i} style={{ position: "absolute", left: x(d.t), top: 0, width: w, height: 24, borderLeft: "1px solid var(--line-soft)", display: "flex", alignItems: "center", gap: 5, padding: "0 7px", color: d.weekend ? "var(--ink-3)" : "var(--ink-1)" }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 600 }}>{pad(new Date(d.t).getUTCDate())}</span>
|
||||
<span style={{ fontSize: 9.5, color: "var(--ink-3)" }}>{MONTHS[new Date(d.t).getUTCMonth()]}</span>
|
||||
<span className="mono" style={{ fontSize: 8.5, color: "var(--ink-3)", letterSpacing: ".05em" }}>{WDAYS[d.dow]}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* нижний ярус — засечки */}
|
||||
{sub.ticks.map((tk, i) => (
|
||||
<div key={i} style={{ position: "absolute", left: x(tk.t), top: 24, height: 22, borderLeft: "1px solid var(--grid)", paddingLeft: 4, display: "flex", alignItems: "center" }}>
|
||||
{showHourTicks && <span className="mono" style={{ fontSize: 9, color: "var(--ink-3)" }}>{fmtTime(tk.t)}</span>}
|
||||
</div>
|
||||
))}
|
||||
{/* метка «сейчас» */}
|
||||
<div style={{ position: "absolute", left: x(now), top: 0, height: "100%", transform: "translateX(-50%)", display: "flex", alignItems: "flex-start" }}>
|
||||
<span className="mono" style={{ marginTop: 3, fontSize: 9, fontWeight: 600, color: "var(--bg-0)", background: "var(--now)", padding: "1px 5px", borderRadius: 4, whiteSpace: "nowrap", boxShadow: "0 0 8px var(--now)" }}>СЕЙЧАС {fmtTime(now)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ТЕЛО */}
|
||||
<div style={{ flex: 1, display: "flex", minHeight: 0 }}>
|
||||
{/* Левая колонка подписей */}
|
||||
<div ref={leftRef} style={{ width: LABEL_W, flex: "none", overflow: "hidden", borderRight: "1px solid var(--line)", background: "var(--bg-1)" }}>
|
||||
<div style={{ height: layout.total, position: "relative" }}>
|
||||
{layout.placed.map((r) => r.kind === "group" ? (
|
||||
<div key={"g" + r.group.id} onClick={() => 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" }}>
|
||||
<svg width="9" height="9" viewBox="0 0 10 10" style={{ transform: timelineCollapsed.has(r.group.id) ? "none" : "rotate(90deg)", transition: "transform .12s", color: "var(--ink-2)", flex: "none" }}><path d="M3 1.5 7 5 3 8.5" stroke="currentColor" strokeWidth="1.6" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
||||
<span className="mono" style={{ fontSize: 9.5, color: "var(--ink-3)" }}>{r.group.code}</span>
|
||||
<span style={{ flex: 1, fontSize: 12, fontWeight: 700 }}>{r.group.name}</span>
|
||||
<span className="mono tnum" style={{ fontSize: 10, color: "var(--ink-3)" }}>{r.count}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div key={r.sc.id} style={{ position: "absolute", top: r.y, left: 0, right: 0, height: r.h, display: "flex", alignItems: "center", gap: 8, padding: "0 12px", borderBottom: "1px solid var(--line-soft)" }}>
|
||||
<TypeDot type={r.sc.type} size={8} />
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div className="mono" style={{ fontSize: 11.5, color: "var(--ink-1)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{r.sc.short}</div>
|
||||
<div style={{ fontSize: 9.5, color: "var(--ink-3)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{r.sc.orbit}</div>
|
||||
</div>
|
||||
<span className="mono tnum" style={{ fontSize: 9.5, color: "var(--ink-3)" }}>{r.ivs.length}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Правый холст */}
|
||||
<div ref={bodyRef} onScroll={onBodyScroll} style={{ flex: 1, overflow: "auto", position: "relative" }}>
|
||||
<div style={{ width: contentW, height: layout.total, position: "relative" }}>
|
||||
{/* фон выходных + сетка дней */}
|
||||
{days.map((d, i) => (
|
||||
<div key={i} style={{ position: "absolute", left: x(d.t), top: 0, width: 86400000 * pxPerMs, height: "100%", borderLeft: "1px solid var(--grid)", background: d.weekend ? "oklch(0.22 0.012 165 / 0.5)" : "transparent" }} />
|
||||
))}
|
||||
{/* линия «сейчас» */}
|
||||
<div style={{ position: "absolute", left: x(now), top: 0, height: "100%", width: 0, borderLeft: "1.5px dashed var(--now)", zIndex: 6 }} />
|
||||
|
||||
{/* строки */}
|
||||
{layout.placed.map((r) => r.kind === "group" ? (
|
||||
<div key={"g" + r.group.id} style={{ position: "absolute", top: r.y, left: 0, width: contentW, height: r.h, background: "var(--bg-2)", borderTop: "1px solid var(--line)", borderBottom: "1px solid var(--line)", opacity: .95 }} />
|
||||
) : (
|
||||
<Row key={r.sc.id} r={r} x={x} contentW={contentW} selectedIv={selectedIv}
|
||||
onSelectIv={onSelectIv} lineageSet={lineageSet} anySelected={anySelected} />
|
||||
))}
|
||||
|
||||
{/* стрелки родословной */}
|
||||
{arrows.length > 0 && (
|
||||
<svg style={{ position: "absolute", left: 0, top: 0, width: contentW, height: layout.total, pointerEvents: "none", zIndex: 7 }}>
|
||||
<defs>
|
||||
<marker id="ah" markerWidth="7" markerHeight="7" refX="5.5" refY="3" orient="auto">
|
||||
<path d="M0 0 L6 3 L0 6 z" fill="var(--accent)" />
|
||||
</marker>
|
||||
</defs>
|
||||
{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 <path key={i} d={d} fill="none" stroke="var(--accent)" strokeWidth="1.6" markerEnd="url(#ah)" opacity="0.9" />;
|
||||
})}
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Одна строка КА: ПЛАНЫ (объемлющие интервалы) по лейнам, внутри — РАБОТЫ.
|
||||
function Row({ r, x, contentW, selectedIv, onSelectIv, lineageSet, anySelected }) {
|
||||
return (
|
||||
<div style={{ position: "absolute", top: r.y, left: 0, width: contentW, height: r.h, borderBottom: "1px solid var(--line-soft)" }}>
|
||||
{/* пересечения планов — штриховка */}
|
||||
{r.overlaps.map((ov, i) => (
|
||||
<div key={"ov" + i} className="hatch" style={{ position: "absolute", left: x(ov.start), width: Math.max(2, x(ov.end) - x(ov.start)), top: 2, height: r.h - 4, borderLeft: "1px solid var(--now)", borderRight: "1px solid var(--now)", borderRadius: 2, zIndex: 1, pointerEvents: "none" }} />
|
||||
))}
|
||||
{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 (
|
||||
<div key={plan.id} onClick={(e) => { 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",
|
||||
}}>
|
||||
{/* заголовок плана */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 4, height: 14, padding: "0 4px 0 5px" }}>
|
||||
{hasParent && <span style={{ flex: "none", color: "var(--accent)", fontSize: 8, lineHeight: 1 }} title="есть предок">◀</span>}
|
||||
{wide && <span className="mono" style={{ flex: 1, fontSize: 9, color: "var(--ink-1)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", letterSpacing: ".01em" }}>{plan.title}</span>}
|
||||
{wide && plan.status === "active" && <span style={{ width: 4, height: 4, borderRadius: 9, background: "var(--now)", boxShadow: "0 0 5px var(--now)", flex: "none" }} />}
|
||||
{wide && plan.rev > 1 && <span className="mono" style={{ flex: "none", fontSize: 7.5, color: "var(--ink-3)", border: "1px solid var(--line)", borderRadius: 3, padding: "0 2px" }}>r{plan.rev}</span>}
|
||||
{!wide && <span style={{ flex: 1 }} />}
|
||||
{hasChild && <span style={{ flex: "none", color: "var(--accent)", fontSize: 8, lineHeight: 1 }} title="есть потомок">▶</span>}
|
||||
</div>
|
||||
{/* трек работ внутри плана */}
|
||||
{showWorks && (
|
||||
<div style={{ position: "absolute", left: 0, right: 0, bottom: 2, height: 11 }}>
|
||||
{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 (
|
||||
<div key={wk.id} title={`${wk.label} · ${fmtTime(wk.start)}–${fmtTime(wk.end)}`}
|
||||
style={{
|
||||
position: "absolute", left: wl, width: ww, top: 0, height: 11,
|
||||
background: isService ? "transparent" : fill,
|
||||
border: isService ? `1px dashed ${fill}` : "none",
|
||||
borderRadius: 2, boxSizing: "border-box",
|
||||
}} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { Timeline });
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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 });
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -0,0 +1,94 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>План КА — Mission Ops</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<style>
|
||||
:root {
|
||||
/* Фон — очень тёмный десатурированный тёмно-зелёный (OPS) */
|
||||
--bg-0: oklch(0.18 0.012 165);
|
||||
--bg-1: oklch(0.215 0.013 165);
|
||||
--bg-2: oklch(0.255 0.014 165);
|
||||
--bg-3: oklch(0.30 0.015 165);
|
||||
--line: oklch(0.34 0.014 165);
|
||||
--line-soft: oklch(0.28 0.013 165);
|
||||
--grid: oklch(0.26 0.012 165);
|
||||
|
||||
--ink: oklch(0.95 0.01 150);
|
||||
--ink-1: oklch(0.80 0.012 160);
|
||||
--ink-2: oklch(0.64 0.012 165);
|
||||
--ink-3: oklch(0.50 0.012 165);
|
||||
|
||||
/* Типы аппаратуры — одинаковая C/L, разный hue */
|
||||
--t-optical: oklch(0.80 0.14 158);
|
||||
--t-radar: oklch(0.82 0.13 82);
|
||||
--t-combo: oklch(0.74 0.15 6);
|
||||
--t-optical-dim: oklch(0.80 0.14 158 / 0.16);
|
||||
--t-radar-dim: oklch(0.82 0.13 82 / 0.16);
|
||||
--t-combo-dim: oklch(0.74 0.15 6 / 0.16);
|
||||
|
||||
--accent: oklch(0.82 0.13 200);
|
||||
--warn: oklch(0.80 0.15 55);
|
||||
--now: oklch(0.86 0.16 25);
|
||||
|
||||
--radius: 7px;
|
||||
--mono: "IBM Plex Mono", ui-monospace, monospace;
|
||||
--sans: "IBM Plex Sans", system-ui, sans-serif;
|
||||
--shadow: 0 8px 30px oklch(0.12 0.01 165 / 0.55);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; margin: 0; }
|
||||
body {
|
||||
font-family: var(--sans);
|
||||
background: var(--bg-0);
|
||||
color: var(--ink);
|
||||
font-size: 13px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow: hidden;
|
||||
}
|
||||
#root { height: 100vh; }
|
||||
|
||||
::-webkit-scrollbar { width: 11px; height: 11px; }
|
||||
::-webkit-scrollbar-track { background: var(--bg-0); }
|
||||
::-webkit-scrollbar-thumb { background: var(--bg-3); border-radius: 6px; border: 2px solid var(--bg-0); }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--line); }
|
||||
::-webkit-scrollbar-corner { background: var(--bg-0); }
|
||||
|
||||
.mono { font-family: var(--mono); }
|
||||
.tnum { font-variant-numeric: tabular-nums; }
|
||||
|
||||
button { font-family: inherit; color: inherit; cursor: pointer; }
|
||||
|
||||
/* диагональная штриховка для пометки пересечений */
|
||||
.hatch {
|
||||
background-image: repeating-linear-gradient(
|
||||
-45deg,
|
||||
oklch(0.86 0.16 25 / 0.55) 0px,
|
||||
oklch(0.86 0.16 25 / 0.55) 2px,
|
||||
transparent 2px,
|
||||
transparent 6px
|
||||
);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
|
||||
<script src="data.jsx"></script>
|
||||
<script type="text/babel" src="util.jsx"></script>
|
||||
<script type="text/babel" src="tabs.jsx"></script>
|
||||
<script type="text/babel" src="sidebar.jsx"></script>
|
||||
<script type="text/babel" src="timeline.jsx"></script>
|
||||
<script type="text/babel" src="details.jsx"></script>
|
||||
<script type="text/babel" src="app.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 (
|
||||
<div className="tgu-editor-panel">
|
||||
<PanelHead title={kindLabel(work.kind)} onClose={onClose} />
|
||||
<div className="tgu-editor-panel__id">{work.id}</div>
|
||||
|
||||
{conflicts.length > 0 && (
|
||||
<div className="tgu-alert tgu-alert--warning">
|
||||
{conflicts.map((conflict) => (
|
||||
<div key={conflict}>{conflict}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="tgu-editor-panel__section">
|
||||
<div className="tgu-editor-eyebrow">Время</div>
|
||||
<TimeStepper label="начало" value={work.startMs} max={work.endMs - 60_000} onChange={(value) => onUpdate(work.id, { startMs: value })} />
|
||||
<TimeStepper label="конец" value={work.endMs} min={work.startMs + 60_000} onChange={(value) => onUpdate(work.id, { endMs: value })} />
|
||||
<div className="tgu-editor-panel__hint">длительность: {formatDuration(work.endMs - work.startMs)}</div>
|
||||
</section>
|
||||
|
||||
{work.kind === "shoot" && (
|
||||
<section className="tgu-editor-panel__section">
|
||||
<div className="tgu-editor-eyebrow">Режим аппаратуры</div>
|
||||
<div className="tgu-editor-chip-row">
|
||||
{modes.map((mode) => (
|
||||
<button
|
||||
className={work.modeId === mode.id ? "is-selected" : ""}
|
||||
key={mode.id}
|
||||
type="button"
|
||||
onClick={() => onUpdate(work.id, { modeId: mode.id, label: mode.label })}
|
||||
>
|
||||
{mode.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{work.targetName && <div className="tgu-editor-panel__hint">цель: {work.targetName}</div>}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{work.kind === "downlink" && (
|
||||
<section className="tgu-editor-panel__section">
|
||||
<div className="tgu-editor-eyebrow">Станция приёма</div>
|
||||
<select value={work.stationId ?? ""} onChange={(event) => onUpdate(work.id, { stationId: event.target.value })}>
|
||||
{stations.map((station) => (
|
||||
<option key={station.id} value={station.id}>
|
||||
{station.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="tgu-editor-panel__actions">
|
||||
<button className="tgu-button" type="button" onClick={() => onDuplicate(work.id)}>
|
||||
Дублировать
|
||||
</button>
|
||||
<button className="tgu-button tgu-button--reject" type="button" onClick={() => onDelete(work.id)}>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span className="tgu-editor-panel__color" style={{ background: color }} aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ShootPanel({
|
||||
target,
|
||||
passes,
|
||||
selectedPass,
|
||||
modeId,
|
||||
modes,
|
||||
onSelectPass,
|
||||
onModeChange,
|
||||
onAdd,
|
||||
onClose,
|
||||
onClearTarget
|
||||
}: ShootPanelProps) {
|
||||
return (
|
||||
<div className="tgu-editor-panel">
|
||||
<PanelHead title="Новая съёмка" onClose={onClose} />
|
||||
|
||||
<section className="tgu-editor-panel__section">
|
||||
<div className="tgu-editor-panel__section-head">
|
||||
<div className="tgu-editor-eyebrow">1 · цель съёмки</div>
|
||||
{target && (
|
||||
<button className="tgu-link-button" type="button" onClick={onClearTarget}>
|
||||
сбросить
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!target ? (
|
||||
<div className="tgu-editor-panel__placeholder">Кликните точку на карте слева, чтобы выбрать цель съёмки.</div>
|
||||
) : (
|
||||
<div className="tgu-editor-target">
|
||||
<i />
|
||||
<span>{target.name}</span>
|
||||
<small>
|
||||
{target.lat.toFixed(2)}, {target.lon.toFixed(2)}
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{target && (
|
||||
<section className="tgu-editor-panel__section">
|
||||
<div className="tgu-editor-eyebrow">2 · окно видимости</div>
|
||||
{passes.length === 0 ? (
|
||||
<div className="tgu-editor-panel__hint">В окне планирования проходов над целью нет.</div>
|
||||
) : (
|
||||
<div className="tgu-editor-pass-list">
|
||||
{passes.map((pass) => {
|
||||
const selected = selectedPass?.startMs === pass.startMs && selectedPass.endMs === pass.endMs;
|
||||
return (
|
||||
<button
|
||||
className={selected ? "is-selected" : ""}
|
||||
key={`${pass.startMs}-${pass.endMs}`}
|
||||
type="button"
|
||||
onClick={() => onSelectPass(pass)}
|
||||
>
|
||||
<span>{formatDate(pass.startMs)} {formatTime(pass.startMs)}</span>
|
||||
<small>{formatDuration(pass.endMs - pass.startMs)}</small>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{target && selectedPass && (
|
||||
<section className="tgu-editor-panel__section">
|
||||
<div className="tgu-editor-eyebrow">3 · режим съёмки</div>
|
||||
<div className="tgu-editor-chip-row">
|
||||
{modes.map((mode) => (
|
||||
<button className={modeId === mode.id ? "is-selected" : ""} key={mode.id} type="button" onClick={() => onModeChange(mode.id)}>
|
||||
{mode.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<button className="tgu-button tgu-button--primary" type="button" onClick={onAdd} disabled={!target || !selectedPass || !modeId}>
|
||||
+ Добавить съёмку в план
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DownlinkPanel({ stationOptions, selected, onSelect, onAdd, onClose }: DownlinkPanelProps) {
|
||||
return (
|
||||
<div className="tgu-editor-panel">
|
||||
<PanelHead title="Новый сброс на НКПОР" onClose={onClose} />
|
||||
<div className="tgu-editor-panel__hint">Выберите станцию и сеанс связи для добавления сброса в черновик.</div>
|
||||
|
||||
<div className="tgu-editor-station-list">
|
||||
{stationOptions.map((option) => (
|
||||
<div className="tgu-editor-station" key={option.station.id}>
|
||||
<div className="tgu-editor-station__head">
|
||||
<span>{option.station.name}</span>
|
||||
<small>{option.passes.length} сеанс.</small>
|
||||
</div>
|
||||
{option.passes.length === 0 ? (
|
||||
<div className="tgu-editor-panel__hint">нет сеансов в текущем окне</div>
|
||||
) : (
|
||||
option.passes.slice(0, 5).map((pass) => {
|
||||
const isSelected = selected?.station.id === option.station.id && selected.pass.startMs === pass.startMs;
|
||||
return (
|
||||
<button
|
||||
className={isSelected ? "is-selected" : ""}
|
||||
key={`${option.station.id}-${pass.startMs}`}
|
||||
type="button"
|
||||
onClick={() => onSelect({ station: option.station, pass })}
|
||||
>
|
||||
<span>{formatDateTime(pass.startMs)}-{formatTime(pass.endMs)}</span>
|
||||
<small>{formatDuration(pass.endMs - pass.startMs)}</small>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button className="tgu-button tgu-button--primary" type="button" onClick={onAdd} disabled={!selected}>
|
||||
+ Добавить сброс в план
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelHead({ title, onClose }: { title: string; onClose: () => void }) {
|
||||
return (
|
||||
<div className="tgu-editor-panel__head">
|
||||
<h2>{title}</h2>
|
||||
<button className="tgu-icon-button" type="button" onClick={onClose} title="Закрыть">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 className="tgu-editor-time-stepper">
|
||||
<span>{label}</span>
|
||||
<div>
|
||||
<button type="button" onClick={() => change(-3_600_000)}>−ч</button>
|
||||
<button type="button" onClick={() => change(-5 * 60_000)}>−5м</button>
|
||||
<output>{formatDateTime(value)}</output>
|
||||
<button type="button" onClick={() => change(5 * 60_000)}>+5м</button>
|
||||
<button type="button" onClick={() => change(3_600_000)}>+ч</button>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -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<string>;
|
||||
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 (
|
||||
<aside className="tgu-editor-rail">
|
||||
<section className="tgu-editor-rail__section">
|
||||
<div className="tgu-editor-eyebrow">Редактируется</div>
|
||||
<div className="tgu-editor-rail__title">{currentPlatform?.name ?? selectedSpacecraftId ?? "КА не выбран"}</div>
|
||||
<div className="tgu-editor-rail__meta">
|
||||
{selectedPlan ? `${selectedPlan.planId} · КПП ${selectedPlan.kppId}` : "План не выбран"}
|
||||
</div>
|
||||
|
||||
<div className="tgu-editor-stats">
|
||||
<RailStat label="Включений" value={draft.works.length} />
|
||||
<RailStat label="Съёмка" value={countWorks(draft.works, "shoot")} color="var(--t-optical)" />
|
||||
<RailStat label="Сброс" value={countWorks(draft.works, "downlink")} color="var(--accent)" />
|
||||
<RailStat label="Конфл." value={conflictCount} color={conflictCount > 0 ? "var(--now)" : undefined} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="tgu-editor-rail__section">
|
||||
<div className="tgu-editor-eyebrow">Контекст других КА</div>
|
||||
<div className="tgu-editor-context-list">
|
||||
{contextPlatforms.length === 0 ? (
|
||||
<div className="tgu-empty tgu-empty--compact">Нет других КА в выбранном интервале.</div>
|
||||
) : (
|
||||
contextPlatforms.map((platform) => {
|
||||
const enabled = contextSpacecraftIds.has(platform.spacecraftId);
|
||||
return (
|
||||
<button
|
||||
className={`tgu-editor-context-item ${enabled ? "is-enabled" : ""}`}
|
||||
key={platform.spacecraftId}
|
||||
type="button"
|
||||
onClick={() => onToggleContext(platform.spacecraftId)}
|
||||
>
|
||||
<span className="tgu-editor-context-item__check">{enabled ? "✓" : ""}</span>
|
||||
<span>{platform.name ?? platform.noradId ?? platform.spacecraftId}</span>
|
||||
<small>{platform.planCount}</small>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function RailStat({ label, value, color }: { label: string; value: number; color?: string }) {
|
||||
return (
|
||||
<div className="tgu-editor-stat">
|
||||
<span style={{ color }}>{value}</span>
|
||||
<small>{label}</small>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function countWorks(works: EditorWork[], kind: EditorWork["kind"]): number {
|
||||
return works.filter((work) => work.kind === kind).length;
|
||||
}
|
||||
@@ -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<string, string[]>;
|
||||
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<HTMLDivElement | null>(null);
|
||||
const dragRef = useRef<DragState | undefined>(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 (
|
||||
<div className="tgu-editor-timeline" ref={wrapperRef} onClick={onClearSelection}>
|
||||
<div className="tgu-editor-timeline__surface" style={{ width: LABEL_WIDTH + bodyWidth, height: Math.max(220, totalHeight) }}>
|
||||
<div className="tgu-editor-timeline__axis-label" style={{ width: LABEL_WIDTH }}>
|
||||
Время (UTC)
|
||||
</div>
|
||||
<div className="tgu-editor-timeline__axis" style={{ left: LABEL_WIDTH, width: bodyWidth }}>
|
||||
{days.map((day) => (
|
||||
<div className="tgu-editor-timeline__day" key={day} style={{ left: toX(day) }}>
|
||||
<strong>{formatDate(day).split(" ")[0]}</strong>
|
||||
<span>{formatDate(day).split(" ")[1]}</span>
|
||||
</div>
|
||||
))}
|
||||
{ticks.map((tick) => (
|
||||
<div className="tgu-editor-timeline__tick-label" key={tick} style={{ left: toX(tick) }}>
|
||||
{formatTime(tick)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="tgu-editor-timeline__grid" style={{ left: LABEL_WIDTH, top: AXIS_HEIGHT, width: bodyWidth }}>
|
||||
{ticks.map((tick) => (
|
||||
<div key={tick} style={{ left: toX(tick) }} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{nowMs >= timelineWindow.fromMs && nowMs <= timelineWindow.toMs && (
|
||||
<div className="tgu-editor-timeline__now" style={{ left: LABEL_WIDTH + toX(nowMs), top: AXIS_HEIGHT }}>
|
||||
<span>Сейчас</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="tgu-editor-timeline__lane" style={{ top: AXIS_HEIGHT, height: ACTIVE_HEIGHT }}>
|
||||
<div className="tgu-editor-timeline__lane-label" style={{ width: LABEL_WIDTH }}>
|
||||
<span>{activeLabel}</span>
|
||||
<small>редактируется</small>
|
||||
</div>
|
||||
<div className="tgu-editor-timeline__lane-body" style={{ left: LABEL_WIDTH, width: bodyWidth }}>
|
||||
{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 (
|
||||
<div
|
||||
className={`tgu-editor-work ${selected ? "is-selected" : ""} ${hasConflict ? "has-conflict" : ""}`}
|
||||
key={work.id}
|
||||
style={{
|
||||
left,
|
||||
width,
|
||||
borderLeftColor: color,
|
||||
background: `color-mix(in srgb, ${color} 22%, var(--bg-1))`
|
||||
}}
|
||||
title={`${kindLabel(work.kind)} · ${formatDateTime(work.startMs)}-${formatTime(work.endMs)}`}
|
||||
onMouseDown={(event) => startDrag(event, work, "move")}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onSelectWork(work.id);
|
||||
}}
|
||||
>
|
||||
<span className="tgu-editor-work__resize tgu-editor-work__resize--left" onMouseDown={(event) => startDrag(event, work, "left")} />
|
||||
<span className="tgu-editor-work__resize tgu-editor-work__resize--right" onMouseDown={(event) => startDrag(event, work, "right")} />
|
||||
<span className="tgu-editor-work__dot" style={{ background: color }} />
|
||||
{width > 54 && <span className="tgu-editor-work__label">{work.label}</span>}
|
||||
{work.isNew && <span className="tgu-editor-work__new">NEW</span>}
|
||||
{width > 82 && <small>{formatTime(work.startMs)}-{formatTime(work.endMs)}</small>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VisibilityTracks
|
||||
tracks={visibilityTracks}
|
||||
window={timelineWindow}
|
||||
width={bodyWidth}
|
||||
labelWidth={LABEL_WIDTH}
|
||||
top={visibilityTop}
|
||||
rowHeight={VISIBILITY_HEIGHT}
|
||||
/>
|
||||
|
||||
{contextLanes.length > 0 && (
|
||||
<div className="tgu-editor-context-divider" style={{ top: dividerTop, width: LABEL_WIDTH + bodyWidth }}>
|
||||
Другие КА · контекст (read-only)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contextLanes.map((lane, index) => (
|
||||
<div className="tgu-editor-timeline__lane" key={lane.spacecraftId} style={{ top: contextTop + index * OTHER_HEIGHT, height: OTHER_HEIGHT }}>
|
||||
<div className="tgu-editor-timeline__lane-label" style={{ width: LABEL_WIDTH }}>
|
||||
<span>{lane.label}</span>
|
||||
</div>
|
||||
<div className="tgu-editor-timeline__lane-body" style={{ left: LABEL_WIDTH, width: bodyWidth }}>
|
||||
{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 (
|
||||
<div
|
||||
className="tgu-editor-work tgu-editor-work--context"
|
||||
key={work.id}
|
||||
style={{ left, width, borderLeftColor: kindColor(work.kind) }}
|
||||
title={`${lane.label} · ${kindLabel(work.kind)} · ${formatTime(work.startMs)}-${formatTime(work.endMs)}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="tgu-editor-toolbar">
|
||||
<div className="tgu-editor-toolbar__identity">
|
||||
<span className="tgu-editor-type-dot" aria-hidden="true" />
|
||||
<div>
|
||||
<div className="tgu-editor-toolbar__title">{platformLabel}</div>
|
||||
<div className="tgu-editor-toolbar__meta">
|
||||
{selectedPlan ? `${selectedPlan.planId} · ${selectedPlan.status}` : "Выберите план на timeline"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tgu-editor-toolbar__group">
|
||||
<button
|
||||
className={`tgu-button ${rightMode === "shoot" ? "tgu-button--primary" : ""}`}
|
||||
type="button"
|
||||
onClick={() => onSetRightMode("shoot")}
|
||||
disabled={!selectedSpacecraftId}
|
||||
>
|
||||
+ Съёмка
|
||||
</button>
|
||||
<button
|
||||
className={`tgu-button ${rightMode === "downlink" ? "tgu-button--primary" : ""}`}
|
||||
type="button"
|
||||
onClick={() => onSetRightMode("downlink")}
|
||||
disabled={!selectedSpacecraftId}
|
||||
>
|
||||
+ Сброс
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="tgu-editor-toolbar__spacer" />
|
||||
|
||||
<div className="tgu-editor-window-control" aria-label="Окно редактора">
|
||||
<button type="button" onClick={() => onPan(-0.25)} title="Назад по времени">◀</button>
|
||||
<button type="button" onClick={() => onZoom(1.4)} title="Отдалить">−</button>
|
||||
<span>{windowLabel || formatDuration(0)}</span>
|
||||
<button type="button" onClick={() => onZoom(0.7)} title="Приблизить">+</button>
|
||||
<button type="button" onClick={() => onPan(0.25)} title="Вперёд по времени">▶</button>
|
||||
</div>
|
||||
|
||||
<div className="tgu-editor-toolbar__group">
|
||||
<button className="tgu-icon-button" type="button" onClick={onUndo} disabled={!canUndo} title="Отменить">
|
||||
↶
|
||||
</button>
|
||||
<button className="tgu-icon-button" type="button" onClick={onRedo} disabled={!canRedo} title="Вернуть">
|
||||
↷
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={`tgu-button ${conflictCount > 0 ? "tgu-editor-button--warning" : ""}`}
|
||||
type="button"
|
||||
onClick={onCheck}
|
||||
disabled={checking || !selectedSpacecraftId}
|
||||
>
|
||||
{checking ? "Проверка..." : conflictCount > 0 ? `Конфликты · ${conflictCount}` : "Проверить"}
|
||||
</button>
|
||||
|
||||
<button className="tgu-button tgu-button--primary" type="button" onClick={onApply} disabled={!dirty || applying}>
|
||||
{applying ? "Применение..." : `Применить${addedCount > 0 ? ` · +${addedCount}` : ""}`}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<DraftHistory>(() => ({
|
||||
draft: seedDraft(selectedPlan, selectedSpacecraftId),
|
||||
past: [],
|
||||
future: []
|
||||
}));
|
||||
const [window, setWindow] = useState<EditorTimelineWindow>(() => defaultEditorWindow(selectedPlan, appliedRange));
|
||||
const [rightMode, setRightMode] = useState<EditorRightMode>("none");
|
||||
const [selectedWorkId, setSelectedWorkId] = useState<string>();
|
||||
const [target, setTarget] = useState<EditorTarget>();
|
||||
const [selectedShootPass, setSelectedShootPass] = useState<EditorVisibilityWindow>();
|
||||
const [modeId, setModeId] = useState(DEFAULT_EDITOR_MODES[0].id);
|
||||
const [selectedDownlink, setSelectedDownlink] = useState<DownlinkSelection>();
|
||||
const [serviceConflicts, setServiceConflicts] = useState<EditorConflictMap>({});
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [notice, setNotice] = useState<string>();
|
||||
const [applyError, setApplyError] = useState<string>();
|
||||
const [contextSpacecraftIds, setContextSpacecraftIds] = useState<Set<string>>(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 (
|
||||
<main className="tgu-editor-workspace">
|
||||
<div className="tgu-empty">Выберите КА или план, чтобы открыть редактор включений.</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="tgu-editor-workspace">
|
||||
<EditorToolbar
|
||||
selectedSpacecraftId={selectedSpacecraftId}
|
||||
selectedPlan={selectedPlan}
|
||||
platforms={platforms}
|
||||
canUndo={history.past.length > 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 && <div className="tgu-alert tgu-alert--success tgu-editor-notice">{notice}</div>}
|
||||
{applyError && <div className="tgu-alert tgu-alert--danger tgu-editor-notice">{applyError}</div>}
|
||||
|
||||
<div className="tgu-editor-layout" style={{ gridTemplateColumns: rightMode === "none" ? "15.5rem minmax(0, 1fr) 0" : "15.5rem minmax(0, 1fr) 21rem" }}>
|
||||
<EditorRail
|
||||
selectedSpacecraftId={selectedSpacecraftId}
|
||||
selectedPlan={selectedPlan}
|
||||
platforms={platforms}
|
||||
draft={draft}
|
||||
conflictCount={conflictCount(conflicts)}
|
||||
contextSpacecraftIds={contextSpacecraftIds}
|
||||
onToggleContext={(spacecraftId) => {
|
||||
setContextSpacecraftIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(spacecraftId)) {
|
||||
next.delete(spacecraftId);
|
||||
} else {
|
||||
next.add(spacecraftId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<section className="tgu-editor-center">
|
||||
<div className="tgu-editor-map-stage">
|
||||
{rightMode === "shoot" && <div className="tgu-map-overlay">Кликните точку съёмки на карте.</div>}
|
||||
<Tgu2DMapView
|
||||
scene={mapScene}
|
||||
layers={EDITOR_MAP_LAYERS}
|
||||
redrawToken={0}
|
||||
pickMode={rightMode === "shoot"}
|
||||
targets={target ? [target] : []}
|
||||
onPickPoint={(point) => {
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
<EditorTimeline
|
||||
activeLabel={activeLabel}
|
||||
draft={draft.works}
|
||||
selectedWorkId={selectedWorkId}
|
||||
window={window}
|
||||
nowMs={Date.now()}
|
||||
conflicts={conflicts}
|
||||
contextLanes={contextLanes}
|
||||
visibilityTracks={visibilityTracks}
|
||||
onSelectWork={(workId) => {
|
||||
setSelectedWorkId(workId);
|
||||
setRightMode("inspect");
|
||||
setNotice(undefined);
|
||||
}}
|
||||
onClearSelection={() => {
|
||||
if (rightMode === "inspect") {
|
||||
setSelectedWorkId(undefined);
|
||||
setRightMode("none");
|
||||
}
|
||||
}}
|
||||
onChangeWork={onChangeWork}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{rightMode !== "none" && (
|
||||
<aside className="tgu-editor-side-panel">
|
||||
{rightMode === "inspect" && selectedWork && (
|
||||
<WorkInspector
|
||||
work={selectedWork}
|
||||
modes={modes}
|
||||
stations={stations}
|
||||
conflicts={conflicts[selectedWork.id] ?? []}
|
||||
onUpdate={onChangeWork}
|
||||
onDelete={(workId) => {
|
||||
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 && <div className="tgu-empty tgu-empty--compact">Включение не выбрано.</div>}
|
||||
{rightMode === "shoot" && (
|
||||
<ShootPanel
|
||||
target={target}
|
||||
passes={shootPasses}
|
||||
selectedPass={selectedShootPass}
|
||||
modeId={modeId}
|
||||
modes={modes}
|
||||
onSelectPass={setSelectedShootPass}
|
||||
onModeChange={setModeId}
|
||||
onAdd={addShootWork}
|
||||
onClose={() => {
|
||||
setRightMode("none");
|
||||
setTarget(undefined);
|
||||
setSelectedShootPass(undefined);
|
||||
}}
|
||||
onClearTarget={() => {
|
||||
setTarget(undefined);
|
||||
setSelectedShootPass(undefined);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{rightMode === "downlink" && (
|
||||
<DownlinkPanel
|
||||
stationOptions={stationOptions}
|
||||
selected={selectedDownlink}
|
||||
onSelect={setSelectedDownlink}
|
||||
onAdd={addDownlinkWork}
|
||||
onClose={() => {
|
||||
setRightMode("none");
|
||||
setSelectedDownlink(undefined);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
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<number>): string {
|
||||
ref.current += 1;
|
||||
return `NW-${String(ref.current).padStart(4, "0")}`;
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="tgu-editor-timeline__lane" key={track.id} style={{ top: rowTop, height: rowHeight }}>
|
||||
<div className="tgu-editor-timeline__lane-label" style={{ width: labelWidth }}>
|
||||
<span className="tgu-editor-visibility-label">
|
||||
<i style={{ background: track.color }} />
|
||||
{track.label}
|
||||
</span>
|
||||
<small>{track.kind === "target" ? "цель · видимость" : "станция · видимость"}</small>
|
||||
</div>
|
||||
<div className="tgu-editor-timeline__lane-body" style={{ left: labelWidth, width }}>
|
||||
{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 (
|
||||
<div
|
||||
className="tgu-editor-visibility-window"
|
||||
key={`${visibilityWindow.startMs}-${visibilityWindow.endMs}`}
|
||||
style={{
|
||||
left,
|
||||
width: Math.max(2, right - left),
|
||||
borderColor: track.color,
|
||||
background: `color-mix(in srgb, ${track.color} 22%, transparent)`
|
||||
}}
|
||||
title={`${track.label}: ${formatTime(visibilityWindow.startMs)}-${formatTime(visibilityWindow.endMs)}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<SaveEditedPlanResult> {
|
||||
// TODO(backend): эндпоинт сохранения правок плана отсутствует в pcp-tgu-service.
|
||||
return {
|
||||
saved: false,
|
||||
message: "Сохранение правок плана во внешний сервис ещё не реализовано в backend."
|
||||
};
|
||||
}
|
||||
@@ -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"]
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, string[]>;
|
||||
|
||||
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];
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>, 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<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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<Omit<EditorWorkBase, "id" | "kind">> &
|
||||
Partial<Pick<EditorShootWork, "modeId" | "targetLat" | "targetLon" | "targetName">> &
|
||||
Partial<Pick<EditorDownlinkWork, "stationId">>;
|
||||
|
||||
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[];
|
||||
};
|
||||
@@ -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<HTMLDivElement | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const dragRef = useRef<DragState | undefined>(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 (
|
||||
<div
|
||||
className="tgu-2d-map"
|
||||
className={`tgu-2d-map ${pickMode ? "is-picking" : ""}`}
|
||||
ref={wrapperRef}
|
||||
onClick={onClick}
|
||||
onPointerDown={onPointerDown}
|
||||
@@ -265,6 +289,17 @@ export function Tgu2DMapView({ scene, layers, redrawToken, onObjectSelect }: Tgu
|
||||
className={polygon.id === selectedPolygon?.id ? "is-selected" : "is-hovered"}
|
||||
/>
|
||||
))}
|
||||
{targets.map((target) => {
|
||||
const screen = projection.project(target);
|
||||
return (
|
||||
<g className="tgu-2d-map__target" key={target.id} transform={`translate(${screen.x} ${screen.y})`}>
|
||||
<circle r="6" />
|
||||
<line x1="-10" x2="10" y1="0" y2="0" />
|
||||
<line x1="0" x2="0" y1="-10" y2="10" />
|
||||
<text x="10" y="-8">{target.name}</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
{tooltip && (
|
||||
<div className="tgu-2d-map__tooltip" style={{ left: tooltip.x, top: tooltip.y }}>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string>();
|
||||
const [selectedPlanId, setSelectedPlanId] = useState<string>();
|
||||
const [activeTab, setActiveTab] = useState<"timeline" | "map">("timeline");
|
||||
const [activeTab, setActiveTab] = useState<TguActiveTab>("timeline");
|
||||
const [decisionInFlight, setDecisionInFlight] = useState(false);
|
||||
const [decisionNotice, setDecisionNotice] = useState<string>();
|
||||
const [decisionError, setDecisionError] = useState<string>();
|
||||
@@ -197,7 +198,7 @@ export function TguPlanningPage() {
|
||||
onClose={() => setSelectedPlanId(undefined)}
|
||||
/>
|
||||
</main>
|
||||
) : (
|
||||
) : activeTab === "map" ? (
|
||||
<Tgu2DMapTab
|
||||
range={appliedRange}
|
||||
invalidRange={invalidRange}
|
||||
@@ -210,6 +211,13 @@ export function TguPlanningPage() {
|
||||
setDecisionNotice(undefined);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<TguEditorTab
|
||||
selectedSpacecraftId={selectedPlan?.spacecraftId ?? selectedSpacecraftId}
|
||||
selectedPlan={selectedPlan}
|
||||
appliedRange={appliedRange}
|
||||
platforms={platforms}
|
||||
/>
|
||||
)}
|
||||
</TguPlanningLayout>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
</button>
|
||||
<button
|
||||
className={activeTab === "editor" ? "is-active" : ""}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === "editor"}
|
||||
onClick={() => onTabChange("editor")}
|
||||
>
|
||||
Редактор
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="tgu-field">
|
||||
|
||||
@@ -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(
|
||||
<StrictMode>
|
||||
|
||||
@@ -22,6 +22,8 @@ export type TimelineRange = {
|
||||
toMs: number;
|
||||
};
|
||||
|
||||
export type TguActiveTab = "timeline" | "map" | "editor";
|
||||
|
||||
export type TimelineRow = {
|
||||
spacecraftId: string;
|
||||
platform?: TguPlatformUi;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||