diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/INTEGRATION.md b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/INTEGRATION.md new file mode 100644 index 0000000..dbd43cf --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/INTEGRATION.md @@ -0,0 +1,179 @@ +# INTEGRATION — встраивание вкладки «Состояние группировки» + +Цель: добавить дашборд новой вкладкой в существующий шелл (рядом с +`Планы / Карта / Комплексный план / Редактор / Заявки`), переключив встроенную +симуляцию на реальные данные. + +--- + +## 1. Зависимости + +```bash +npm i d3-geo topojson-client +# react / react-dom — уже есть в проекте +``` + +Версии, на которых собран прототип: `d3-geo@3`, `topojson-client@3`, `react@18`. + +--- + +## 2. Положить модуль + +Скопируйте папку `src/` в проект, например в `src/features/constellation/`. +Все импорты внутри — относительные, так что путь произвольный. + +`dashboard.css` импортируется автоматически из `ConstellationDashboard.jsx` +(`import "./dashboard.css"`). Если используете CSS-модули/Tailwind — см. п.6. + +--- + +## 3. Карта мира (TopoJSON) + +`MapView.jsx` сейчас тянет карту с CDN: + +```js +fetch("https://unpkg.com/world-atlas@2.0.2/land-110m.json") +``` + +Для прода **положите файл локально** и раздавайте со своего origin: + +```js +// import landUrl from "../assets/land-110m.json?url"; // Vite +fetch(landUrl).then(r => r.json()).then(/* ... */) +``` + +Либо, если у вас уже есть карта (Leaflet/MapLibre/OpenLayers), рассмотрите +вариант рендерить слои группировки поверх неё — см. п.7. + +--- + +## 4. Монтирование как вкладки + +`src/index.js` отдаёт `default` = компонент `ConstellationDashboard` (без пропсов — +сейчас он сам строит данные из `data.js`/`sim.js`). + +Пример с массивом табов (адаптируйте под ваш роутер): + +```jsx +import ConstellationDashboard from "@/features/constellation"; + +const TABS = [ + { id: "plans", label: "Планы", element: }, + { id: "map", label: "Карта", element: }, + // ... + { id: "constel", label: "Группировка", element: }, // ← новая +]; +``` + +или с react-router: + +```jsx +} /> +``` + +### Важно про высоту +Корневой `.app` использует `height:100vh`, а `reference/styles.css` ставит +`body{overflow:hidden}`. Внутри вкладки с собственной шапкой это надо снять: + +- замените в `dashboard.css` `.app{height:100vh}` → `.app{height:100%}` и дайте + контейнеру вкладки реальную высоту (`height:100%` по цепочке родителей), **или** +- оставьте `100vh`, если вкладка занимает весь вьюпорт. +- правила для `body{…}` из `dashboard.css` лучше удалить/перенести, чтобы не влиять + на остальное приложение (или сразу оскопить — см. п.6). + +--- + +## 5. Контракты данных (главное) + +Сейчас данные синтетические. Замена — в двух файлах: `data.js` (что у нас есть) +и `sim.js` (где это находится во времени/пространстве). + +### 5.1 `data.js` — статический состав группировки + +```ts +type Payload = "optic" | "radar"; + +interface Satellite { + id: string; // уникальный ключ + name: string; // "Ресурс-П №3" + norad: number; + payload: Payload; // влияет на цвет и какие ROI снимает + domain: "ОПТИКА" | "ЛОКАЦИЯ"; // верхняя группировка в списке/таймлайне + group: string; // подгруппа ("Ресурс-П", "Кондор-ФКА", …) + color: string; // = COL[payload], проставляется автоматически + mem0: number; // стартовая загрузка памяти, % + orbit: { latAmp:number; period:number; phase:number; lon0:number }; // только для встроенной симуляции +} + +interface Station { id:string; name:string; lon:number; lat:number; } + +interface ROI { // заявка на съёмку (полигон) + id:string; name:string; payload:Payload; + poly:[number,number][]; // [[lon,lat], …] (без замыкающей точки) +} +``` + +Экспорты: `SATELLITES`, `STATIONS`, `ROIS`, `COL`. +Чтобы расширить до 20–30 КА и новых групп — просто добавляйте элементы; список, +сводка и таймлайн подхватывают автоматически (группировка идёт по `domain`→`group`). +Поле `orbit` нужно только встроенной симуляции (п.5.3). + +### 5.2 `sim.js` — геометрия и расписание + +Чистые функции, легко заменить на реальные источники: + +| Функция | Что возвращает | Чем заменить в проде | +|---|---|---| +| `subSat(sat, tMs, epochMs)` | `{lon,lat}` подспутниковой точки | реальная эфемерида/SGP4 по TLE на время `t` | +| `subSolar(tMs)` | `{lon,lat}` подсолнечной точки | можно оставить (астрономия точная) | +| `inShadow(lon,lat,tMs)` | в тени ли точка | по вашей модели освещённости | +| `statusAt(sat,tMs,env)` | `{state, p, roi?, station?, prohibit?, eclipse}` | **главный хук**: верните статус из вашего плана | +| `buildSchedule(...)` | интервалы `capture/downlink/prohibit/shadow` по КА на домене | отдайте готовые интервалы из API планирования | +| `buildProhibits(...)` | окна запрета по КА | из вашего сервиса ограничений (ВНЗ, питание и т.п.) | + +`state ∈ "operational" | "capture" | "downlink" | "prohibit"` (плюс флаг `eclipse`). +Приоритет в `statusAt`: запрет → съёмка → сброс → operational. + +### 5.3 Минимальная интеграция с API (рекомендуемый путь) +1. **Состав** (`SATELLITES/STATIONS/ROIS`) — из ваших справочников/`pcp-request-service`. +2. **Положение** — замените тело `subSat()` на расчёт по TLE (напр. `satellite.js`/SGP4), + `epochMs`/`period`/`latAmp`/`lon0` тогда не нужны. +3. **Расписание** — вместо `buildSchedule()` отдайте интервалы прямо из плана: + объект `{ [satId]: { capture:[], downlink:[], prohibit:[], shadow:[] } }`, + где интервал = `{ start:ms, end:ms, label?:string }`. Таймлайн и лента это рисуют как есть. +4. **Запреты** — `prohibit`-интервалы из сервиса ограничений (поле `reason` → подпись). +5. **Память борта** — если телеметрия известна, замените модельную динамику в RAF-цикле + `ConstellationDashboard.jsx` (блок `memory dynamics`) на реальные значения по `satId`. + +Домен времени задаётся в `buildEnv()` (`now-6h … now+18h`) — подгоните под горизонт плана. + +--- + +## 6. Изоляция стилей (если нужно) +`dashboard.css` — глобальные классы (`.app`, `.hdr`, `.side`, …) и `:root`-переменные. +Если боитесь конфликтов: +- оберните корень во `
` и добавьте префикс к селекторам + (или прогоните через CSS-модули / `@scope`), **или** +- оставьте как есть, если имена классов не пересекаются с вашими. +Переменные в `:root` безобидны (с префиксом `--bg0` и т.п.), но при желании +перенесите их на `.cdash`. + +--- + +## 7. (Опционально) рендер на вашей карте +Если в приложении уже есть карта (на скриншотах — тёмная equirectangular): +- слои **заявок (ROI)**, **станций**, **треков**, **полос обзора**, **линий сброса** + можно отрисовать вашими средствами (GeoJSON-слой / canvas-overlay), переиспользуя + геометрию из `sim.js` (`subSat`, footprint = окружность радиусом ~7–9° вокруг точки). +- тогда `MapView.jsx` нужен лишь как референс по слоям/цветам/порядку отрисовки. +- остальные части (`Header`, `SidePanel`, `Timeline`, RAF-часы/память) переиспользуются как есть. + +--- + +## 8. Проверка после встраивания +- [ ] Вкладка открывается, карта рисует сушу/сетку, КА движутся при Play. +- [ ] Скорости ×1…×600 меняют ход времени; «Сейчас» включает LIVE. +- [ ] Скраб по таймлайну двигает плейхед и пересчитывает статусы/карту. +- [ ] Клик по КА (список/карта/таймлайн) ставит фокус и затемняет остальных. +- [ ] Полосы памяти меняются на съёмке/сбросе. +- [ ] Высота/скролл вкладки корректны (см. п.4 «про высоту»). diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/README.md b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/README.md new file mode 100644 index 0000000..6f8e9c1 --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/README.md @@ -0,0 +1,156 @@ +# Handoff: Дашборд состояния группировки (Mission Ops «Состояние группировки») + +## Overview +Полноэкранная информационная панель (operations wall) для мониторинга орбитальной +группировки КА в реальном времени. Показывает на карте мира движущиеся аппараты, +их маршруты съёмки, сбросы данных на наземные станции, интервалы запрета работы и +тень Земли. Снизу — таймлайн событий по каждому КА. Воспроизведение управляется +кнопкой Play со скоростями ×1/×10/×60/×600, есть скраб по времени и кнопка «Сейчас». + +Назначение: дежурная смена видит «что сейчас делает группировка» одним взглядом — +кто снимает, кто сбрасывает, кто в запрете/в тени, загрузку памяти бортов и +ближайшие события. + +## About the Design Files +Файлы в этом пакете — **дизайн-референс, реализованный на HTML/React-прототипе**. +Это эталон внешнего вида и поведения, **не обязательно финальный продакшн-код**. +Задача — **воссоздать этот интерфейс в вашем кодовом окружении** (судя по скриншотам — +React + Vite, карта в стиле тёмной equirectangular-проекции), используя ваши паттерны, +ваш роутинг табов и, по возможности, **вашу существующую карту и ваши данные из API** +вместо встроенной симуляции. + +Два слоя в пакете: +- `reference/` — рабочий автономный прототип (открывается в браузере, грузит React/d3 с CDN). + Нужен только чтобы «пощупать» поведение и свериться по пикселям. +- `src/` — те же экраны, переписанные в **чистые ESM-модули** (`import/export`, без CDN + и без `window.*`). Это то, что встраивается в приложение. См. `INTEGRATION.md`. + +## Fidelity +**High-fidelity.** Цвета, типографика, отступы, состояния и анимации — финальные. +Воссоздавайте 1:1, подменяя только источник данных (см. ниже) и, при желании, +рендер карты на вашу картографическую библиотеку. + +## Screens / Views +Один экран, 4 функциональные зоны в CSS-grid/flex. + +### 1. Topbar (шапка) — `Header` +- **Layout:** горизонтальный флекс, высота `54px`, нижняя граница `1px var(--line)`, + фон `linear-gradient(var(--bg1),var(--bg0))`. Три блока: бренд (слева, `min-width:248px`), + транспорт (центр, `flex:1`, по центру), часы (справа, `min-width:248px`, выровнено вправо). +- **Бренд:** кружок-маркер `20px` с бирюзовым свечением + «СОСТОЯНИЕ ГРУППИРОВКИ» + (600, 13px, `nowrap`) и «MISSION OPS · ТГУ» (mono, 9px, `--dim`, letter-spacing .12em). +- **Транспорт:** кнопки `−1ч`, Play/Pause (бирюзовая, 42px), `+1ч`; разделитель; + сегмент скоростей `×1 ×10 ×60 ×600` (активная — бирюзовый фон); кнопка `СЕЙЧАС` + (в live-режиме — зелёная). +- **Часы:** бейдж `LIVE`/`СИМ` (LIVE — зелёная пульсирующая точка), крупное mono-время + `HH:MM:SS` (22px) + `UTC`, ниже `ДЕНЬ · ДД.ММ.ГГГГ` (mono, 10px). + +### 2. Left panel (левая панель) — `SidePanel` +Ширина `328px`, фон `--bg1`, правая граница, вертикальный флекс, скролл внутри. +- **СВОДКА ГРУППИРОВКИ:** 4 плитки (grid 4×1): СНИМАЮТ / СБРОС / ЗАПРЕТ / В ТЕНИ — + число (mono, 21px, цвет статуса) + подпись (8px, `--dim`). Под ними строка + «● ОПТИКА n ● ЛОКАЦИЯ n · N КА». +- **Поиск:** input (mono, 11.5px) — фильтрует список по name / NORAD / группе. +- **КОСМИЧЕСКИЕ АППАРАТЫ:** список, сгруппированный `ОПТИКА`→группы, `ЛОКАЦИЯ`→группы. + Каждая строка КА: цветная риска слева (cyan=оптика / violet=локация), имя (500, 12.5px), + статус справа (mono, 9px, цвет статуса, иконка ☾ если в тени), снизу `NORAD XXXXX`, + полоса памяти (4px) и `NN%`. Цвет памяти: бирюза → амбер >78% → красный >92%. + Клик по строке = фокус КА (подсветка только его событий). +- **АКТИВНЫЕ СОБЫТИЯ:** лента текущих событий (СЪЁМКА/СБРОС/ЗАПРЕТ) с целью/станцией + и обратным отсчётом «ещё M:SS». Клик = фокус соответствующего КА. + +### 3. Map (карта) — `MapView` +Занимает всё центральное пространство (`flex:1`). Canvas 2D, equirectangular-проекция +(d3-geo), повёрнута на `rotate([-10,0])`. Слои: +- База (кэш offscreen): океан `#0a0e0f`, сетка градусов (шаг 30°), суша `#171c1e` + (контур `#252d30`), прямоугольники заявок (ROI) пунктиром амбер, подписи станций (mono 9px). +- Тень Земли: ночное полушарие (d3.geoCircle от антисолнечной точки, r=90°) заливкой + `rgba(20,28,46,0.55)` с кромкой-терминатором. +- По каждому КА: синусоидальный трек (±52 мин, цвет по типу полезной нагрузки), + «полоса обзора» (footprint, geoCircle), глиф-ромб с гало и кольцом-статусом, подпись. +- Съёмка: амбер-утолщение трека + подсветка полигона заявки. +- Сброс: анимированная пунктирная синяя линия КА→станция + свечение станции. +- Станции: белые треугольники. +- Оверлеи (абсолютно): подсказка/карточка фокуса (сверху-слева), легенда (снизу-слева), + координаты курсора (снизу-справа, mono), тултип имени КА при наведении. + +### 4. Timeline (таймлайн) — `Timeline` +Высота `248px`, фон `--bg1`, верхняя граница. Шапка: «ТАЙМЛАЙН СОБЫТИЙ» + легенда +(СЪЁМКА/СБРОС/ЗАПРЕТ РАБОТЫ(штриховка)/ТЕНЬ ЗЕМЛИ) + статус режима. Тело — SVG: +- Левая колонка (`188px`): группы и имена КА с цветной точкой. +- Ось времени сверху (часовые тики, на полночь — дата). +- Дорожка каждого КА: полосы событий — тень (низ, слейт), сброс (синий), съёмка (амбер), + запрет (красная штриховка, во всю высоту). +- Вертикальный плейхед (бирюза в live, амбер в симуляции) + пунктирная линия «СЕЙЧАС» + (реальное время). Зажатие+перетаскивание = скраб времени. Клик по строке = фокус КА. + +## Interactions & Behavior +- **Play/Pause:** запуск/останов модельного времени. +- **Скорость ×1/×10/×60/×600:** множитель к ходу модельного времени (×1 = реальное). +- **СЕЙЧАС:** включает live-режим (время = системные часы, ×1, бейдж LIVE, зелёная пульсация). +- **−1ч / +1ч:** сдвиг модельного времени на час (выходит из live). +- **Скраб по таймлайну:** установка времени перетаскиванием (выходит из live). +- **Фокус КА:** клик в списке / на карте / в таймлайне / в ленте — подсвечивает один КА, + затемняет остальные; повторный клик снимает фокус. +- **Наведение на карту:** тултип имени ближайшего КА + координаты курсора (lat/lon). +- **Анимации:** бегущие пунктиры линий сброса, пульсация live-точки (1.4s), плавные + переходы полос памяти. Без бесконечных декоративных лупов на контенте. + +## State Management +Всё состояние — в корневом компоненте (`ConstellationDashboard.jsx`): +- `simRef` (модельное время, ms) — в `ref`, чтобы canvas рисовался на 60fps без ре-рендеров. +- `playingRef`, `speedRef`, `liveRef` — режим воспроизведения. +- `focusId` (state + ref) — выбранный КА. +- `memRef` — карта «загрузка памяти %» по КА (растёт при съёмке, падает при сбросе). +- `query` — строка поиска. `cursor`, `hover` — UI карты. +- RAF-цикл двигает время и память, рисует карту императивно (`mapApi.draw`), а панели/таймлайн + ре-рендерит троттлингом (~12 fps) через `setTick`. Производные (статусы, счётчики, + активные события) вычисляются в `render` из `simRef` + `memRef`. + +> Контракты данных и точки замены симуляции на реальное API — в `INTEGRATION.md`. + +## Design Tokens +Из `src/dashboard.css` (`:root`): +- **Фоны:** `--bg0 #0a0c0b` (канвас), `--bg1 #0e1211` (панели), `--panel #111514`, + `--ins #0a0e0d` (вставки/карта). +- **Линии:** `--line rgba(255,255,255,.06)`, `--line2 rgba(255,255,255,.11)`. +- **Текст:** `--hi #e7ece9`, `--mid #95a19d`, `--dim #5b6662`, `--faint #3c4644`. +- **Акценты:** `--teal #5fb3a3` (управление/активное), `--amber #d99a4e` (съёмка/заявки), + `--blue #5b9bd5` (сброс), `--red #d9685f` (запрет), `--green #5cae7e` (operational), + `--violet #a78bdb` (локация/РСА). +- **Тип нагрузки:** ОПТИКА `#56c2c8` (cyan), ЛОКАЦИЯ `#a78bdb` (violet) — `COL` в `data.js`. +- **Карта:** океан `#0a0e0f`, суша `#171c1e`/контур `#252d30`, ночь `rgba(20,28,46,.55)`. +- **Типографика:** IBM Plex Sans (UI), IBM Plex Mono (числа/координаты/ID/метки). + Заголовки секций — капс, letter-spacing .14–.16em. +- **Скругления:** 5–8px. **Высоты:** topbar 54, timeline 248, строка таймлайна 22. + +## Assets +- Карта мира: TopoJSON `world-atlas@2` → `land-110m.json` (грузится в `MapView`). + В проде положите файл локально или используйте свою карту (см. `INTEGRATION.md`). +- Иконок-картинок нет: станции/КА рисуются на canvas, бренд-маркер — CSS. +- Шрифты: IBM Plex Sans/Mono (Google Fonts; `@import` в `dashboard.css` можно убрать, + если шрифты уже есть в приложении). + +## Files +``` +design_handoff_constellation_dashboard/ +├── README.md ← этот файл (дизайн-референс) +├── INTEGRATION.md ← как встроить вкладку + контракты данных +├── reference/ ← автономный прототип «как должно выглядеть» +│ ├── preview.html ← открыть в браузере +│ ├── styles.css +│ └── js/ (data, sim, map, timeline, panels, app — версия с CDN/window.*) +└── src/ ← ESM-модули для встраивания + ├── index.js ← точка входа (default = ConstellationDashboard) + ├── ConstellationDashboard.jsx← корневой компонент вкладки + ├── MapView.jsx ← canvas-карта + ├── Timeline.jsx ← таймлайн событий + ├── Panels.jsx ← Header + SidePanel + ├── sim.js ← геометрия/расписание (заменяемо на API) + ├── data.js ← модель группировки (заменяемо на API) + ├── util.js ← хелперы + └── dashboard.css ← все стили +``` + +См. **`INTEGRATION.md`** — пошагово: зависимости, монтирование как вкладки, +контракты данных и где подключить реальные эфемериды/расписание. diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/app.jsx b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/app.jsx new file mode 100644 index 0000000..8d3c4a9 --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/app.jsx @@ -0,0 +1,203 @@ +/* ============================================================ + app.jsx — wiring, RAF clock, memory sim, derived state + ============================================================ */ +const { useState, useEffect, useRef, useMemo } = React; + +function buildEnv() { + const epochMs = Date.now(); + const { sats, stations, rois } = window.OPS; + const prohibits = SIM.buildProhibits(sats, epochMs); + const env = { epochMs, sats, stations, rois, prohibits }; + const domain = { start: epochMs - 6 * 3600e3, end: epochMs + 18 * 3600e3 }; + const sched = SIM.buildSchedule(sats, env, domain.start, domain.end, 1); + return { env, domain, sched }; +} + +function findInterval(list, t) { + if (!list) return null; + for (const iv of list) if (t >= iv.start && t < iv.end) return iv; + return null; +} + +function App() { + const cfg = useMemo(buildEnv, []); + const { env, domain, sched } = cfg; + + const apiRef = useRef({}); + const simRef = useRef(env.epochMs); + const playingRef = useRef(true); + const speedRef = useRef(10); + const liveRef = useRef(false); + const focusRef = useRef(null); + const hoverRef = useRef(null); + const memRef = useRef(Object.fromEntries(env.sats.map((s) => [s.id, s.mem0]))); + const lastT = useRef(performance.now()); + + const [, setTick] = useState(0); + const [playing, setPlaying] = useState(true); + const [speed, setSpeed] = useState(10); + const [live, setLive] = useState(false); + const [focusId, setFocusId] = useState(null); + const [query, setQuery] = useState(""); + const [cursor, setCursor] = useState(null); + const [hover, setHover] = useState(null); + + function setFocus(id) { focusRef.current = id; setFocusId(id); } + + // map api callbacks + useEffect(() => { + const a = apiRef.current; + a.simTime = () => simRef.current; + a.onCursor = (ll) => setCursor(ll); + a.onPick = (id, x, y) => { hoverRef.current = id; setHover(id ? { id, x, y } : null); }; + a.onClickPick = () => { if (hoverRef.current) setFocus(focusRef.current === hoverRef.current ? null : hoverRef.current); }; + a.onReady = () => a.draw && a.draw(simRef.current, { focusId: focusRef.current }); + }, []); + + // RAF loop + useEffect(() => { + let raf; + let acc = 0; + const loop = (now) => { + const dt = Math.min(100, now - lastT.current); + lastT.current = now; + if (playingRef.current) { + if (liveRef.current) simRef.current = Date.now(); + else simRef.current += dt * speedRef.current; + if (simRef.current > domain.end) simRef.current = domain.end; + if (simRef.current < domain.start) simRef.current = domain.start; + // memory dynamics + const secEq = (dt * speedRef.current) / 1000; + for (const s of env.sats) { + const stt = SIM.statusAt(s, simRef.current, env); + let m = memRef.current[s.id]; + if (stt.state === "capture") m += 0.7 * secEq; + else if (stt.state === "downlink") m -= 1.5 * secEq; + else m += 0.02 * secEq; // idle housekeeping drift + memRef.current[s.id] = Math.max(0, Math.min(100, m)); + } + } + if (apiRef.current.draw) apiRef.current.draw(simRef.current, { focusId: focusRef.current }); + acc += dt; + if (acc >= 80) { acc = 0; setTick((t) => (t + 1) % 1e6); } + raf = requestAnimationFrame(loop); + }; + raf = requestAnimationFrame(loop); + return () => cancelAnimationFrame(raf); + }, []); + + // ---- controls ---- + function onPlay() { const p = !playingRef.current; playingRef.current = p; setPlaying(p); if (!p) { liveRef.current = false; setLive(false); } } + function onSpeed(s) { speedRef.current = s; setSpeed(s); if (s !== 1) { liveRef.current = false; setLive(false); } } + function onNow() { liveRef.current = true; playingRef.current = true; speedRef.current = 1; simRef.current = Date.now(); setLive(true); setPlaying(true); setSpeed(1); } + function onStep(d) { liveRef.current = false; setLive(false); simRef.current = Math.max(domain.start, Math.min(domain.end, simRef.current + d)); } + function onScrub(t) { liveRef.current = false; setLive(false); simRef.current = Math.max(domain.start, Math.min(domain.end, t)); } + + // ---- derived (per render) ---- + const tNow = simRef.current; + const statuses = {}; + const counts = { capture: 0, downlink: 0, prohibit: 0, eclipse: 0, optic: 0, radar: 0, total: env.sats.length }; + for (const s of env.sats) { + const st = SIM.statusAt(s, tNow, env); + statuses[s.id] = st; + if (counts[st.state] !== undefined) counts[st.state]++; + if (st.eclipse) counts.eclipse++; + if (s.payload === "optic") counts.optic++; else counts.radar++; + } + const events = []; + for (const s of env.sats) { + const st = statuses[s.id]; + let iv = null, type = null, target = null, color = null; + if (st.state === "capture") { iv = findInterval(sched[s.id].capture, tNow); type = "СЪЁМКА"; target = st.roi.name; color = STATUS_META.capture.c; } + else if (st.state === "downlink") { iv = findInterval(sched[s.id].downlink, tNow); type = "СБРОС"; target = "ст. " + st.station.name; color = STATUS_META.downlink.c; } + else if (st.state === "prohibit") { iv = findInterval(sched[s.id].prohibit, tNow); type = "ЗАПРЕТ"; target = st.prohibit.reason; color = STATUS_META.prohibit.c; } + if (type) events.push({ id: s.id, satId: s.id, sat: s.name, type, target, color, remain: window.fmtDur((iv ? iv.end : tNow) - tNow) }); + } + events.sort((a, b) => a.remain.localeCompare(b.remain)); + + const focusSat = focusId ? env.sats.find((s) => s.id === focusId) : null; + + return ( +
+
+
+ +
+
+ +
+ {focusSat ? ( + setFocus(null)} /> + ) : ( +
КЛИК ПО АППАРАТУ — ФОКУС И ПОДСВЕТКА СОБЫТИЙ
+ )} +
+ +
+ {cursor ? `${Math.abs(cursor.lat).toFixed(3)}° ${cursor.lat >= 0 ? "с.ш." : "ю.ш."}, ${Math.abs(cursor.lon).toFixed(3)}° ${cursor.lon >= 0 ? "в.д." : "з.д."}` : "—"} +
+ {hover && hover.id !== focusId && ( +
+ {env.sats.find((s) => s.id === hover.id)?.name} +
+ )} +
+
+
+ ТАЙМЛАЙН СОБЫТИЙ +
+ + + + +
+ {live ? "РЕЖИМ LIVE · ×1" : "СИМУЛЯЦИЯ ×" + speed} · перетащите для скраба +
+ +
+
+
+
+ ); +} + +function FocusCard({ sat, status, mem, onClose }) { + const meta = STATUS_META[status.state]; + return ( +
+ +
+
{sat.name}
+
+ NORAD {sat.norad}·{sat.group} + ·{status.eclipse ? "☾ " : ""}{meta.t} +
+
+
+
{Math.abs(status.p.lat).toFixed(2)}° {status.p.lat >= 0 ? "N" : "S"} · {Math.abs(status.p.lon).toFixed(2)}° {status.p.lon >= 0 ? "E" : "W"}
+
ПАМЯТЬ {Math.round(mem)}%
+
+
+ ); +} + +function MapLegend() { + return ( +
+
СТАНЦИЯ ПРИЁМА
+
ОПТИКА
+
ЛОКАЦИЯ
+
МАРШРУТ СЪЁМКИ
+
СБРОС НА СТАНЦИЮ
+
ТЕНЬ ЗЕМЛИ
+
+ ); +} +function LegItem({ c, t, hatch }) { + return {t}; +} + +ReactDOM.createRoot(document.getElementById("root")).render(); diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/data.js b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/data.js new file mode 100644 index 0000000..7ff8b83 --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/data.js @@ -0,0 +1,73 @@ +/* ============================================================ + data.js — constellation model (plain JS, attaches window.OPS) + Группировка: ОПТИКА + ЛОКАЦИЯ, с группами КА. + ============================================================ */ +(function () { + const COL = { + optic: "#56c2c8", // оптика — cyan + radar: "#a78bdb", // локация (РСА) — violet + }; + + // --- Satellites ------------------------------------------------- + // orbit: latAmp(deg amplitude of sub-sat latitude), period(min), + // phase(0..1 of period), lon0(deg start longitude) + // mem0: initial onboard memory fill % + const sats = [ + // ============ ОПТИКА ============ + { id: "res3", name: "Ресурс-П №3", norad: 41386, payload: "optic", domain: "ОПТИКА", group: "Ресурс-П", + orbit: { latAmp: 81, period: 94.2, phase: 0.02, lon0: 12 }, mem0: 38 }, + { id: "res4", name: "Ресурс-П №4", norad: 41387, payload: "optic", domain: "ОПТИКА", group: "Ресурс-П", + orbit: { latAmp: 81, period: 94.6, phase: 0.34, lon0: 155 }, mem0: 64 }, + { id: "res5", name: "Ресурс-П №5", norad: 41388, payload: "optic", domain: "ОПТИКА", group: "Ресурс-П", + orbit: { latAmp: 81, period: 94.4, phase: 0.67, lon0: -98 }, mem0: 21 }, + { id: "kan6", name: "Канопус-В №6", norad: 43657, payload: "optic", domain: "ОПТИКА", group: "Канопус-В", + orbit: { latAmp: 78, period: 96.1, phase: 0.12, lon0: 64 }, mem0: 52 }, + { id: "kan7", name: "Канопус-В №7", norad: 43658, payload: "optic", domain: "ОПТИКА", group: "Канопус-В", + orbit: { latAmp: 78, period: 96.4, phase: 0.55, lon0: -150 }, mem0: 79 }, + + // ============ ЛОКАЦИЯ (РСА) ============ + { id: "kon1", name: "Кондор-ФКА №1", norad: 57350, payload: "radar", domain: "ЛОКАЦИЯ", group: "Кондор-ФКА", + orbit: { latAmp: 74, period: 92.7, phase: 0.20, lon0: 33 }, mem0: 45 }, + { id: "kon2", name: "Кондор-ФКА №2", norad: 62138, payload: "radar", domain: "ЛОКАЦИЯ", group: "Кондор-ФКА", + orbit: { latAmp: 74, period: 92.9, phase: 0.62, lon0: 178 }, mem0: 17 }, + { id: "kon3", name: "Кондор-ФКА №3", norad: 62139, payload: "radar", domain: "ЛОКАЦИЯ", group: "Кондор-ФКА", + orbit: { latAmp: 74, period: 92.5, phase: 0.88, lon0: -70 }, mem0: 70 }, + { id: "obz1", name: "Обзор-Р №1", norad: 60001, payload: "radar", domain: "ЛОКАЦИЯ", group: "Обзор-Р", + orbit: { latAmp: 67, period: 97.8, phase: 0.30, lon0: 100 }, mem0: 33 }, + { id: "obz2", name: "Обзор-Р №2", norad: 60002, payload: "radar", domain: "ЛОКАЦИЯ", group: "Обзор-Р", + orbit: { latAmp: 67, period: 98.1, phase: 0.74, lon0: -25 }, mem0: 58 }, + ]; + sats.forEach((s) => { s.color = COL[s.payload]; }); + + // --- Ground stations ------------------------------------------- + const stations = [ + { id: "msk", name: "Москва", lon: 37.6, lat: 55.7 }, + { id: "mur", name: "Мурманск", lon: 33.1, lat: 68.9 }, + { id: "ana", name: "Анадырь", lon: 177.5, lat: 64.7 }, + { id: "kha", name: "Хабаровск", lon: 135.1, lat: 48.5 }, + { id: "kgd", name: "Калининград", lon: 20.5, lat: 54.7 }, + { id: "svb", name: "Шпицберген", lon: 15.6, lat: 78.2 }, + { id: "ant", name: "Прогресс", lon: 76.4, lat: -69.4 }, + ]; + + // --- Targets / ROIs (заявки на съёмку) ------------------------- + // rect helper: center + half-width/height in degrees + function rect(lon, lat, hw, hh) { + return [ + [lon - hw, lat - hh], [lon + hw, lat - hh], + [lon + hw, lat + hh], [lon - hw, lat + hh], + ]; + } + const rois = [ + { id: "gib", name: "Гибралтар", payload: "optic", poly: rect(-5.4, 36.0, 3.2, 2.4) }, + { id: "blk", name: "Чёрное море", payload: "optic", poly: rect(34.0, 43.5, 6.0, 3.0) }, + { id: "jpn", name: "Японские о-ва", payload: "radar", poly: rect(140.5, 37.5, 3.4, 6.5) }, + { id: "kam", name: "Камчатка", payload: "radar", poly: rect(159.0, 56.0, 4.5, 5.0) }, + { id: "cal", name: "Калифорния", payload: "optic", poly: rect(-119.5, 36.5, 3.5, 4.5) }, + { id: "syr", name: "Сирия", payload: "optic", poly: rect(38.5, 35.0, 3.0, 2.6) }, + { id: "sah", name: "о.Сахалин", payload: "radar", poly: rect(143.0, 50.5, 2.2, 4.8) }, + { id: "kur", name: "Курилы", payload: "radar", poly: rect(151.0, 46.0, 2.0, 3.6) }, + ]; + + window.OPS = { sats, stations, rois, COL }; +})(); diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/map.jsx b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/map.jsx new file mode 100644 index 0000000..3d9f07a --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/map.jsx @@ -0,0 +1,331 @@ +/* ============================================================ + map.jsx — canvas world map (exports window.MapView) + Renders: land + graticule, ROIs, stations, night/terminator, + satellite sine tracks, footprints, capture swaths, downlinks. + Imperative draw(simTime, ui) called from the app RAF loop. + ============================================================ */ +const { useRef, useEffect } = React; + +const MAP_C = { + ocean: "#0a0e0f", + land: "#171c1e", + landStroke: "#252d30", + grat: "rgba(120,150,150,0.06)", + gratText: "rgba(150,180,178,0.25)", + amber: "#d99a4e", + blue: "#5b9bd5", + red: "#d9685f", + teal: "#5fb3a3", + night: "rgba(20,28,46,0.55)", + nightEdge: "rgba(90,120,170,0.18)", + station: "#cdd6d3", +}; + +function MapView({ env, apiRef }) { + const wrapRef = useRef(null); + const canvasRef = useRef(null); + const st = useRef({ + w: 0, h: 0, dpr: 1, proj: null, path: null, + base: null, land: null, ready: false, + }); + + // ---- load world land (topojson via unpkg, graceful fallback) ---- + useEffect(() => { + let alive = true; + fetch("https://unpkg.com/world-atlas@2.0.2/land-110m.json") + .then((r) => r.json()) + .then((topo) => { + if (!alive) return; + st.current.land = topojson.feature(topo, topo.objects.land); + buildBase(); + if (apiRef.current.onReady) apiRef.current.onReady(); + }) + .catch(() => { buildBase(); }); + return () => { alive = false; }; + }, []); + + // ---- sizing ---- + function resize() { + const wrap = wrapRef.current, cv = canvasRef.current; + if (!wrap || !cv) return; + const r = wrap.getBoundingClientRect(); + const dpr = Math.min(window.devicePixelRatio || 1, 2); + st.current.w = r.width; st.current.h = r.height; st.current.dpr = dpr; + cv.width = r.width * dpr; cv.height = r.height * dpr; + cv.style.width = r.width + "px"; cv.style.height = r.height + "px"; + const proj = d3.geoEquirectangular() + .scale(r.width / (2 * Math.PI)) + .translate([r.width / 2, r.height / 2]) + .rotate([-10, 0]); + st.current.proj = proj; + buildBase(); + } + useEffect(() => { + resize(); + const ro = new ResizeObserver(resize); + if (wrapRef.current) ro.observe(wrapRef.current); + return () => ro.disconnect(); + }, []); + + // ---- cached base layer (land, graticule, ROIs, stations) ---- + function buildBase() { + const s = st.current; + if (!s.w || !s.proj) return; + const dpr = s.dpr; + const off = document.createElement("canvas"); + off.width = s.w * dpr; off.height = s.h * dpr; + const ctx = off.getContext("2d"); + ctx.scale(dpr, dpr); + const path = d3.geoPath(s.proj, ctx); + + // ocean + ctx.fillStyle = MAP_C.ocean; + ctx.fillRect(0, 0, s.w, s.h); + + // graticule + const grat = d3.geoGraticule().step([30, 30]); + ctx.beginPath(); path(grat()); + ctx.strokeStyle = MAP_C.grat; ctx.lineWidth = 1; ctx.stroke(); + + // land + if (s.land) { + ctx.beginPath(); path(s.land); + ctx.fillStyle = MAP_C.land; ctx.fill(); + ctx.strokeStyle = MAP_C.landStroke; ctx.lineWidth = 0.6; ctx.stroke(); + } + + // ROIs (заявки) — faint amber dashed (planar path, avoids d3 winding-complement) + ctx.save(); + ctx.lineWidth = 1; ctx.setLineDash([4, 3]); + for (const r of env.rois) { + tracePoly(ctx, s.proj, r.poly); + ctx.fillStyle = "rgba(217,154,78,0.05)"; ctx.fill(); + ctx.strokeStyle = "rgba(217,154,78,0.5)"; ctx.stroke(); + } + ctx.restore(); + + // station labels + ctx.font = "9px 'IBM Plex Mono', monospace"; + ctx.textAlign = "left"; ctx.textBaseline = "middle"; + for (const stn of env.stations) { + const pt = s.proj([stn.lon, stn.lat]); if (!pt) continue; + ctx.fillStyle = "rgba(205,214,211,0.55)"; + ctx.fillText(stn.name.toUpperCase(), pt[0] + 8, pt[1] - 6); + } + + s.base = off; s.path = path; s.ready = true; + } + + // ---- helpers ---- + function project(lon, lat) { + const p = st.current.proj && st.current.proj([lon, lat]); + return p; + } + // draw a great-circle-ish track polyline, breaking at antimeridian wrap + function strokeTrack(ctx, pts, color, width, alpha) { + const s = st.current, half = s.w * 0.5; + ctx.save(); ctx.globalAlpha = alpha; ctx.strokeStyle = color; + ctx.lineWidth = width; ctx.lineJoin = "round"; ctx.lineCap = "round"; + ctx.beginPath(); + let prev = null, started = false; + for (const ll of pts) { + const p = s.proj(ll); if (!p) { started = false; continue; } + if (started && prev && Math.abs(p[0] - prev[0]) > half) started = false; + if (!started) { ctx.moveTo(p[0], p[1]); started = true; } + else ctx.lineTo(p[0], p[1]); + prev = p; + } + ctx.stroke(); ctx.restore(); + } + + // ---- main per-frame draw ---- + function draw(tMs, ui) { + const s = st.current, cv = canvasRef.current; + if (!cv || !s.proj) return; + const ctx = cv.getContext("2d"); + ctx.setTransform(s.dpr, 0, 0, s.dpr, 0, 0); + ctx.clearRect(0, 0, s.w, s.h); + if (s.base) ctx.drawImage(s.base, 0, 0, s.w, s.h); + + const path = d3.geoPath(s.proj, ctx); + const focus = ui.focusId; + + // --- night overlay (тень Земли) --- + const ss = SIM.subSolar(tMs); + const anti = [SIM.wrapLon(ss.lon + 180), -ss.lat]; + const nightPoly = d3.geoCircle().center(anti).radius(90)(); + ctx.beginPath(); path(nightPoly); + ctx.fillStyle = MAP_C.night; ctx.fill(); + ctx.lineWidth = 1; ctx.strokeStyle = MAP_C.nightEdge; ctx.stroke(); + + // --- per-sat dynamic --- + const dash = (tMs / 30) % 16; + const sats = env.sats; + // tracks first (under glyphs) + for (const sat of sats) { + const dim = focus && focus !== sat.id; + const pts = []; + for (let dm = -52; dm <= 52; dm += 1.6) { + pts.push([0, 0]); // placeholder replaced below + } + // build actual track points + pts.length = 0; + for (let dm = -52; dm <= 52; dm += 1.6) { + const p = SIM.subSat(sat, tMs + dm * 60000, env.epochMs); + pts.push([p.lon, p.lat]); + } + strokeTrack(ctx, pts, sat.color, dim ? 0.8 : 1.4, dim ? 0.10 : 0.30); + } + + // events + glyphs + for (const sat of sats) { + const dim = focus && focus !== sat.id; + const stt = SIM.statusAt(sat, tMs, env); + const p = stt.p; + const pt = s.proj([p.lon, p.lat]); if (!pt) continue; + const baseA = dim ? 0.25 : 1; + + // footprint circle (swath of view) + const fp = d3.geoCircle().center([p.lon, p.lat]).radius(sat.payload === "radar" ? 6.5 : 8)(); + ctx.beginPath(); path(fp); + ctx.fillStyle = hexA(sat.color, dim ? 0.03 : 0.055); ctx.fill(); + ctx.strokeStyle = hexA(sat.color, dim ? 0.08 : 0.16); ctx.lineWidth = 1; ctx.stroke(); + + // capture (маршрут съёмки) — amber swath along track + ROI highlight + if (stt.state === "capture") { + const seg = []; + for (let dm = -7; dm <= 7; dm += 1) { + const q = SIM.subSat(sat, tMs + dm * 60000, env.epochMs); + seg.push([q.lon, q.lat]); + } + strokeTrack(ctx, seg, MAP_C.amber, dim ? 3 : 6, dim ? 0.3 : 0.85); + ctx.setLineDash([]); + tracePoly(ctx, s.proj, stt.roi.poly); + ctx.fillStyle = "rgba(217,154,78,0.16)"; ctx.fill(); + ctx.strokeStyle = MAP_C.amber; ctx.lineWidth = 1.4; ctx.stroke(); + } + + // downlink (сброс) — animated dashed line to station + if (stt.state === "downlink") { + const sp = s.proj([stt.station.lon, stt.station.lat]); + if (sp) { + ctx.save(); + ctx.setLineDash([5, 5]); ctx.lineDashOffset = -dash; + ctx.strokeStyle = hexA(MAP_C.blue, dim ? 0.4 : 0.95); ctx.lineWidth = 1.6; + ctx.beginPath(); ctx.moveTo(pt[0], pt[1]); ctx.lineTo(sp[0], sp[1]); ctx.stroke(); + ctx.restore(); + // station glow + ctx.beginPath(); ctx.arc(sp[0], sp[1], 7, 0, 7); + ctx.fillStyle = hexA(MAP_C.blue, 0.18); ctx.fill(); + } + } + + // stations as triangles (drawn here so glow sits under) + // (drawn once globally below to avoid repaint per sat) -> skip + + // sat glyph + drawSatGlyph(ctx, pt[0], pt[1], sat, stt, baseA, sat.id === focus); + + // label + if (!dim || sat.id === focus) { + ctx.font = "10px 'IBM Plex Mono', monospace"; + ctx.textAlign = "left"; ctx.textBaseline = "middle"; + ctx.fillStyle = hexA("#e6ebe9", baseA); + ctx.fillText(sat.name, pt[0] + 11, pt[1] + 0.5); + } + } + + // stations triangles on top + for (const stn of env.stations) { + const sp = s.proj([stn.lon, stn.lat]); if (!sp) continue; + drawTriangle(ctx, sp[0], sp[1], 5, MAP_C.station); + } + } + + function drawSatGlyph(ctx, x, y, sat, stt, alpha, isFocus) { + const ringColor = stt.state === "capture" ? MAP_C.amber + : stt.state === "downlink" ? MAP_C.blue + : stt.state === "prohibit" ? MAP_C.red : null; + // halo + ctx.beginPath(); ctx.arc(x, y, 9, 0, 7); + ctx.fillStyle = hexA(sat.color, 0.10 * alpha); ctx.fill(); + // ring + if (ringColor) { + ctx.beginPath(); ctx.arc(x, y, 7.5, 0, 7); + ctx.strokeStyle = hexA(ringColor, alpha); ctx.lineWidth = 1.6; ctx.setLineDash([]); ctx.stroke(); + } + if (isFocus) { + ctx.beginPath(); ctx.arc(x, y, 11, 0, 7); + ctx.strokeStyle = hexA(MAP_C.teal, 0.9); ctx.lineWidth = 1; ctx.stroke(); + } + // diamond body + ctx.save(); ctx.translate(x, y); ctx.rotate(Math.PI / 4); + const r = 3.4; + ctx.fillStyle = stt.eclipse ? hexA(sat.color, 0.5 * alpha) : hexA(sat.color, alpha); + ctx.strokeStyle = hexA("#05080a", alpha); ctx.lineWidth = 1; + ctx.beginPath(); ctx.rect(-r, -r, r * 2, r * 2); ctx.fill(); ctx.stroke(); + ctx.restore(); + } + + function drawTriangle(ctx, x, y, r, color) { + ctx.beginPath(); + ctx.moveTo(x, y - r); ctx.lineTo(x + r, y + r * 0.8); ctx.lineTo(x - r, y + r * 0.8); + ctx.closePath(); + ctx.fillStyle = hexA(color, 0.85); ctx.fill(); + ctx.strokeStyle = "rgba(10,14,16,0.9)"; ctx.lineWidth = 0.8; ctx.stroke(); + } + + // pointer → lon/lat + nearest sat + function onMove(e) { + const s = st.current, r = canvasRef.current.getBoundingClientRect(); + const x = e.clientX - r.left, y = e.clientY - r.top; + const ll = s.proj && s.proj.invert([x, y]); + if (apiRef.current.onCursor) apiRef.current.onCursor(ll ? { lon: ll[0], lat: ll[1] } : null); + // hit test sats handled by app via getHover; expose nearest + if (apiRef.current.onPick) { + let best = null, bd = 16; + for (const sat of env.sats) { + const p = SIM.subSat(sat, apiRef.current.simTime(), env.epochMs); + const pp = s.proj([p.lon, p.lat]); if (!pp) continue; + const d = Math.hypot(pp[0] - x, pp[1] - y); + if (d < bd) { bd = d; best = sat.id; } + } + apiRef.current.onPick(best, x, y); + } + } + function onClick() { + if (apiRef.current.onClickPick) apiRef.current.onClickPick(); + } + + // expose imperative API + useEffect(() => { + apiRef.current.draw = draw; + apiRef.current.project = project; + }); + + return ( +
+ apiRef.current.onCursor && apiRef.current.onCursor(null)} onClick={onClick} /> +
+ ); +} + +// trace a small lon/lat polygon as a planar projected path (no antimeridian crossing) +function tracePoly(ctx, proj, poly) { + ctx.beginPath(); + poly.forEach((pt, i) => { + const p = proj(pt); if (!p) return; + if (i === 0) ctx.moveTo(p[0], p[1]); else ctx.lineTo(p[0], p[1]); + }); + ctx.closePath(); +} + +function hexA(hex, a) { + if (hex[0] !== "#") return hex; + const n = parseInt(hex.slice(1), 16); + const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255; + return `rgba(${r},${g},${b},${a})`; +} + +window.MapView = MapView; +window.hexA = hexA; diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/panels.jsx b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/panels.jsx new file mode 100644 index 0000000..555036d --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/panels.jsx @@ -0,0 +1,180 @@ +/* ============================================================ + panels.jsx — header + left panel (exports window.Header, + window.SidePanel). Status meta, summary, roster, activity feed. + ============================================================ */ +const STATUS_META = { + operational: { c: "#5cae7e", t: "НА ОРБИТЕ" }, + capture: { c: "#d99a4e", t: "СЪЁМКА" }, + downlink: { c: "#5b9bd5", t: "СБРОС" }, + prohibit: { c: "#d9685f", t: "ЗАПРЕТ" }, +}; + +function fmtClock(ms) { + const d = new Date(ms); + const p = (n) => String(n).padStart(2, "0"); + return { + time: `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}`, + date: `${p(d.getUTCDate())}.${p(d.getUTCMonth() + 1)}.${d.getUTCFullYear()}`, + dow: ["ВС", "ПН", "ВТ", "СР", "ЧТ", "ПТ", "СБ"][d.getUTCDay()], + }; +} +function fmtDur(ms) { + const s = Math.max(0, Math.round(ms / 1000)); + const m = Math.floor(s / 60), ss = s % 60; + return `${m}:${String(ss).padStart(2, "0")}`; +} + +function Header({ simTime, playing, speed, live, onPlay, onSpeed, onNow, onStep }) { + const ck = fmtClock(simTime); + const speeds = [1, 10, 60, 600]; + return ( +
+
+ +
+
СОСТОЯНИЕ ГРУППИРОВКИ
+
MISSION OPS · ТГУ
+
+
+ +
+ + + +
+
+ {speeds.map((s) => ( + + ))} +
+ +
+ +
+
+ {live ? "LIVE" : "СИМ"} +
+
+ {ck.time} + UTC +
+
{ck.dow} · {ck.date}
+
+
+ ); +} + +function SummaryTiles({ counts }) { + const tiles = [ + { k: "capture", label: "СНИМАЮТ", n: counts.capture, c: STATUS_META.capture.c }, + { k: "downlink", label: "СБРОС", n: counts.downlink, c: STATUS_META.downlink.c }, + { k: "prohibit", label: "ЗАПРЕТ", n: counts.prohibit, c: STATUS_META.prohibit.c }, + { k: "eclipse", label: "В ТЕНИ", n: counts.eclipse, c: "#7d92c4" }, + ]; + return ( +
+
+ {tiles.map((t) => ( +
+
{t.n}
+
{t.label}
+
+ ))} +
+
+ ОПТИКА {counts.optic} + ЛОКАЦИЯ {counts.radar} + {counts.total} КА +
+
+ ); +} + +function SatRow({ sat, status, mem, focus, onClick }) { + const meta = STATUS_META[status.state]; + const memColor = mem > 92 ? "#d9685f" : mem > 78 ? "#d99a4e" : "#5fb3a3"; + return ( +
+ +
+
+ {sat.name} + + {status.eclipse && }{meta.t} + +
+
+ NORAD {sat.norad} +
+ {Math.round(mem)}% +
+
+
+ ); +} + +function Roster({ sats, statuses, memory, focusId, onFocus, query }) { + const q = query.trim().toLowerCase(); + const filtered = sats.filter((s) => + !q || s.name.toLowerCase().includes(q) || String(s.norad).includes(q) || s.group.toLowerCase().includes(q)); + const groups = []; + let curD = null, curG = null; + filtered.forEach((s) => { + if (s.domain !== curD) { groups.push({ type: "domain", label: s.domain, color: s.color }); curD = s.domain; curG = null; } + if (s.group !== curG) { groups.push({ type: "group", label: s.group }); curG = s.group; } + groups.push({ type: "sat", sat: s }); + }); + return ( +
+ {groups.map((g, i) => { + if (g.type === "domain") return
{g.label}
; + if (g.type === "group") return
{g.label}
; + const s = g.sat; + return onFocus(focusId === s.id ? null : s.id)} />; + })} +
+ ); +} + +function ActivityFeed({ events, onFocus, focusId }) { + return ( +
+
АКТИВНЫЕ СОБЫТИЯ {events.length}
+
+ {events.length === 0 &&
нет активных событий
} + {events.map((e) => ( +
onFocus(e.satId)}> + +
+
{e.type}{e.target}
+
{e.sat}ещё {e.remain}
+
+
+ ))} +
+
+ ); +} + +function SidePanel({ sats, statuses, memory, counts, events, focusId, onFocus, query, setQuery }) { + return ( + + ); +} + +window.Header = Header; +window.SidePanel = SidePanel; +window.STATUS_META = STATUS_META; +window.fmtDur = fmtDur; diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/sim.js b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/sim.js new file mode 100644 index 0000000..afb4ac8 --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/sim.js @@ -0,0 +1,158 @@ +/* ============================================================ + sim.js — geometry + schedule (plain JS, attaches window.SIM) + ============================================================ */ +(function () { + const D2R = Math.PI / 180, R2D = 180 / Math.PI; + const TWO_PI = Math.PI * 2; + const SIDEREAL_MIN = 1436.07; // earth rotation period (min) + + function wrapLon(lon) { + lon = ((lon + 180) % 360 + 360) % 360 - 180; + return lon; + } + + // sub-satellite point at sim time t(ms), given epoch ms. + function subSat(sat, tMs, epochMs) { + const m = (tMs - epochMs) / 60000; // minutes since epoch + const o = sat.orbit; + const M = o.phase * TWO_PI + TWO_PI * (m / o.period); + const lat = o.latAmp * Math.sin(M); + // one sine wave per orbit across full longitude, drifting west via earth rotation + const lon = wrapLon(o.lon0 + 360 * (m / o.period) - 360 * (m / SIDEREAL_MIN)); + return { lon, lat }; + } + + // subsolar point (approx, declination ignored -> equatorial) + function subSolar(tMs) { + const secOfDay = (tMs / 1000) % 86400; + const lon = wrapLon(180 - (secOfDay / 86400) * 360); + // small seasonal declination wobble for life + const dayFrac = (tMs / 86400000) % 365.25; + const lat = 23.4 * Math.sin((dayFrac / 365.25) * TWO_PI - 1.4); + return { lon, lat }; + } + + // angular (great-circle) distance in degrees + function angDist(lon1, lat1, lon2, lat2) { + const φ1 = lat1 * D2R, φ2 = lat2 * D2R; + const dφ = (lat2 - lat1) * D2R; + const dλ = (lon2 - lon1) * D2R; + const a = Math.sin(dφ / 2) ** 2 + Math.cos(φ1) * Math.cos(φ2) * Math.sin(dλ / 2) ** 2; + return 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) * R2D; + } + + function inShadow(lon, lat, tMs) { + const ss = subSolar(tMs); + return angDist(lon, lat, ss.lon, ss.lat) > 90; + } + + // point in polygon (ray casting), poly = [[lon,lat],...] + function pointInPoly(lon, lat, poly) { + let inside = false; + for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { + const xi = poly[i][0], yi = poly[i][1]; + const xj = poly[j][0], yj = poly[j][1]; + const intersect = (yi > lat) !== (yj > lat) && + lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi; + if (intersect) inside = !inside; + } + return inside; + } + + const STATION_RANGE = 22; // deg ground range for downlink visibility + + // which ROI is the sat over (matching payload), null if none + function overROI(sat, p, rois) { + for (const r of rois) { + if (r.payload !== sat.payload) continue; + if (pointInPoly(p.lon, p.lat, r.poly)) return r; + } + return null; + } + // best (nearest in-range) station, null if none + function nearStation(p, stations) { + let best = null, bd = STATION_RANGE; + for (const st of stations) { + const d = angDist(p.lon, p.lat, st.lon, st.lat); + if (d < bd) { bd = d; best = st; } + } + return best; + } + + // --- prohibition windows (интервалы запрета) per sat ---------- + function buildProhibits(sats, epochMs) { + const H = 3600e3; + const out = {}; + sats.forEach((s, i) => { + const list = []; + // deterministic-ish spread + const a0 = (i * 1.9) % 22 - 4; // hours from epoch + list.push({ start: epochMs + a0 * H, end: epochMs + (a0 + 0.7 + (i % 3) * 0.15) * H, reason: "ВНЗ" }); + if (i % 2 === 0) { + const a1 = a0 + 8 + (i % 4); + list.push({ start: epochMs + a1 * H, end: epochMs + (a1 + 0.9) * H, reason: "ОГРАН. ПИТАНИЕ" }); + } + out[s.id] = list; + }); + return out; + } + function inProhibit(list, tMs) { + if (!list) return null; + for (const w of list) if (tMs >= w.start && tMs < w.end) return w; + return null; + } + + // live status of a sat at time t (priority order) + // returns {state, roi, station, eclipse, prohibit} + function statusAt(sat, tMs, env) { + const p = subSat(sat, tMs, env.epochMs); + const prohibit = inProhibit(env.prohibits[sat.id], tMs); + const eclipse = inShadow(p.lon, p.lat, tMs); + if (prohibit) return { state: "prohibit", p, prohibit, eclipse }; + const roi = overROI(sat, p, env.rois); + if (roi) return { state: "capture", p, roi, eclipse }; + const st = nearStation(p, env.stations); + if (st) return { state: "downlink", p, station: st, eclipse }; + return { state: "operational", p, eclipse }; + } + + // --- precompute event intervals over a domain for the timeline - + function buildSchedule(sats, env, domainStart, domainEnd, stepMin) { + const step = stepMin * 60000; + const sched = {}; + for (const s of sats) { + const tracks = { capture: [], downlink: [], prohibit: [], shadow: [] }; + // prohibits are explicit + (env.prohibits[s.id] || []).forEach((w) => + tracks.prohibit.push({ start: w.start, end: w.end, label: w.reason })); + let cur = { capture: null, downlink: null, shadow: null }; + for (let t = domainStart; t <= domainEnd; t += step) { + const p = subSat(s, t, env.epochMs); + const proh = inProhibit(env.prohibits[s.id], t); + const shadow = inShadow(p.lon, p.lat, t); + const roi = !proh ? overROI(s, p, env.rois) : null; + const st = !proh && !roi ? nearStation(p, env.stations) : null; + // shadow band + if (shadow) { if (!cur.shadow) cur.shadow = { start: t, end: t }; else cur.shadow.end = t; } + else if (cur.shadow) { tracks.shadow.push(cur.shadow); cur.shadow = null; } + // capture band + if (roi) { if (!cur.capture) cur.capture = { start: t, end: t, label: roi.name }; else cur.capture.end = t; } + else if (cur.capture) { tracks.capture.push(cur.capture); cur.capture = null; } + // downlink band + if (st) { if (!cur.downlink) cur.downlink = { start: t, end: t, label: st.name }; else cur.downlink.end = t; } + else if (cur.downlink) { tracks.downlink.push(cur.downlink); cur.downlink = null; } + } + if (cur.shadow) tracks.shadow.push(cur.shadow); + if (cur.capture) tracks.capture.push(cur.capture); + if (cur.downlink) tracks.downlink.push(cur.downlink); + sched[s.id] = tracks; + } + return sched; + } + + window.SIM = { + D2R, R2D, wrapLon, subSat, subSolar, angDist, inShadow, + pointInPoly, overROI, nearStation, buildProhibits, inProhibit, + statusAt, buildSchedule, STATION_RANGE, + }; +})(); diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/timeline.jsx b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/timeline.jsx new file mode 100644 index 0000000..af1f142 --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/timeline.jsx @@ -0,0 +1,160 @@ +/* ============================================================ + timeline.jsx — bottom event timeline (exports window.Timeline) + Per-sat rows with capture / downlink / prohibit / shadow bands, + moving playhead, real-"now" marker, drag-to-scrub. + ============================================================ */ +const { useRef: useRefTL, useEffect: useEffectTL, useState: useStateTL } = React; + +const TL_SPAN = 12 * 3600e3; // visible window +const TL_LEAD = 0.32; // playhead position fraction +const TL_GUTTER = 188; +const TL_ROW = 22; +const TL_AXIS = 26; + +const TL_COL = { + capture: "#d99a4e", + downlink: "#5b9bd5", + prohibit: "#d9685f", + shadow: "rgba(120,140,180,0.30)", +}; + +function Timeline({ env, sched, simTime, domain, focusId, onScrub, onFocus, live }) { + const wrapRef = useRefTL(null); + const [w, setW] = useStateTL(1200); + const dragRef = useRefTL(false); + + useEffectTL(() => { + const ro = new ResizeObserver((es) => setW(es[0].contentRect.width)); + if (wrapRef.current) ro.observe(wrapRef.current); + return () => ro.disconnect(); + }, []); + + const areaW = Math.max(200, w - TL_GUTTER); + let viewStart = simTime - TL_LEAD * TL_SPAN; + viewStart = Math.max(domain.start, Math.min(viewStart, domain.end - TL_SPAN)); + const viewEnd = viewStart + TL_SPAN; + const X = (t) => TL_GUTTER + ((t - viewStart) / TL_SPAN) * areaW; + const clampT = (t) => Math.max(viewStart, Math.min(viewEnd, t)); + + const sats = env.sats; + const rows = []; + let y = TL_AXIS; + let lastDomain = null; + sats.forEach((s) => { + if (s.domain !== lastDomain) { rows.push({ type: "group", label: s.domain, y }); y += 16; lastDomain = s.domain; } + rows.push({ type: "sat", sat: s, y }); + y += TL_ROW; + }); + const totalH = y + 6; + + // axis ticks (hourly) + const ticks = []; + const hour = 3600e3; + let t0 = Math.ceil(viewStart / hour) * hour; + for (let t = t0; t <= viewEnd; t += hour) ticks.push(t); + + function ptFromEvent(e) { + const r = wrapRef.current.getBoundingClientRect(); + const x = e.clientX - r.left; + const frac = (x - TL_GUTTER) / areaW; + return viewStart + frac * TL_SPAN; + } + function down(e) { + if (e.clientX - wrapRef.current.getBoundingClientRect().left < TL_GUTTER) return; + dragRef.current = true; onScrub(ptFromEvent(e)); + } + function move(e) { if (dragRef.current) onScrub(ptFromEvent(e)); } + function up() { dragRef.current = false; } + useEffectTL(() => { + window.addEventListener("mousemove", move); + window.addEventListener("mouseup", up); + return () => { window.removeEventListener("mousemove", move); window.removeEventListener("mouseup", up); }; + }); + + const nowX = X(Date.now()); + const headX = X(simTime); + + return ( +
+ + + + + + + + + {/* axis */} + + {ticks.map((t, i) => { + const x = X(t); const d = new Date(t); + const hh = String(d.getUTCHours()).padStart(2, "0"); + const isDay = d.getUTCHours() === 0; + return ( + + + + {isDay ? `${String(d.getUTCDate()).padStart(2, "0")}.${String(d.getUTCMonth() + 1).padStart(2, "0")}` : `${hh}:00`} + + + ); + })} + + {/* rows */} + {rows.map((r, i) => { + if (r.type === "group") { + return {r.label}; + } + const s = r.sat; + const tr = sched[s.id] || {}; + const dim = focusId && focusId !== s.id; + const cy = r.y + TL_ROW / 2; + const band = (iv, key, fill, h) => { + const x1 = X(Math.max(iv.start, viewStart)), x2 = X(Math.min(iv.end, viewEnd)); + if (x2 <= TL_GUTTER || x1 >= w || x2 - x1 < 0.6) return null; + return ; + }; + return ( + onFocus(focusId === s.id ? null : s.id)} style={{ cursor: "pointer" }}> + + + + {s.name} + {/* baseline */} + + {/* shadow (under) */} + {(tr.shadow || []).map((iv, k) => band(iv, "sh" + k, TL_COL.shadow, TL_ROW - 8))} + {/* downlink */} + {(tr.downlink || []).map((iv, k) => band(iv, "dl" + k, TL_COL.downlink, 8))} + {/* capture */} + {(tr.capture || []).map((iv, k) => band(iv, "cp" + k, TL_COL.capture, 10))} + {/* prohibit (hatched, full height) */} + {(tr.prohibit || []).map((iv, k) => { + const x1 = X(Math.max(iv.start, viewStart)), x2 = X(Math.min(iv.end, viewEnd)); + if (x2 <= TL_GUTTER || x1 >= w || x2 - x1 < 0.6) return null; + return ; + })} + + ); + })} + + {/* now marker (real wall clock) */} + {nowX > TL_GUTTER && nowX < w && ( + + + СЕЙЧАС + + )} + {/* playhead */} + + + + + +
+ ); +} + +window.Timeline = Timeline; diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/preview.html b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/preview.html new file mode 100644 index 0000000..af71843 --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/preview.html @@ -0,0 +1,34 @@ + + + + + +Состояние группировки — Mission Ops + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/styles.css b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/styles.css new file mode 100644 index 0000000..12d5124 --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/styles.css @@ -0,0 +1,180 @@ +:root { + --bg0:#0a0c0b; --bg1:#0e1211; --panel:#111514; --ins:#0a0e0d; + --line:rgba(255,255,255,0.06); --line2:rgba(255,255,255,0.11); + --hi:#e7ece9; --mid:#95a19d; --dim:#5b6662; --faint:#3c4644; + --teal:#5fb3a3; --teal-bg:rgba(95,179,163,0.13); --teal-br:rgba(95,179,163,0.35); + --amber:#d99a4e; --blue:#5b9bd5; --red:#d9685f; --green:#5cae7e; --violet:#a78bdb; +} +*{box-sizing:border-box;} +html,body{height:100%;margin:0;} +body{ + background:var(--bg0); color:var(--hi); + font-family:'IBM Plex Sans',system-ui,sans-serif; + font-size:13px; -webkit-font-smoothing:antialiased; + overflow:hidden; +} +.mono,.clock-time,.sr-norad,.map-cursor,.tl-tick,.fc-meta{font-family:'IBM Plex Mono',monospace;} +button{font-family:inherit; cursor:pointer; color:inherit;} +::-webkit-scrollbar{width:8px;height:8px;} +::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.07);border-radius:4px;} +::-webkit-scrollbar-track{background:transparent;} + +.app{height:100vh; display:flex; flex-direction:column; overflow:hidden;} + +/* ---------- header ---------- */ +.hdr{ + height:54px; flex:none; display:flex; align-items:center; + border-bottom:1px solid var(--line); background:linear-gradient(var(--bg1),var(--bg0)); + padding:0 16px; gap:18px; position:relative; z-index:5; +} +.hdr-brand{display:flex; align-items:center; gap:11px; min-width:248px;} +.brand-mark{width:20px;height:20px;border-radius:50%;border:1.5px solid var(--teal); + box-shadow:0 0 10px rgba(95,179,163,0.4) inset, 0 0 6px rgba(95,179,163,0.3);} +.brand-1{font-weight:600; font-size:13px; letter-spacing:.04em; white-space:nowrap;} +.brand-2{font-family:'IBM Plex Mono',monospace; font-size:9px; color:var(--dim); letter-spacing:.12em; margin-top:1px;} + +.hdr-transport{flex:1; display:flex; align-items:center; justify-content:center; gap:7px;} +.t-btn{background:var(--bg1); border:1px solid var(--line2); color:var(--mid); + height:30px; min-width:34px; padding:0 11px; border-radius:6px; font-size:11.5px; letter-spacing:.04em; + transition:.12s;} +.t-btn:hover{border-color:var(--teal-br); color:var(--hi);} +.t-play{font-size:12px; color:var(--teal); border-color:var(--teal-br); background:var(--teal-bg); width:42px;} +.t-play.on{background:rgba(95,179,163,0.2);} +.t-now{font-size:10.5px; letter-spacing:.1em;} +.t-now.live{background:rgba(92,174,126,0.16); border-color:rgba(92,174,126,0.5); color:var(--green);} +.t-sep{width:1px; height:20px; background:var(--line2); margin:0 4px;} +.speed-seg{display:flex; border:1px solid var(--line2); border-radius:6px; overflow:hidden; background:var(--bg1);} +.speed-seg .sp{background:transparent; border:0; color:var(--dim); height:28px; padding:0 11px; font-size:11px; + font-family:'IBM Plex Mono',monospace; border-right:1px solid var(--line); transition:.12s;} +.speed-seg .sp:last-child{border-right:0;} +.speed-seg .sp:hover{color:var(--mid);} +.speed-seg .sp.on{background:var(--teal-bg); color:var(--teal);} + +.hdr-clock{display:flex; flex-direction:column; align-items:flex-end; min-width:240px; gap:1px;} +.live-badge{display:flex; align-items:center; gap:5px; font-size:9.5px; letter-spacing:.16em; color:var(--amber); + font-family:'IBM Plex Mono',monospace;} +.live-badge .live-dot{width:6px;height:6px;border-radius:50%;background:var(--amber);} +.live-badge.on{color:var(--green);} +.live-badge.on .live-dot{background:var(--green); box-shadow:0 0 6px var(--green); animation:pulse 1.4s infinite;} +@keyframes pulse{0%,100%{opacity:1;}50%{opacity:.35;}} +.clock-main{display:flex; align-items:baseline; gap:6px;} +.clock-time{font-size:22px; font-weight:500; letter-spacing:.02em; line-height:1;} +.clock-utc{font-size:9px; color:var(--dim); letter-spacing:.1em;} +.clock-date{font-size:10px; color:var(--mid); font-family:'IBM Plex Mono',monospace; letter-spacing:.06em;} + +/* ---------- body ---------- */ +.body{flex:1; display:flex; min-height:0;} +.side{width:328px; flex:none; border-right:1px solid var(--line); background:var(--bg1); + display:flex; flex-direction:column; overflow:hidden;} +.main{flex:1; display:flex; flex-direction:column; min-width:0;} + +.sec-label{font-family:'IBM Plex Mono',monospace; font-size:9.5px; letter-spacing:.16em; color:var(--dim); + padding:13px 16px 8px; display:flex; align-items:center; gap:8px;} +.sec-label.tight{padding-top:6px;} +.sec-label .count{background:var(--bg0); border:1px solid var(--line2); border-radius:8px; padding:0 6px; + font-size:9px; color:var(--mid);} + +/* summary */ +.summary{padding:0 14px 6px;} +.summary-tiles{display:grid; grid-template-columns:repeat(4,1fr); gap:6px;} +.s-tile{background:var(--ins); border:1px solid var(--line); border-radius:7px; padding:9px 4px 7px; text-align:center;} +.s-num{font-family:'IBM Plex Mono',monospace; font-size:21px; font-weight:600; line-height:1;} +.s-lbl{font-size:8px; color:var(--dim); letter-spacing:.1em; margin-top:5px;} +.summary-foot{display:flex; align-items:center; gap:13px; padding:9px 2px 2px; font-size:10px; + color:var(--mid); font-family:'IBM Plex Mono',monospace;} +.summary-foot .dot,.r-domain .dot{width:7px;height:7px;border-radius:2px;display:inline-block;margin-right:5px;vertical-align:middle;} +.summary-foot .muted{margin-left:auto; color:var(--dim);} + +/* search */ +.side-search{padding:4px 14px 2px;} +.side-search input{width:100%; background:var(--ins); border:1px solid var(--line); border-radius:6px; + color:var(--hi); padding:7px 10px; font-size:11.5px; outline:none; font-family:'IBM Plex Mono',monospace;} +.side-search input::placeholder{color:var(--faint);} +.side-search input:focus{border-color:var(--teal-br);} + +/* roster */ +.roster{flex:1; min-height:0; overflow-y:auto; padding:0 8px 8px;} +.r-domain{font-family:'IBM Plex Mono',monospace; font-size:10px; letter-spacing:.14em; color:var(--mid); + padding:11px 8px 5px; display:flex; align-items:center;} +.r-group{font-size:9.5px; color:var(--faint); letter-spacing:.08em; padding:3px 8px 3px 20px; text-transform:uppercase;} +.sat-row{display:flex; gap:9px; padding:7px 8px 7px 10px; border-radius:7px; cursor:pointer; + align-items:center; transition:.1s; border:1px solid transparent;} +.sat-row:hover{background:rgba(255,255,255,0.025);} +.sat-row.focus{background:var(--teal-bg); border-color:var(--teal-br);} +.sr-payload{width:3px; align-self:stretch; border-radius:2px; flex:none;} +.sr-main{flex:1; min-width:0;} +.sr-top{display:flex; justify-content:space-between; align-items:baseline; gap:8px;} +.sr-name{font-size:12.5px; font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;} +.sr-status{font-size:9px; letter-spacing:.08em; font-family:'IBM Plex Mono',monospace; flex:none; display:flex; align-items:center; gap:3px;} +.sr-status .ecl{opacity:.85;} +.sr-bot{display:flex; align-items:center; gap:8px; margin-top:4px;} +.sr-norad{font-size:9px; color:var(--dim); flex:none;} +.mem-bar{flex:1; height:4px; background:rgba(255,255,255,0.07); border-radius:2px; overflow:hidden;} +.mem-bar span{display:block; height:100%; border-radius:2px; transition:width .2s;} +.sr-mem{font-size:9.5px; font-family:'IBM Plex Mono',monospace; flex:none; width:30px; text-align:right;} + +/* activity */ +.activity{flex:none; max-height:34%; display:flex; flex-direction:column; border-top:1px solid var(--line); overflow:hidden;} +.act-list{overflow-y:auto; padding:0 8px 8px;} +.act-empty{color:var(--faint); font-size:11px; padding:6px 10px; font-family:'IBM Plex Mono',monospace;} +.act-row{display:flex; gap:8px; padding:6px 8px; border-radius:6px; cursor:pointer; align-items:center;} +.act-row:hover{background:rgba(255,255,255,0.025);} +.act-row.focus{background:var(--teal-bg);} +.act-bar{width:3px; align-self:stretch; border-radius:2px; flex:none;} +.act-body{flex:1; min-width:0;} +.act-top{display:flex; gap:7px; align-items:baseline;} +.act-type{font-size:9.5px; letter-spacing:.1em; font-family:'IBM Plex Mono',monospace;} +.act-tgt{font-size:11px; color:var(--hi); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;} +.act-sub{display:flex; justify-content:space-between; font-size:9.5px; color:var(--dim); margin-top:2px; font-family:'IBM Plex Mono',monospace;} +.act-rem{color:var(--mid);} + +/* ---------- map ---------- */ +.map-area{flex:1; position:relative; min-height:0; overflow:hidden; background:var(--ins);} +.map-wrap{position:absolute; inset:0;} +.map-wrap canvas{display:block;} +.map-caption{position:absolute; top:12px; left:12px; z-index:3;} +.cap-hint{font-family:'IBM Plex Mono',monospace; font-size:9.5px; letter-spacing:.12em; color:var(--faint); + background:rgba(10,14,13,0.6); border:1px solid var(--line); padding:6px 10px; border-radius:6px; white-space:nowrap;} +.focus-card{display:flex; align-items:stretch; gap:11px; background:rgba(11,16,15,0.92); backdrop-filter:blur(6px); + border:1px solid var(--teal-br); border-radius:8px; padding:10px 13px; min-width:330px;} +.fc-pay{width:3px; border-radius:2px; flex:none;} +.fc-info{flex:1;} +.fc-name{font-size:14px; font-weight:600; display:flex; justify-content:space-between; align-items:center;} +.fc-x{background:none; border:0; color:var(--dim); font-size:12px; padding:0 2px;} +.fc-x:hover{color:var(--hi);} +.fc-meta{font-size:9.5px; color:var(--mid); margin-top:4px; display:flex; gap:6px; align-items:center; letter-spacing:.04em;} +.fc-dot{color:var(--faint);} +.fc-pos{text-align:right; font-family:'IBM Plex Mono',monospace; font-size:10px; color:var(--mid); display:flex; flex-direction:column; justify-content:center; gap:4px;} +.fc-mem b{color:var(--hi);} + +.map-legend{position:absolute; bottom:12px; left:12px; z-index:3; background:rgba(10,14,13,0.55); + border:1px solid var(--line); border-radius:7px; padding:8px 11px; display:flex; flex-direction:column; gap:5px;} +.ml-row{display:flex; align-items:center; gap:8px; font-family:'IBM Plex Mono',monospace; font-size:9px; + letter-spacing:.08em; color:var(--mid); white-space:nowrap;} +.ml-tri{width:0;height:0;border-left:4px solid transparent;border-right:4px solid transparent;border-bottom:7px solid #cdd6d3;} +.ml-sw{width:14px; height:3px; border-radius:2px; background:var(--teal);} +.ml-sw.amber{background:var(--amber);} +.ml-sw.blue{background:var(--blue);} +.ml-sw.blue.dash{background:repeating-linear-gradient(90deg,var(--blue) 0 4px,transparent 4px 7px);} +.ml-sw.night{background:rgba(90,120,170,0.5); height:9px; width:14px; border-radius:2px;} + +.map-cursor{position:absolute; bottom:10px; right:14px; z-index:3; font-family:'IBM Plex Mono',monospace; + font-size:10px; color:var(--mid); background:rgba(10,14,13,0.6); padding:3px 8px; border-radius:5px; border:1px solid var(--line);} +.map-tip{position:absolute; z-index:4; pointer-events:none; font-family:'IBM Plex Mono',monospace; font-size:10px; + background:rgba(11,16,15,0.95); border:1px solid var(--line2); padding:3px 7px; border-radius:5px; color:var(--hi); white-space:nowrap;} + +/* ---------- timeline ---------- */ +.tl-area{height:248px; flex:none; border-top:1px solid var(--line); background:var(--bg1); display:flex; flex-direction:column;} +.tl-head{height:34px; flex:none; display:flex; align-items:center; gap:18px; padding:0 16px; border-bottom:1px solid var(--line);} +.tl-title{font-family:'IBM Plex Mono',monospace; font-size:10px; letter-spacing:.16em; color:var(--mid);} +.tl-legend{display:flex; gap:15px; flex:1;} +.leg-item{display:flex; align-items:center; gap:6px; font-family:'IBM Plex Mono',monospace; font-size:9px; + letter-spacing:.08em; color:var(--dim);} +.leg-sw{width:13px; height:8px; border-radius:2px; display:inline-block;} +.leg-sw.hatch{background:repeating-linear-gradient(45deg,rgba(217,104,95,0.7) 0 2px,rgba(217,104,95,0.12) 2px 5px); border:1px solid rgba(217,104,95,0.5);} +.tl-hint{font-family:'IBM Plex Mono',monospace; font-size:9.5px; color:var(--faint); letter-spacing:.06em;} +.tl-wrap{flex:1; overflow-y:auto; overflow-x:hidden; user-select:none; cursor:crosshair;} +.tl-tick{fill:var(--dim); font-size:9px; font-family:'IBM Plex Mono',monospace;} +.tl-group{fill:var(--faint); font-size:9px; letter-spacing:.14em; font-family:'IBM Plex Mono',monospace;} +.tl-name{fill:var(--mid); font-size:10.5px;} +.tl-nowlbl{fill:rgba(95,179,163,0.6); font-size:8px; letter-spacing:.12em; font-family:'IBM Plex Mono',monospace;} diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/ConstellationDashboard.jsx b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/ConstellationDashboard.jsx new file mode 100644 index 0000000..1cabe32 --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/ConstellationDashboard.jsx @@ -0,0 +1,210 @@ +/* ============================================================ + ConstellationDashboard.jsx — wiring, RAF clock, memory sim (ESM) + Default export: — монтируется как вкладка. + ============================================================ */ +import React, { useState, useEffect, useRef, useMemo } from "react"; +import MapView from "./MapView.jsx"; +import Timeline from "./Timeline.jsx"; +import { Header, SidePanel, STATUS_META, fmtDur } from "./Panels.jsx"; +import * as SIM from "./sim.js"; +import { SATELLITES, STATIONS, ROIS, COL } from "./data.js"; +import "./dashboard.css"; + +function buildEnv() { + const epochMs = Date.now(); + const sats = SATELLITES, stations = STATIONS, rois = ROIS; + const prohibits = SIM.buildProhibits(sats, epochMs); + const env = { epochMs, sats, stations, rois, prohibits }; + const domain = { start: epochMs - 6 * 3600e3, end: epochMs + 18 * 3600e3 }; + const sched = SIM.buildSchedule(sats, env, domain.start, domain.end, 1); + return { env, domain, sched }; +} + +function findInterval(list, t) { + if (!list) return null; + for (const iv of list) if (t >= iv.start && t < iv.end) return iv; + return null; +} + +function App() { + const cfg = useMemo(buildEnv, []); + const { env, domain, sched } = cfg; + + const apiRef = useRef({}); + const simRef = useRef(env.epochMs); + const playingRef = useRef(true); + const speedRef = useRef(10); + const liveRef = useRef(false); + const focusRef = useRef(null); + const hoverRef = useRef(null); + const memRef = useRef(Object.fromEntries(env.sats.map((s) => [s.id, s.mem0]))); + const lastT = useRef(performance.now()); + + const [, setTick] = useState(0); + const [playing, setPlaying] = useState(true); + const [speed, setSpeed] = useState(10); + const [live, setLive] = useState(false); + const [focusId, setFocusId] = useState(null); + const [query, setQuery] = useState(""); + const [cursor, setCursor] = useState(null); + const [hover, setHover] = useState(null); + + function setFocus(id) { focusRef.current = id; setFocusId(id); } + + // map api callbacks + useEffect(() => { + const a = apiRef.current; + a.simTime = () => simRef.current; + a.onCursor = (ll) => setCursor(ll); + a.onPick = (id, x, y) => { hoverRef.current = id; setHover(id ? { id, x, y } : null); }; + a.onClickPick = () => { if (hoverRef.current) setFocus(focusRef.current === hoverRef.current ? null : hoverRef.current); }; + a.onReady = () => a.draw && a.draw(simRef.current, { focusId: focusRef.current }); + }, []); + + // RAF loop + useEffect(() => { + let raf; + let acc = 0; + const loop = (now) => { + const dt = Math.min(100, now - lastT.current); + lastT.current = now; + if (playingRef.current) { + if (liveRef.current) simRef.current = Date.now(); + else simRef.current += dt * speedRef.current; + if (simRef.current > domain.end) simRef.current = domain.end; + if (simRef.current < domain.start) simRef.current = domain.start; + // memory dynamics + const secEq = (dt * speedRef.current) / 1000; + for (const s of env.sats) { + const stt = SIM.statusAt(s, simRef.current, env); + let m = memRef.current[s.id]; + if (stt.state === "capture") m += 0.7 * secEq; + else if (stt.state === "downlink") m -= 1.5 * secEq; + else m += 0.02 * secEq; // idle housekeeping drift + memRef.current[s.id] = Math.max(0, Math.min(100, m)); + } + } + if (apiRef.current.draw) apiRef.current.draw(simRef.current, { focusId: focusRef.current }); + acc += dt; + if (acc >= 80) { acc = 0; setTick((t) => (t + 1) % 1e6); } + raf = requestAnimationFrame(loop); + }; + raf = requestAnimationFrame(loop); + return () => cancelAnimationFrame(raf); + }, []); + + // ---- controls ---- + function onPlay() { const p = !playingRef.current; playingRef.current = p; setPlaying(p); if (!p) { liveRef.current = false; setLive(false); } } + function onSpeed(s) { speedRef.current = s; setSpeed(s); if (s !== 1) { liveRef.current = false; setLive(false); } } + function onNow() { liveRef.current = true; playingRef.current = true; speedRef.current = 1; simRef.current = Date.now(); setLive(true); setPlaying(true); setSpeed(1); } + function onStep(d) { liveRef.current = false; setLive(false); simRef.current = Math.max(domain.start, Math.min(domain.end, simRef.current + d)); } + function onScrub(t) { liveRef.current = false; setLive(false); simRef.current = Math.max(domain.start, Math.min(domain.end, t)); } + + // ---- derived (per render) ---- + const tNow = simRef.current; + const statuses = {}; + const counts = { capture: 0, downlink: 0, prohibit: 0, eclipse: 0, optic: 0, radar: 0, total: env.sats.length }; + for (const s of env.sats) { + const st = SIM.statusAt(s, tNow, env); + statuses[s.id] = st; + if (counts[st.state] !== undefined) counts[st.state]++; + if (st.eclipse) counts.eclipse++; + if (s.payload === "optic") counts.optic++; else counts.radar++; + } + const events = []; + for (const s of env.sats) { + const st = statuses[s.id]; + let iv = null, type = null, target = null, color = null; + if (st.state === "capture") { iv = findInterval(sched[s.id].capture, tNow); type = "СЪЁМКА"; target = st.roi.name; color = STATUS_META.capture.c; } + else if (st.state === "downlink") { iv = findInterval(sched[s.id].downlink, tNow); type = "СБРОС"; target = "ст. " + st.station.name; color = STATUS_META.downlink.c; } + else if (st.state === "prohibit") { iv = findInterval(sched[s.id].prohibit, tNow); type = "ЗАПРЕТ"; target = st.prohibit.reason; color = STATUS_META.prohibit.c; } + if (type) events.push({ id: s.id, satId: s.id, sat: s.name, type, target, color, remain: fmtDur((iv ? iv.end : tNow) - tNow) }); + } + events.sort((a, b) => a.remain.localeCompare(b.remain)); + + const focusSat = focusId ? env.sats.find((s) => s.id === focusId) : null; + + return ( +
+
+
+ +
+
+ +
+ {focusSat ? ( + setFocus(null)} /> + ) : ( +
КЛИК ПО АППАРАТУ — ФОКУС И ПОДСВЕТКА СОБЫТИЙ
+ )} +
+ +
+ {cursor ? `${Math.abs(cursor.lat).toFixed(3)}° ${cursor.lat >= 0 ? "с.ш." : "ю.ш."}, ${Math.abs(cursor.lon).toFixed(3)}° ${cursor.lon >= 0 ? "в.д." : "з.д."}` : "—"} +
+ {hover && hover.id !== focusId && ( +
+ {env.sats.find((s) => s.id === hover.id)?.name} +
+ )} +
+
+
+ ТАЙМЛАЙН СОБЫТИЙ +
+ + + + +
+ {live ? "РЕЖИМ LIVE · ×1" : "СИМУЛЯЦИЯ ×" + speed} · перетащите для скраба +
+ +
+
+
+
+ ); +} + +function FocusCard({ sat, status, mem, onClose }) { + const meta = STATUS_META[status.state]; + return ( +
+ +
+
{sat.name}
+
+ NORAD {sat.norad}·{sat.group} + ·{status.eclipse ? "☾ " : ""}{meta.t} +
+
+
+
{Math.abs(status.p.lat).toFixed(2)}° {status.p.lat >= 0 ? "N" : "S"} · {Math.abs(status.p.lon).toFixed(2)}° {status.p.lon >= 0 ? "E" : "W"}
+
ПАМЯТЬ {Math.round(mem)}%
+
+
+ ); +} + +function MapLegend() { + return ( +
+
СТАНЦИЯ ПРИЁМА
+
ОПТИКА
+
ЛОКАЦИЯ
+
МАРШРУТ СЪЁМКИ
+
СБРОС НА СТАНЦИЮ
+
ТЕНЬ ЗЕМЛИ
+
+ ); +} +function LegItem({ c, t, hatch }) { + return {t}; +} + +export default App; diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/MapView.jsx b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/MapView.jsx new file mode 100644 index 0000000..1180930 --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/MapView.jsx @@ -0,0 +1,331 @@ +/* ============================================================ + MapView.jsx — canvas world map (ESM, default export) + Renders: land + graticule, ROIs, stations, night/terminator, + satellite sine tracks, footprints, capture swaths, downlinks. + Imperative draw(simTime, ui) called from the app RAF loop. + ============================================================ */ +import React, { useRef, useEffect } from "react"; +import { geoEquirectangular, geoPath, geoGraticule, geoCircle } from "d3-geo"; +import { feature } from "topojson-client"; +import * as SIM from "./sim.js"; +import { hexA } from "./util.js"; + +// keep the original call-sites (d3.* / topojson.*) working unchanged +const d3 = { geoEquirectangular, geoPath, geoGraticule, geoCircle }; +const topojson = { feature }; + +const MAP_C = { + ocean: "#0a0e0f", + land: "#171c1e", + landStroke: "#252d30", + grat: "rgba(120,150,150,0.06)", + gratText: "rgba(150,180,178,0.25)", + amber: "#d99a4e", + blue: "#5b9bd5", + red: "#d9685f", + teal: "#5fb3a3", + night: "rgba(20,28,46,0.55)", + nightEdge: "rgba(90,120,170,0.18)", + station: "#cdd6d3", +}; + +function MapView({ env, apiRef }) { + const wrapRef = useRef(null); + const canvasRef = useRef(null); + const st = useRef({ + w: 0, h: 0, dpr: 1, proj: null, path: null, + base: null, land: null, ready: false, + }); + + // ---- load world land (topojson via unpkg, graceful fallback) ---- + useEffect(() => { + let alive = true; + fetch("https://unpkg.com/world-atlas@2.0.2/land-110m.json") + .then((r) => r.json()) + .then((topo) => { + if (!alive) return; + st.current.land = topojson.feature(topo, topo.objects.land); + buildBase(); + if (apiRef.current.onReady) apiRef.current.onReady(); + }) + .catch(() => { buildBase(); }); + return () => { alive = false; }; + }, []); + + // ---- sizing ---- + function resize() { + const wrap = wrapRef.current, cv = canvasRef.current; + if (!wrap || !cv) return; + const r = wrap.getBoundingClientRect(); + const dpr = Math.min(window.devicePixelRatio || 1, 2); + st.current.w = r.width; st.current.h = r.height; st.current.dpr = dpr; + cv.width = r.width * dpr; cv.height = r.height * dpr; + cv.style.width = r.width + "px"; cv.style.height = r.height + "px"; + const proj = d3.geoEquirectangular() + .scale(r.width / (2 * Math.PI)) + .translate([r.width / 2, r.height / 2]) + .rotate([-10, 0]); + st.current.proj = proj; + buildBase(); + } + useEffect(() => { + resize(); + const ro = new ResizeObserver(resize); + if (wrapRef.current) ro.observe(wrapRef.current); + return () => ro.disconnect(); + }, []); + + // ---- cached base layer (land, graticule, ROIs, stations) ---- + function buildBase() { + const s = st.current; + if (!s.w || !s.proj) return; + const dpr = s.dpr; + const off = document.createElement("canvas"); + off.width = s.w * dpr; off.height = s.h * dpr; + const ctx = off.getContext("2d"); + ctx.scale(dpr, dpr); + const path = d3.geoPath(s.proj, ctx); + + // ocean + ctx.fillStyle = MAP_C.ocean; + ctx.fillRect(0, 0, s.w, s.h); + + // graticule + const grat = d3.geoGraticule().step([30, 30]); + ctx.beginPath(); path(grat()); + ctx.strokeStyle = MAP_C.grat; ctx.lineWidth = 1; ctx.stroke(); + + // land + if (s.land) { + ctx.beginPath(); path(s.land); + ctx.fillStyle = MAP_C.land; ctx.fill(); + ctx.strokeStyle = MAP_C.landStroke; ctx.lineWidth = 0.6; ctx.stroke(); + } + + // ROIs (заявки) — faint amber dashed (planar path, avoids d3 winding-complement) + ctx.save(); + ctx.lineWidth = 1; ctx.setLineDash([4, 3]); + for (const r of env.rois) { + tracePoly(ctx, s.proj, r.poly); + ctx.fillStyle = "rgba(217,154,78,0.05)"; ctx.fill(); + ctx.strokeStyle = "rgba(217,154,78,0.5)"; ctx.stroke(); + } + ctx.restore(); + + // station labels + ctx.font = "9px 'IBM Plex Mono', monospace"; + ctx.textAlign = "left"; ctx.textBaseline = "middle"; + for (const stn of env.stations) { + const pt = s.proj([stn.lon, stn.lat]); if (!pt) continue; + ctx.fillStyle = "rgba(205,214,211,0.55)"; + ctx.fillText(stn.name.toUpperCase(), pt[0] + 8, pt[1] - 6); + } + + s.base = off; s.path = path; s.ready = true; + } + + // ---- helpers ---- + function project(lon, lat) { + const p = st.current.proj && st.current.proj([lon, lat]); + return p; + } + // draw a great-circle-ish track polyline, breaking at antimeridian wrap + function strokeTrack(ctx, pts, color, width, alpha) { + const s = st.current, half = s.w * 0.5; + ctx.save(); ctx.globalAlpha = alpha; ctx.strokeStyle = color; + ctx.lineWidth = width; ctx.lineJoin = "round"; ctx.lineCap = "round"; + ctx.beginPath(); + let prev = null, started = false; + for (const ll of pts) { + const p = s.proj(ll); if (!p) { started = false; continue; } + if (started && prev && Math.abs(p[0] - prev[0]) > half) started = false; + if (!started) { ctx.moveTo(p[0], p[1]); started = true; } + else ctx.lineTo(p[0], p[1]); + prev = p; + } + ctx.stroke(); ctx.restore(); + } + + // ---- main per-frame draw ---- + function draw(tMs, ui) { + const s = st.current, cv = canvasRef.current; + if (!cv || !s.proj) return; + const ctx = cv.getContext("2d"); + ctx.setTransform(s.dpr, 0, 0, s.dpr, 0, 0); + ctx.clearRect(0, 0, s.w, s.h); + if (s.base) ctx.drawImage(s.base, 0, 0, s.w, s.h); + + const path = d3.geoPath(s.proj, ctx); + const focus = ui.focusId; + + // --- night overlay (тень Земли) --- + const ss = SIM.subSolar(tMs); + const anti = [SIM.wrapLon(ss.lon + 180), -ss.lat]; + const nightPoly = d3.geoCircle().center(anti).radius(90)(); + ctx.beginPath(); path(nightPoly); + ctx.fillStyle = MAP_C.night; ctx.fill(); + ctx.lineWidth = 1; ctx.strokeStyle = MAP_C.nightEdge; ctx.stroke(); + + // --- per-sat dynamic --- + const dash = (tMs / 30) % 16; + const sats = env.sats; + // tracks first (under glyphs) + for (const sat of sats) { + const dim = focus && focus !== sat.id; + const pts = []; + for (let dm = -52; dm <= 52; dm += 1.6) { + pts.push([0, 0]); // placeholder replaced below + } + // build actual track points + pts.length = 0; + for (let dm = -52; dm <= 52; dm += 1.6) { + const p = SIM.subSat(sat, tMs + dm * 60000, env.epochMs); + pts.push([p.lon, p.lat]); + } + strokeTrack(ctx, pts, sat.color, dim ? 0.8 : 1.4, dim ? 0.10 : 0.30); + } + + // events + glyphs + for (const sat of sats) { + const dim = focus && focus !== sat.id; + const stt = SIM.statusAt(sat, tMs, env); + const p = stt.p; + const pt = s.proj([p.lon, p.lat]); if (!pt) continue; + const baseA = dim ? 0.25 : 1; + + // footprint circle (swath of view) + const fp = d3.geoCircle().center([p.lon, p.lat]).radius(sat.payload === "radar" ? 6.5 : 8)(); + ctx.beginPath(); path(fp); + ctx.fillStyle = hexA(sat.color, dim ? 0.03 : 0.055); ctx.fill(); + ctx.strokeStyle = hexA(sat.color, dim ? 0.08 : 0.16); ctx.lineWidth = 1; ctx.stroke(); + + // capture (маршрут съёмки) — amber swath along track + ROI highlight + if (stt.state === "capture") { + const seg = []; + for (let dm = -7; dm <= 7; dm += 1) { + const q = SIM.subSat(sat, tMs + dm * 60000, env.epochMs); + seg.push([q.lon, q.lat]); + } + strokeTrack(ctx, seg, MAP_C.amber, dim ? 3 : 6, dim ? 0.3 : 0.85); + ctx.setLineDash([]); + tracePoly(ctx, s.proj, stt.roi.poly); + ctx.fillStyle = "rgba(217,154,78,0.16)"; ctx.fill(); + ctx.strokeStyle = MAP_C.amber; ctx.lineWidth = 1.4; ctx.stroke(); + } + + // downlink (сброс) — animated dashed line to station + if (stt.state === "downlink") { + const sp = s.proj([stt.station.lon, stt.station.lat]); + if (sp) { + ctx.save(); + ctx.setLineDash([5, 5]); ctx.lineDashOffset = -dash; + ctx.strokeStyle = hexA(MAP_C.blue, dim ? 0.4 : 0.95); ctx.lineWidth = 1.6; + ctx.beginPath(); ctx.moveTo(pt[0], pt[1]); ctx.lineTo(sp[0], sp[1]); ctx.stroke(); + ctx.restore(); + // station glow + ctx.beginPath(); ctx.arc(sp[0], sp[1], 7, 0, 7); + ctx.fillStyle = hexA(MAP_C.blue, 0.18); ctx.fill(); + } + } + + // stations as triangles (drawn here so glow sits under) + // (drawn once globally below to avoid repaint per sat) -> skip + + // sat glyph + drawSatGlyph(ctx, pt[0], pt[1], sat, stt, baseA, sat.id === focus); + + // label + if (!dim || sat.id === focus) { + ctx.font = "10px 'IBM Plex Mono', monospace"; + ctx.textAlign = "left"; ctx.textBaseline = "middle"; + ctx.fillStyle = hexA("#e6ebe9", baseA); + ctx.fillText(sat.name, pt[0] + 11, pt[1] + 0.5); + } + } + + // stations triangles on top + for (const stn of env.stations) { + const sp = s.proj([stn.lon, stn.lat]); if (!sp) continue; + drawTriangle(ctx, sp[0], sp[1], 5, MAP_C.station); + } + } + + function drawSatGlyph(ctx, x, y, sat, stt, alpha, isFocus) { + const ringColor = stt.state === "capture" ? MAP_C.amber + : stt.state === "downlink" ? MAP_C.blue + : stt.state === "prohibit" ? MAP_C.red : null; + // halo + ctx.beginPath(); ctx.arc(x, y, 9, 0, 7); + ctx.fillStyle = hexA(sat.color, 0.10 * alpha); ctx.fill(); + // ring + if (ringColor) { + ctx.beginPath(); ctx.arc(x, y, 7.5, 0, 7); + ctx.strokeStyle = hexA(ringColor, alpha); ctx.lineWidth = 1.6; ctx.setLineDash([]); ctx.stroke(); + } + if (isFocus) { + ctx.beginPath(); ctx.arc(x, y, 11, 0, 7); + ctx.strokeStyle = hexA(MAP_C.teal, 0.9); ctx.lineWidth = 1; ctx.stroke(); + } + // diamond body + ctx.save(); ctx.translate(x, y); ctx.rotate(Math.PI / 4); + const r = 3.4; + ctx.fillStyle = stt.eclipse ? hexA(sat.color, 0.5 * alpha) : hexA(sat.color, alpha); + ctx.strokeStyle = hexA("#05080a", alpha); ctx.lineWidth = 1; + ctx.beginPath(); ctx.rect(-r, -r, r * 2, r * 2); ctx.fill(); ctx.stroke(); + ctx.restore(); + } + + function drawTriangle(ctx, x, y, r, color) { + ctx.beginPath(); + ctx.moveTo(x, y - r); ctx.lineTo(x + r, y + r * 0.8); ctx.lineTo(x - r, y + r * 0.8); + ctx.closePath(); + ctx.fillStyle = hexA(color, 0.85); ctx.fill(); + ctx.strokeStyle = "rgba(10,14,16,0.9)"; ctx.lineWidth = 0.8; ctx.stroke(); + } + + // pointer → lon/lat + nearest sat + function onMove(e) { + const s = st.current, r = canvasRef.current.getBoundingClientRect(); + const x = e.clientX - r.left, y = e.clientY - r.top; + const ll = s.proj && s.proj.invert([x, y]); + if (apiRef.current.onCursor) apiRef.current.onCursor(ll ? { lon: ll[0], lat: ll[1] } : null); + // hit test sats handled by app via getHover; expose nearest + if (apiRef.current.onPick) { + let best = null, bd = 16; + for (const sat of env.sats) { + const p = SIM.subSat(sat, apiRef.current.simTime(), env.epochMs); + const pp = s.proj([p.lon, p.lat]); if (!pp) continue; + const d = Math.hypot(pp[0] - x, pp[1] - y); + if (d < bd) { bd = d; best = sat.id; } + } + apiRef.current.onPick(best, x, y); + } + } + function onClick() { + if (apiRef.current.onClickPick) apiRef.current.onClickPick(); + } + + // expose imperative API + useEffect(() => { + apiRef.current.draw = draw; + apiRef.current.project = project; + }); + + return ( +
+ apiRef.current.onCursor && apiRef.current.onCursor(null)} onClick={onClick} /> +
+ ); +} + +// trace a small lon/lat polygon as a planar projected path (no antimeridian crossing) +function tracePoly(ctx, proj, poly) { + ctx.beginPath(); + poly.forEach((pt, i) => { + const p = proj(pt); if (!p) return; + if (i === 0) ctx.moveTo(p[0], p[1]); else ctx.lineTo(p[0], p[1]); + }); + ctx.closePath(); +} + +export default MapView; diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/Panels.jsx b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/Panels.jsx new file mode 100644 index 0000000..a934870 --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/Panels.jsx @@ -0,0 +1,180 @@ +/* ============================================================ + Panels.jsx — header + left panel (ESM) + Exports: Header, SidePanel, STATUS_META, fmtDur. + ============================================================ */ +import React from "react"; +import { COL } from "./data.js"; + +const STATUS_META = { + operational: { c: "#5cae7e", t: "НА ОРБИТЕ" }, + capture: { c: "#d99a4e", t: "СЪЁМКА" }, + downlink: { c: "#5b9bd5", t: "СБРОС" }, + prohibit: { c: "#d9685f", t: "ЗАПРЕТ" }, +}; + +function fmtClock(ms) { + const d = new Date(ms); + const p = (n) => String(n).padStart(2, "0"); + return { + time: `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}`, + date: `${p(d.getUTCDate())}.${p(d.getUTCMonth() + 1)}.${d.getUTCFullYear()}`, + dow: ["ВС", "ПН", "ВТ", "СР", "ЧТ", "ПТ", "СБ"][d.getUTCDay()], + }; +} +function fmtDur(ms) { + const s = Math.max(0, Math.round(ms / 1000)); + const m = Math.floor(s / 60), ss = s % 60; + return `${m}:${String(ss).padStart(2, "0")}`; +} + +function Header({ simTime, playing, speed, live, onPlay, onSpeed, onNow, onStep }) { + const ck = fmtClock(simTime); + const speeds = [1, 10, 60, 600]; + return ( +
+
+ +
+
СОСТОЯНИЕ ГРУППИРОВКИ
+
MISSION OPS · ТГУ
+
+
+ +
+ + + +
+
+ {speeds.map((s) => ( + + ))} +
+ +
+ +
+
+ {live ? "LIVE" : "СИМ"} +
+
+ {ck.time} + UTC +
+
{ck.dow} · {ck.date}
+
+
+ ); +} + +function SummaryTiles({ counts }) { + const tiles = [ + { k: "capture", label: "СНИМАЮТ", n: counts.capture, c: STATUS_META.capture.c }, + { k: "downlink", label: "СБРОС", n: counts.downlink, c: STATUS_META.downlink.c }, + { k: "prohibit", label: "ЗАПРЕТ", n: counts.prohibit, c: STATUS_META.prohibit.c }, + { k: "eclipse", label: "В ТЕНИ", n: counts.eclipse, c: "#7d92c4" }, + ]; + return ( +
+
+ {tiles.map((t) => ( +
+
{t.n}
+
{t.label}
+
+ ))} +
+
+ ОПТИКА {counts.optic} + ЛОКАЦИЯ {counts.radar} + {counts.total} КА +
+
+ ); +} + +function SatRow({ sat, status, mem, focus, onClick }) { + const meta = STATUS_META[status.state]; + const memColor = mem > 92 ? "#d9685f" : mem > 78 ? "#d99a4e" : "#5fb3a3"; + return ( +
+ +
+
+ {sat.name} + + {status.eclipse && }{meta.t} + +
+
+ NORAD {sat.norad} +
+ {Math.round(mem)}% +
+
+
+ ); +} + +function Roster({ sats, statuses, memory, focusId, onFocus, query }) { + const q = query.trim().toLowerCase(); + const filtered = sats.filter((s) => + !q || s.name.toLowerCase().includes(q) || String(s.norad).includes(q) || s.group.toLowerCase().includes(q)); + const groups = []; + let curD = null, curG = null; + filtered.forEach((s) => { + if (s.domain !== curD) { groups.push({ type: "domain", label: s.domain, color: s.color }); curD = s.domain; curG = null; } + if (s.group !== curG) { groups.push({ type: "group", label: s.group }); curG = s.group; } + groups.push({ type: "sat", sat: s }); + }); + return ( +
+ {groups.map((g, i) => { + if (g.type === "domain") return
{g.label}
; + if (g.type === "group") return
{g.label}
; + const s = g.sat; + return onFocus(focusId === s.id ? null : s.id)} />; + })} +
+ ); +} + +function ActivityFeed({ events, onFocus, focusId }) { + return ( +
+
АКТИВНЫЕ СОБЫТИЯ {events.length}
+
+ {events.length === 0 &&
нет активных событий
} + {events.map((e) => ( +
onFocus(e.satId)}> + +
+
{e.type}{e.target}
+
{e.sat}ещё {e.remain}
+
+
+ ))} +
+
+ ); +} + +function SidePanel({ sats, statuses, memory, counts, events, focusId, onFocus, query, setQuery }) { + return ( + + ); +} + +export { Header, SidePanel, STATUS_META, fmtDur }; diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/Timeline.jsx b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/Timeline.jsx new file mode 100644 index 0000000..9351480 --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/Timeline.jsx @@ -0,0 +1,160 @@ +/* ============================================================ + Timeline.jsx — bottom event timeline (ESM, default export) + Per-sat rows with capture / downlink / prohibit / shadow bands, + moving playhead, real-"now" marker, drag-to-scrub. + ============================================================ */ +import React, { useRef as useRefTL, useEffect as useEffectTL, useState as useStateTL } from "react"; + +const TL_SPAN = 12 * 3600e3; // visible window +const TL_LEAD = 0.32; // playhead position fraction +const TL_GUTTER = 188; +const TL_ROW = 22; +const TL_AXIS = 26; + +const TL_COL = { + capture: "#d99a4e", + downlink: "#5b9bd5", + prohibit: "#d9685f", + shadow: "rgba(120,140,180,0.30)", +}; + +function Timeline({ env, sched, simTime, domain, focusId, onScrub, onFocus, live }) { + const wrapRef = useRefTL(null); + const [w, setW] = useStateTL(1200); + const dragRef = useRefTL(false); + + useEffectTL(() => { + const ro = new ResizeObserver((es) => setW(es[0].contentRect.width)); + if (wrapRef.current) ro.observe(wrapRef.current); + return () => ro.disconnect(); + }, []); + + const areaW = Math.max(200, w - TL_GUTTER); + let viewStart = simTime - TL_LEAD * TL_SPAN; + viewStart = Math.max(domain.start, Math.min(viewStart, domain.end - TL_SPAN)); + const viewEnd = viewStart + TL_SPAN; + const X = (t) => TL_GUTTER + ((t - viewStart) / TL_SPAN) * areaW; + const clampT = (t) => Math.max(viewStart, Math.min(viewEnd, t)); + + const sats = env.sats; + const rows = []; + let y = TL_AXIS; + let lastDomain = null; + sats.forEach((s) => { + if (s.domain !== lastDomain) { rows.push({ type: "group", label: s.domain, y }); y += 16; lastDomain = s.domain; } + rows.push({ type: "sat", sat: s, y }); + y += TL_ROW; + }); + const totalH = y + 6; + + // axis ticks (hourly) + const ticks = []; + const hour = 3600e3; + let t0 = Math.ceil(viewStart / hour) * hour; + for (let t = t0; t <= viewEnd; t += hour) ticks.push(t); + + function ptFromEvent(e) { + const r = wrapRef.current.getBoundingClientRect(); + const x = e.clientX - r.left; + const frac = (x - TL_GUTTER) / areaW; + return viewStart + frac * TL_SPAN; + } + function down(e) { + if (e.clientX - wrapRef.current.getBoundingClientRect().left < TL_GUTTER) return; + dragRef.current = true; onScrub(ptFromEvent(e)); + } + function move(e) { if (dragRef.current) onScrub(ptFromEvent(e)); } + function up() { dragRef.current = false; } + useEffectTL(() => { + window.addEventListener("mousemove", move); + window.addEventListener("mouseup", up); + return () => { window.removeEventListener("mousemove", move); window.removeEventListener("mouseup", up); }; + }); + + const nowX = X(Date.now()); + const headX = X(simTime); + + return ( +
+ + + + + + + + + {/* axis */} + + {ticks.map((t, i) => { + const x = X(t); const d = new Date(t); + const hh = String(d.getUTCHours()).padStart(2, "0"); + const isDay = d.getUTCHours() === 0; + return ( + + + + {isDay ? `${String(d.getUTCDate()).padStart(2, "0")}.${String(d.getUTCMonth() + 1).padStart(2, "0")}` : `${hh}:00`} + + + ); + })} + + {/* rows */} + {rows.map((r, i) => { + if (r.type === "group") { + return {r.label}; + } + const s = r.sat; + const tr = sched[s.id] || {}; + const dim = focusId && focusId !== s.id; + const cy = r.y + TL_ROW / 2; + const band = (iv, key, fill, h) => { + const x1 = X(Math.max(iv.start, viewStart)), x2 = X(Math.min(iv.end, viewEnd)); + if (x2 <= TL_GUTTER || x1 >= w || x2 - x1 < 0.6) return null; + return ; + }; + return ( + onFocus(focusId === s.id ? null : s.id)} style={{ cursor: "pointer" }}> + + + + {s.name} + {/* baseline */} + + {/* shadow (under) */} + {(tr.shadow || []).map((iv, k) => band(iv, "sh" + k, TL_COL.shadow, TL_ROW - 8))} + {/* downlink */} + {(tr.downlink || []).map((iv, k) => band(iv, "dl" + k, TL_COL.downlink, 8))} + {/* capture */} + {(tr.capture || []).map((iv, k) => band(iv, "cp" + k, TL_COL.capture, 10))} + {/* prohibit (hatched, full height) */} + {(tr.prohibit || []).map((iv, k) => { + const x1 = X(Math.max(iv.start, viewStart)), x2 = X(Math.min(iv.end, viewEnd)); + if (x2 <= TL_GUTTER || x1 >= w || x2 - x1 < 0.6) return null; + return ; + })} + + ); + })} + + {/* now marker (real wall clock) */} + {nowX > TL_GUTTER && nowX < w && ( + + + СЕЙЧАС + + )} + {/* playhead */} + + + + + +
+ ); +} + +export default Timeline; diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/dashboard.css b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/dashboard.css new file mode 100644 index 0000000..31f9ab6 --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/dashboard.css @@ -0,0 +1,183 @@ +/* Fonts — remove this @import if your app already loads IBM Plex */ +@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap"); + +:root { + --bg0:#0a0c0b; --bg1:#0e1211; --panel:#111514; --ins:#0a0e0d; + --line:rgba(255,255,255,0.06); --line2:rgba(255,255,255,0.11); + --hi:#e7ece9; --mid:#95a19d; --dim:#5b6662; --faint:#3c4644; + --teal:#5fb3a3; --teal-bg:rgba(95,179,163,0.13); --teal-br:rgba(95,179,163,0.35); + --amber:#d99a4e; --blue:#5b9bd5; --red:#d9685f; --green:#5cae7e; --violet:#a78bdb; +} +*{box-sizing:border-box;} +html,body{height:100%;margin:0;} +body{ + background:var(--bg0); color:var(--hi); + font-family:'IBM Plex Sans',system-ui,sans-serif; + font-size:13px; -webkit-font-smoothing:antialiased; + overflow:hidden; +} +.mono,.clock-time,.sr-norad,.map-cursor,.tl-tick,.fc-meta{font-family:'IBM Plex Mono',monospace;} +button{font-family:inherit; cursor:pointer; color:inherit;} +::-webkit-scrollbar{width:8px;height:8px;} +::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.07);border-radius:4px;} +::-webkit-scrollbar-track{background:transparent;} + +.app{height:100vh; display:flex; flex-direction:column; overflow:hidden;} + +/* ---------- header ---------- */ +.hdr{ + height:54px; flex:none; display:flex; align-items:center; + border-bottom:1px solid var(--line); background:linear-gradient(var(--bg1),var(--bg0)); + padding:0 16px; gap:18px; position:relative; z-index:5; +} +.hdr-brand{display:flex; align-items:center; gap:11px; min-width:248px;} +.brand-mark{width:20px;height:20px;border-radius:50%;border:1.5px solid var(--teal); + box-shadow:0 0 10px rgba(95,179,163,0.4) inset, 0 0 6px rgba(95,179,163,0.3);} +.brand-1{font-weight:600; font-size:13px; letter-spacing:.04em; white-space:nowrap;} +.brand-2{font-family:'IBM Plex Mono',monospace; font-size:9px; color:var(--dim); letter-spacing:.12em; margin-top:1px;} + +.hdr-transport{flex:1; display:flex; align-items:center; justify-content:center; gap:7px;} +.t-btn{background:var(--bg1); border:1px solid var(--line2); color:var(--mid); + height:30px; min-width:34px; padding:0 11px; border-radius:6px; font-size:11.5px; letter-spacing:.04em; + transition:.12s;} +.t-btn:hover{border-color:var(--teal-br); color:var(--hi);} +.t-play{font-size:12px; color:var(--teal); border-color:var(--teal-br); background:var(--teal-bg); width:42px;} +.t-play.on{background:rgba(95,179,163,0.2);} +.t-now{font-size:10.5px; letter-spacing:.1em;} +.t-now.live{background:rgba(92,174,126,0.16); border-color:rgba(92,174,126,0.5); color:var(--green);} +.t-sep{width:1px; height:20px; background:var(--line2); margin:0 4px;} +.speed-seg{display:flex; border:1px solid var(--line2); border-radius:6px; overflow:hidden; background:var(--bg1);} +.speed-seg .sp{background:transparent; border:0; color:var(--dim); height:28px; padding:0 11px; font-size:11px; + font-family:'IBM Plex Mono',monospace; border-right:1px solid var(--line); transition:.12s;} +.speed-seg .sp:last-child{border-right:0;} +.speed-seg .sp:hover{color:var(--mid);} +.speed-seg .sp.on{background:var(--teal-bg); color:var(--teal);} + +.hdr-clock{display:flex; flex-direction:column; align-items:flex-end; min-width:240px; gap:1px;} +.live-badge{display:flex; align-items:center; gap:5px; font-size:9.5px; letter-spacing:.16em; color:var(--amber); + font-family:'IBM Plex Mono',monospace;} +.live-badge .live-dot{width:6px;height:6px;border-radius:50%;background:var(--amber);} +.live-badge.on{color:var(--green);} +.live-badge.on .live-dot{background:var(--green); box-shadow:0 0 6px var(--green); animation:pulse 1.4s infinite;} +@keyframes pulse{0%,100%{opacity:1;}50%{opacity:.35;}} +.clock-main{display:flex; align-items:baseline; gap:6px;} +.clock-time{font-size:22px; font-weight:500; letter-spacing:.02em; line-height:1;} +.clock-utc{font-size:9px; color:var(--dim); letter-spacing:.1em;} +.clock-date{font-size:10px; color:var(--mid); font-family:'IBM Plex Mono',monospace; letter-spacing:.06em;} + +/* ---------- body ---------- */ +.body{flex:1; display:flex; min-height:0;} +.side{width:328px; flex:none; border-right:1px solid var(--line); background:var(--bg1); + display:flex; flex-direction:column; overflow:hidden;} +.main{flex:1; display:flex; flex-direction:column; min-width:0;} + +.sec-label{font-family:'IBM Plex Mono',monospace; font-size:9.5px; letter-spacing:.16em; color:var(--dim); + padding:13px 16px 8px; display:flex; align-items:center; gap:8px;} +.sec-label.tight{padding-top:6px;} +.sec-label .count{background:var(--bg0); border:1px solid var(--line2); border-radius:8px; padding:0 6px; + font-size:9px; color:var(--mid);} + +/* summary */ +.summary{padding:0 14px 6px;} +.summary-tiles{display:grid; grid-template-columns:repeat(4,1fr); gap:6px;} +.s-tile{background:var(--ins); border:1px solid var(--line); border-radius:7px; padding:9px 4px 7px; text-align:center;} +.s-num{font-family:'IBM Plex Mono',monospace; font-size:21px; font-weight:600; line-height:1;} +.s-lbl{font-size:8px; color:var(--dim); letter-spacing:.1em; margin-top:5px;} +.summary-foot{display:flex; align-items:center; gap:13px; padding:9px 2px 2px; font-size:10px; + color:var(--mid); font-family:'IBM Plex Mono',monospace;} +.summary-foot .dot,.r-domain .dot{width:7px;height:7px;border-radius:2px;display:inline-block;margin-right:5px;vertical-align:middle;} +.summary-foot .muted{margin-left:auto; color:var(--dim);} + +/* search */ +.side-search{padding:4px 14px 2px;} +.side-search input{width:100%; background:var(--ins); border:1px solid var(--line); border-radius:6px; + color:var(--hi); padding:7px 10px; font-size:11.5px; outline:none; font-family:'IBM Plex Mono',monospace;} +.side-search input::placeholder{color:var(--faint);} +.side-search input:focus{border-color:var(--teal-br);} + +/* roster */ +.roster{flex:1; min-height:0; overflow-y:auto; padding:0 8px 8px;} +.r-domain{font-family:'IBM Plex Mono',monospace; font-size:10px; letter-spacing:.14em; color:var(--mid); + padding:11px 8px 5px; display:flex; align-items:center;} +.r-group{font-size:9.5px; color:var(--faint); letter-spacing:.08em; padding:3px 8px 3px 20px; text-transform:uppercase;} +.sat-row{display:flex; gap:9px; padding:7px 8px 7px 10px; border-radius:7px; cursor:pointer; + align-items:center; transition:.1s; border:1px solid transparent;} +.sat-row:hover{background:rgba(255,255,255,0.025);} +.sat-row.focus{background:var(--teal-bg); border-color:var(--teal-br);} +.sr-payload{width:3px; align-self:stretch; border-radius:2px; flex:none;} +.sr-main{flex:1; min-width:0;} +.sr-top{display:flex; justify-content:space-between; align-items:baseline; gap:8px;} +.sr-name{font-size:12.5px; font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;} +.sr-status{font-size:9px; letter-spacing:.08em; font-family:'IBM Plex Mono',monospace; flex:none; display:flex; align-items:center; gap:3px;} +.sr-status .ecl{opacity:.85;} +.sr-bot{display:flex; align-items:center; gap:8px; margin-top:4px;} +.sr-norad{font-size:9px; color:var(--dim); flex:none;} +.mem-bar{flex:1; height:4px; background:rgba(255,255,255,0.07); border-radius:2px; overflow:hidden;} +.mem-bar span{display:block; height:100%; border-radius:2px; transition:width .2s;} +.sr-mem{font-size:9.5px; font-family:'IBM Plex Mono',monospace; flex:none; width:30px; text-align:right;} + +/* activity */ +.activity{flex:none; max-height:34%; display:flex; flex-direction:column; border-top:1px solid var(--line); overflow:hidden;} +.act-list{overflow-y:auto; padding:0 8px 8px;} +.act-empty{color:var(--faint); font-size:11px; padding:6px 10px; font-family:'IBM Plex Mono',monospace;} +.act-row{display:flex; gap:8px; padding:6px 8px; border-radius:6px; cursor:pointer; align-items:center;} +.act-row:hover{background:rgba(255,255,255,0.025);} +.act-row.focus{background:var(--teal-bg);} +.act-bar{width:3px; align-self:stretch; border-radius:2px; flex:none;} +.act-body{flex:1; min-width:0;} +.act-top{display:flex; gap:7px; align-items:baseline;} +.act-type{font-size:9.5px; letter-spacing:.1em; font-family:'IBM Plex Mono',monospace;} +.act-tgt{font-size:11px; color:var(--hi); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;} +.act-sub{display:flex; justify-content:space-between; font-size:9.5px; color:var(--dim); margin-top:2px; font-family:'IBM Plex Mono',monospace;} +.act-rem{color:var(--mid);} + +/* ---------- map ---------- */ +.map-area{flex:1; position:relative; min-height:0; overflow:hidden; background:var(--ins);} +.map-wrap{position:absolute; inset:0;} +.map-wrap canvas{display:block;} +.map-caption{position:absolute; top:12px; left:12px; z-index:3;} +.cap-hint{font-family:'IBM Plex Mono',monospace; font-size:9.5px; letter-spacing:.12em; color:var(--faint); + background:rgba(10,14,13,0.6); border:1px solid var(--line); padding:6px 10px; border-radius:6px; white-space:nowrap;} +.focus-card{display:flex; align-items:stretch; gap:11px; background:rgba(11,16,15,0.92); backdrop-filter:blur(6px); + border:1px solid var(--teal-br); border-radius:8px; padding:10px 13px; min-width:330px;} +.fc-pay{width:3px; border-radius:2px; flex:none;} +.fc-info{flex:1;} +.fc-name{font-size:14px; font-weight:600; display:flex; justify-content:space-between; align-items:center;} +.fc-x{background:none; border:0; color:var(--dim); font-size:12px; padding:0 2px;} +.fc-x:hover{color:var(--hi);} +.fc-meta{font-size:9.5px; color:var(--mid); margin-top:4px; display:flex; gap:6px; align-items:center; letter-spacing:.04em;} +.fc-dot{color:var(--faint);} +.fc-pos{text-align:right; font-family:'IBM Plex Mono',monospace; font-size:10px; color:var(--mid); display:flex; flex-direction:column; justify-content:center; gap:4px;} +.fc-mem b{color:var(--hi);} + +.map-legend{position:absolute; bottom:12px; left:12px; z-index:3; background:rgba(10,14,13,0.55); + border:1px solid var(--line); border-radius:7px; padding:8px 11px; display:flex; flex-direction:column; gap:5px;} +.ml-row{display:flex; align-items:center; gap:8px; font-family:'IBM Plex Mono',monospace; font-size:9px; + letter-spacing:.08em; color:var(--mid); white-space:nowrap;} +.ml-tri{width:0;height:0;border-left:4px solid transparent;border-right:4px solid transparent;border-bottom:7px solid #cdd6d3;} +.ml-sw{width:14px; height:3px; border-radius:2px; background:var(--teal);} +.ml-sw.amber{background:var(--amber);} +.ml-sw.blue{background:var(--blue);} +.ml-sw.blue.dash{background:repeating-linear-gradient(90deg,var(--blue) 0 4px,transparent 4px 7px);} +.ml-sw.night{background:rgba(90,120,170,0.5); height:9px; width:14px; border-radius:2px;} + +.map-cursor{position:absolute; bottom:10px; right:14px; z-index:3; font-family:'IBM Plex Mono',monospace; + font-size:10px; color:var(--mid); background:rgba(10,14,13,0.6); padding:3px 8px; border-radius:5px; border:1px solid var(--line);} +.map-tip{position:absolute; z-index:4; pointer-events:none; font-family:'IBM Plex Mono',monospace; font-size:10px; + background:rgba(11,16,15,0.95); border:1px solid var(--line2); padding:3px 7px; border-radius:5px; color:var(--hi); white-space:nowrap;} + +/* ---------- timeline ---------- */ +.tl-area{height:248px; flex:none; border-top:1px solid var(--line); background:var(--bg1); display:flex; flex-direction:column;} +.tl-head{height:34px; flex:none; display:flex; align-items:center; gap:18px; padding:0 16px; border-bottom:1px solid var(--line);} +.tl-title{font-family:'IBM Plex Mono',monospace; font-size:10px; letter-spacing:.16em; color:var(--mid);} +.tl-legend{display:flex; gap:15px; flex:1;} +.leg-item{display:flex; align-items:center; gap:6px; font-family:'IBM Plex Mono',monospace; font-size:9px; + letter-spacing:.08em; color:var(--dim);} +.leg-sw{width:13px; height:8px; border-radius:2px; display:inline-block;} +.leg-sw.hatch{background:repeating-linear-gradient(45deg,rgba(217,104,95,0.7) 0 2px,rgba(217,104,95,0.12) 2px 5px); border:1px solid rgba(217,104,95,0.5);} +.tl-hint{font-family:'IBM Plex Mono',monospace; font-size:9.5px; color:var(--faint); letter-spacing:.06em;} +.tl-wrap{flex:1; overflow-y:auto; overflow-x:hidden; user-select:none; cursor:crosshair;} +.tl-tick{fill:var(--dim); font-size:9px; font-family:'IBM Plex Mono',monospace;} +.tl-group{fill:var(--faint); font-size:9px; letter-spacing:.14em; font-family:'IBM Plex Mono',monospace;} +.tl-name{fill:var(--mid); font-size:10.5px;} +.tl-nowlbl{fill:rgba(95,179,163,0.6); font-size:8px; letter-spacing:.12em; font-family:'IBM Plex Mono',monospace;} diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/data.js b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/data.js new file mode 100644 index 0000000..d66b900 --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/data.js @@ -0,0 +1,73 @@ +/* ============================================================ + data.js — constellation model (ESM) + Группировка: ОПТИКА + ЛОКАЦИЯ, с группами КА. + См. INTEGRATION.md — эти массивы предполагается заменить данными из API. + ============================================================ */ +export const COL = { + optic: "#56c2c8", // оптика — cyan + radar: "#a78bdb", // локация (РСА) — violet + }; + + // --- Satellites ------------------------------------------------- + // orbit: latAmp(deg amplitude of sub-sat latitude), period(min), + // phase(0..1 of period), lon0(deg start longitude) + // mem0: initial onboard memory fill % + const sats = [ + // ============ ОПТИКА ============ + { id: "res3", name: "Ресурс-П №3", norad: 41386, payload: "optic", domain: "ОПТИКА", group: "Ресурс-П", + orbit: { latAmp: 81, period: 94.2, phase: 0.02, lon0: 12 }, mem0: 38 }, + { id: "res4", name: "Ресурс-П №4", norad: 41387, payload: "optic", domain: "ОПТИКА", group: "Ресурс-П", + orbit: { latAmp: 81, period: 94.6, phase: 0.34, lon0: 155 }, mem0: 64 }, + { id: "res5", name: "Ресурс-П №5", norad: 41388, payload: "optic", domain: "ОПТИКА", group: "Ресурс-П", + orbit: { latAmp: 81, period: 94.4, phase: 0.67, lon0: -98 }, mem0: 21 }, + { id: "kan6", name: "Канопус-В №6", norad: 43657, payload: "optic", domain: "ОПТИКА", group: "Канопус-В", + orbit: { latAmp: 78, period: 96.1, phase: 0.12, lon0: 64 }, mem0: 52 }, + { id: "kan7", name: "Канопус-В №7", norad: 43658, payload: "optic", domain: "ОПТИКА", group: "Канопус-В", + orbit: { latAmp: 78, period: 96.4, phase: 0.55, lon0: -150 }, mem0: 79 }, + + // ============ ЛОКАЦИЯ (РСА) ============ + { id: "kon1", name: "Кондор-ФКА №1", norad: 57350, payload: "radar", domain: "ЛОКАЦИЯ", group: "Кондор-ФКА", + orbit: { latAmp: 74, period: 92.7, phase: 0.20, lon0: 33 }, mem0: 45 }, + { id: "kon2", name: "Кондор-ФКА №2", norad: 62138, payload: "radar", domain: "ЛОКАЦИЯ", group: "Кондор-ФКА", + orbit: { latAmp: 74, period: 92.9, phase: 0.62, lon0: 178 }, mem0: 17 }, + { id: "kon3", name: "Кондор-ФКА №3", norad: 62139, payload: "radar", domain: "ЛОКАЦИЯ", group: "Кондор-ФКА", + orbit: { latAmp: 74, period: 92.5, phase: 0.88, lon0: -70 }, mem0: 70 }, + { id: "obz1", name: "Обзор-Р №1", norad: 60001, payload: "radar", domain: "ЛОКАЦИЯ", group: "Обзор-Р", + orbit: { latAmp: 67, period: 97.8, phase: 0.30, lon0: 100 }, mem0: 33 }, + { id: "obz2", name: "Обзор-Р №2", norad: 60002, payload: "radar", domain: "ЛОКАЦИЯ", group: "Обзор-Р", + orbit: { latAmp: 67, period: 98.1, phase: 0.74, lon0: -25 }, mem0: 58 }, + ]; + sats.forEach((s) => { s.color = COL[s.payload]; }); + + // --- Ground stations ------------------------------------------- + const stations = [ + { id: "msk", name: "Москва", lon: 37.6, lat: 55.7 }, + { id: "mur", name: "Мурманск", lon: 33.1, lat: 68.9 }, + { id: "ana", name: "Анадырь", lon: 177.5, lat: 64.7 }, + { id: "kha", name: "Хабаровск", lon: 135.1, lat: 48.5 }, + { id: "kgd", name: "Калининград", lon: 20.5, lat: 54.7 }, + { id: "svb", name: "Шпицберген", lon: 15.6, lat: 78.2 }, + { id: "ant", name: "Прогресс", lon: 76.4, lat: -69.4 }, + ]; + + // --- Targets / ROIs (заявки на съёмку) ------------------------- + // rect helper: center + half-width/height in degrees + function rect(lon, lat, hw, hh) { + return [ + [lon - hw, lat - hh], [lon + hw, lat - hh], + [lon + hw, lat + hh], [lon - hw, lat + hh], + ]; + } + const rois = [ + { id: "gib", name: "Гибралтар", payload: "optic", poly: rect(-5.4, 36.0, 3.2, 2.4) }, + { id: "blk", name: "Чёрное море", payload: "optic", poly: rect(34.0, 43.5, 6.0, 3.0) }, + { id: "jpn", name: "Японские о-ва", payload: "radar", poly: rect(140.5, 37.5, 3.4, 6.5) }, + { id: "kam", name: "Камчатка", payload: "radar", poly: rect(159.0, 56.0, 4.5, 5.0) }, + { id: "cal", name: "Калифорния", payload: "optic", poly: rect(-119.5, 36.5, 3.5, 4.5) }, + { id: "syr", name: "Сирия", payload: "optic", poly: rect(38.5, 35.0, 3.0, 2.6) }, + { id: "sah", name: "о.Сахалин", payload: "radar", poly: rect(143.0, 50.5, 2.2, 4.8) }, + { id: "kur", name: "Курилы", payload: "radar", poly: rect(151.0, 46.0, 2.0, 3.6) }, + ]; + +export { sats as SATELLITES, stations as STATIONS, rois as ROIS }; + diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/index.js b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/index.js new file mode 100644 index 0000000..ab53214 --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/index.js @@ -0,0 +1,10 @@ +/* index.js — public entry for the dashboard module */ +export { default } from "./ConstellationDashboard.jsx"; +export { default as ConstellationDashboard } from "./ConstellationDashboard.jsx"; + +// granular exports (in case you want to recompose the layout) +export { default as MapView } from "./MapView.jsx"; +export { default as Timeline } from "./Timeline.jsx"; +export { Header, SidePanel, STATUS_META, fmtDur } from "./Panels.jsx"; +export * as SIM from "./sim.js"; +export { SATELLITES, STATIONS, ROIS, COL } from "./data.js"; diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/sim.js b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/sim.js new file mode 100644 index 0000000..2494881 --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/sim.js @@ -0,0 +1,158 @@ +/* ============================================================ + sim.js — geometry + schedule (ESM) + Чистая геометрия подспутниковой точки, тень, расписание событий. + См. INTEGRATION.md — subSat()/statusAt() можно заменить на реальную эфемериду/API. + ============================================================ */ +const D2R = Math.PI / 180, R2D = 180 / Math.PI; + const TWO_PI = Math.PI * 2; + const SIDEREAL_MIN = 1436.07; // earth rotation period (min) + + function wrapLon(lon) { + lon = ((lon + 180) % 360 + 360) % 360 - 180; + return lon; + } + + // sub-satellite point at sim time t(ms), given epoch ms. + function subSat(sat, tMs, epochMs) { + const m = (tMs - epochMs) / 60000; // minutes since epoch + const o = sat.orbit; + const M = o.phase * TWO_PI + TWO_PI * (m / o.period); + const lat = o.latAmp * Math.sin(M); + // one sine wave per orbit across full longitude, drifting west via earth rotation + const lon = wrapLon(o.lon0 + 360 * (m / o.period) - 360 * (m / SIDEREAL_MIN)); + return { lon, lat }; + } + + // subsolar point (approx, declination ignored -> equatorial) + function subSolar(tMs) { + const secOfDay = (tMs / 1000) % 86400; + const lon = wrapLon(180 - (secOfDay / 86400) * 360); + // small seasonal declination wobble for life + const dayFrac = (tMs / 86400000) % 365.25; + const lat = 23.4 * Math.sin((dayFrac / 365.25) * TWO_PI - 1.4); + return { lon, lat }; + } + + // angular (great-circle) distance in degrees + function angDist(lon1, lat1, lon2, lat2) { + const φ1 = lat1 * D2R, φ2 = lat2 * D2R; + const dφ = (lat2 - lat1) * D2R; + const dλ = (lon2 - lon1) * D2R; + const a = Math.sin(dφ / 2) ** 2 + Math.cos(φ1) * Math.cos(φ2) * Math.sin(dλ / 2) ** 2; + return 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) * R2D; + } + + function inShadow(lon, lat, tMs) { + const ss = subSolar(tMs); + return angDist(lon, lat, ss.lon, ss.lat) > 90; + } + + // point in polygon (ray casting), poly = [[lon,lat],...] + function pointInPoly(lon, lat, poly) { + let inside = false; + for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { + const xi = poly[i][0], yi = poly[i][1]; + const xj = poly[j][0], yj = poly[j][1]; + const intersect = (yi > lat) !== (yj > lat) && + lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi; + if (intersect) inside = !inside; + } + return inside; + } + + const STATION_RANGE = 22; // deg ground range for downlink visibility + + // which ROI is the sat over (matching payload), null if none + function overROI(sat, p, rois) { + for (const r of rois) { + if (r.payload !== sat.payload) continue; + if (pointInPoly(p.lon, p.lat, r.poly)) return r; + } + return null; + } + // best (nearest in-range) station, null if none + function nearStation(p, stations) { + let best = null, bd = STATION_RANGE; + for (const st of stations) { + const d = angDist(p.lon, p.lat, st.lon, st.lat); + if (d < bd) { bd = d; best = st; } + } + return best; + } + + // --- prohibition windows (интервалы запрета) per sat ---------- + function buildProhibits(sats, epochMs) { + const H = 3600e3; + const out = {}; + sats.forEach((s, i) => { + const list = []; + // deterministic-ish spread + const a0 = (i * 1.9) % 22 - 4; // hours from epoch + list.push({ start: epochMs + a0 * H, end: epochMs + (a0 + 0.7 + (i % 3) * 0.15) * H, reason: "ВНЗ" }); + if (i % 2 === 0) { + const a1 = a0 + 8 + (i % 4); + list.push({ start: epochMs + a1 * H, end: epochMs + (a1 + 0.9) * H, reason: "ОГРАН. ПИТАНИЕ" }); + } + out[s.id] = list; + }); + return out; + } + function inProhibit(list, tMs) { + if (!list) return null; + for (const w of list) if (tMs >= w.start && tMs < w.end) return w; + return null; + } + + // live status of a sat at time t (priority order) + // returns {state, roi, station, eclipse, prohibit} + function statusAt(sat, tMs, env) { + const p = subSat(sat, tMs, env.epochMs); + const prohibit = inProhibit(env.prohibits[sat.id], tMs); + const eclipse = inShadow(p.lon, p.lat, tMs); + if (prohibit) return { state: "prohibit", p, prohibit, eclipse }; + const roi = overROI(sat, p, env.rois); + if (roi) return { state: "capture", p, roi, eclipse }; + const st = nearStation(p, env.stations); + if (st) return { state: "downlink", p, station: st, eclipse }; + return { state: "operational", p, eclipse }; + } + + // --- precompute event intervals over a domain for the timeline - + function buildSchedule(sats, env, domainStart, domainEnd, stepMin) { + const step = stepMin * 60000; + const sched = {}; + for (const s of sats) { + const tracks = { capture: [], downlink: [], prohibit: [], shadow: [] }; + // prohibits are explicit + (env.prohibits[s.id] || []).forEach((w) => + tracks.prohibit.push({ start: w.start, end: w.end, label: w.reason })); + let cur = { capture: null, downlink: null, shadow: null }; + for (let t = domainStart; t <= domainEnd; t += step) { + const p = subSat(s, t, env.epochMs); + const proh = inProhibit(env.prohibits[s.id], t); + const shadow = inShadow(p.lon, p.lat, t); + const roi = !proh ? overROI(s, p, env.rois) : null; + const st = !proh && !roi ? nearStation(p, env.stations) : null; + // shadow band + if (shadow) { if (!cur.shadow) cur.shadow = { start: t, end: t }; else cur.shadow.end = t; } + else if (cur.shadow) { tracks.shadow.push(cur.shadow); cur.shadow = null; } + // capture band + if (roi) { if (!cur.capture) cur.capture = { start: t, end: t, label: roi.name }; else cur.capture.end = t; } + else if (cur.capture) { tracks.capture.push(cur.capture); cur.capture = null; } + // downlink band + if (st) { if (!cur.downlink) cur.downlink = { start: t, end: t, label: st.name }; else cur.downlink.end = t; } + else if (cur.downlink) { tracks.downlink.push(cur.downlink); cur.downlink = null; } + } + if (cur.shadow) tracks.shadow.push(cur.shadow); + if (cur.capture) tracks.capture.push(cur.capture); + if (cur.downlink) tracks.downlink.push(cur.downlink); + sched[s.id] = tracks; + } + return sched; + } + +export { + D2R, R2D, wrapLon, subSat, subSolar, angDist, inShadow, + pointInPoly, overROI, nearStation, buildProhibits, inProhibit, + statusAt, buildSchedule, STATION_RANGE, +}; diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/util.js b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/util.js new file mode 100644 index 0000000..0f91d08 --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/util.js @@ -0,0 +1,9 @@ +/* util.js — small shared helpers (ESM) */ + +// "#rrggbb" + alpha -> "rgba(...)"; passes through non-hex strings unchanged +export function hexA(hex, a) { + if (typeof hex !== "string" || hex[0] !== "#") return hex; + const n = parseInt(hex.slice(1), 16); + const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255; + return `rgba(${r},${g},${b},${a})`; +} diff --git a/docs/ui-prototypes/бриф-дашборд-состояние-группировки.md b/docs/ui-prototypes/бриф-дашборд-состояние-группировки.md new file mode 100644 index 0000000..af9dd3f --- /dev/null +++ b/docs/ui-prototypes/бриф-дашборд-состояние-группировки.md @@ -0,0 +1,115 @@ +# Хэндофф для Claude Design — дашборд «Состояние группировки» + +Этот файл — то, что прикладываем в чат Claude Design вместе со скриншотами текущего UI. +Цель: спроектировать **новый экран «Состояние группировки»** в едином стиле с уже сделанными +прототипами (`pcp-tgu-mission-ops`, `pcp-service-map`, `pcp-tgu-editor`), но на **реальных +токенах продукта**. + +Эталон стиля — **реальный код** pcp-tgu-ops-ui (`src/styles/theme.css` + `editor-tokens.css`), +а не oklch/IBM Plex из прежних прототипов. Ниже — те же имена токенов, что в прототипах, +но с боевыми значениями. Используй их as-is. + +--- + +## 1. Токены (вставить в `:root` нового прототипа) + +```css +:root { + /* Фоны (тёмная пультовая тема) */ + --bg-0: #111315; /* база */ + --bg-1: #171a1c; /* панель */ + --bg-2: #1f2325; /* панель 2 */ + --bg-3: #262b2e; /* приподнятый элемент */ + --line: #30373a; + --line-soft: #252b2d; + --grid: #252b2d; + + /* Текст */ + --ink: #e7ecec; + --ink-1: #e7ecec; + --ink-2: #a4b0b1; + --ink-3: #748183; + + /* Акцент / статусы */ + --accent: #68c3bd; /* бирюза — основной акцент */ + --accent-strong: #82ded8; + --danger: #d64545; + --warning: #f0ad2e; + --success: #2f9e44; + --now: #f0ad2e; /* линия «сейчас» */ + --warn: #f0ad2e; + + /* Типы аппаратуры (ДЗЗ): оптика / радар / комбо */ + --t-optical: #68c3bd; + --t-optical-dim: rgba(104, 195, 189, 0.18); + --t-radar: #f0ad2e; + --t-radar-dim: rgba(240, 173, 46, 0.16); + --t-combo: #d64545; + --t-combo-dim: rgba(214, 69, 69, 0.16); + + /* Шрифты */ + --sans: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + --mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + + --radius: 7px; + --shadow: 0 16px 42px rgba(0, 0, 0, 0.28); +} +``` + +**Правила стиля, обязательные к соблюдению:** +- Все **числа, идентификаторы, времена** — моноширинным (`--mono`). Это узнаваемая черта UI. +- Подписи/заголовки — `--sans` (Inter). +- Никаких oklch и IBM Plex — только значения выше. + +--- + +## 2. Что изменилось в продукте с момента прежних прототипов + +Если будешь заодно подравнивать существующие морды — учти эти расхождения с тем, что было +сгенерировано раньше: + +- **Редактор:** левая панель информации шире (≈17rem вместо 15.5rem) — числа больше не липнут к краю. +- **Слоты:** статусы слотов проставляются и красятся **только после закладки плана на КА** + (статус `LAID_IN`), а не по «принятию». До закладки — нейтральны. +- **Закладка:** кнопка «Подтвердить закладку» заблокирована, пока начало закладки в будущем. +- **Комплексный план (вкладка):** появились история прогонов (выпадающий список прошлых расчётов), + тумблер «Показать заявки» (оверлей заявок на карте) и ограничение интервала расчёта — **не более 10 суток**. +- **Редактор / правка включения:** инспектор включения read-only; правка идёт через + «Редактировать» → предзаполненное окно построения, которое заменяет маршрут. +- **Таймлайн:** стрелки цепочки версий плана между выдачами. +- **Статусы плана:** `LAID_IN` и `SUPERSEDED` теперь реальны; `ACCEPTED` не возникает. + +--- + +## 3. ТЗ на новый экран «Состояние группировки» + +Оперативный дашборд состояния орбитальной группировки ДЗЗ. Главное — «живая» карта с +проигрыванием и подсветкой того, что снимается прямо сейчас. + +**Раскладка (предложение, можно улучшить):** +- Крупная карта мира (2D, как в `pcp-service-map`) — основная область. +- Снизу — компактная **полоса проигрывания**: кнопка ▶/⏸, скорость (1×/10×/60×/300×), + ползунок времени, текущее модельное время моноширинным. +- Слева/справа — узкая панель: список КА группировки со статусом (на связи / съёмка / сброс / + вне зоны), фильтр по типу аппаратуры (оптика/радар/комбо), счётчики. +- Верхняя строка — сводка: всего КА, сейчас снимают N, активных маршрутов M, ближайший сеанс. + +**Поведение «плей»:** +- По ▶ модельное время идёт вперёд; **метки КА двигаются по трассам** в реальном (масштабируемом) времени. +- Маршруты/полосы, которые **снимаются в текущий момент времени**, подсвечиваются (акцентом/типом + аппаратуры), у КА в режиме съёмки — выделенное состояние. +- При паузе — всё замирает, можно кликнуть КА/маршрут и увидеть детали (как инспектор в редакторе). +- Линия «сейчас» (`--now`) отделяет факт от прогноза, если будет мини-таймлайн снизу. + +**Данные (моки в `data.jsx`, сид-генерация как в прежних прототипах):** +- `spacecraft[]`: { id, name, group, type: optical|radar|combo, ... } + упрощённая трасса + (последовательность {t, lat, lon}) для анимации позиции по времени. +- `captures[]` (включения): { id, scId, tStart, tEnd, footprint (полигон/полоса), requestId? } — + по ним подсвечиваем «снимается сейчас», когда `tStart ≤ now ≤ tEnd`. +- `NOW` зафиксировать под дату проекта, диапазон — несколько суток. + +**Формат сдачи:** новая папка `docs/ui-prototypes/pcp-grouping-dashboard/` в том же устройстве, +что прежние прототипы: `app.jsx` (корень) + `data.jsx` (моки) + модули (`mapview.jsx`, +`timeline.jsx`/`playbar.jsx`, `sidebar.jsx`, `details.jsx`, `util.jsx`) + HTML-обёртка +(React/Babel из CDN, блок `:root` из раздела 1). Переиспользуй `basemap.js`/`world.js`/`orbit.jsx` +из `pcp-service-map`, если подходят. diff --git a/docs/бэклог-pcp-tgu-ops-ui.md b/docs/бэклог-pcp-tgu-ops-ui.md new file mode 100644 index 0000000..b406ffa --- /dev/null +++ b/docs/бэклог-pcp-tgu-ops-ui.md @@ -0,0 +1,42 @@ +# Бэклог pcp-tgu-ops-ui + +Список задач по сервису **pcp-tgu-ops-ui** (и связанным бэкендам). +Ведётся вручную и с помощью Claude: по просьбе — отмечаем выполненное и добавляем новые задачи. + +**Обозначения статуса:** +- `[ ]` — не начато +- `[~]` — в работе +- `[x]` — выполнено (переносится в раздел «Выполнено» с пометкой) + +У каждой задачи есть стабильный номер `#N` — он не меняется при правках списка, на него удобно ссылаться. Новые задачи получают следующий свободный номер. + +_Обновлено: 2026-06-05_ + +--- + +## Активные задачи + +### Карта и отображение +- [ ] **#1** — Отображать трассы полос исходя из возможности съёмки КА. Для оптики показывать только участки, попадающие в допустимый угол Солнца. +- [ ] **#2** — При клике в область, где пересекается несколько полос обзора, выводить список этих объектов на правой панели; при выборе элемента — выделять соответствующий объект на карте. +- [ ] **#3** — Показ забронированных слотов на вкладках «Карта» и «Редактор». На вкладке «Редактор» они должны быть другого цвета и выделяться. + +### Редактор +- [ ] **#4** — Показывать на вкладке «Редактор» только те заявки, которые может удовлетворить аппарат (например, для оптики не показывать заявку, для которой доступна только локация). +- [ ] **#5** — Дать возможность превратить план в статусе `PLANNED` (построенный цепочкой от последнего `LAID_IN`) в черновик и вручную наполнить его включениями — до того, как он будет автоматически выдан по таймеру. + +### Комплексный план +- [ ] **#6** — Пересчитывать комплексный план при бронировании новых слотов или при поступлении новой заявки. + +### Бэкенд / инфраструктура +- [ ] **#7** — Перевести сервис станций на ordinis (по примеру получения аппаратов в сервисе tgu). + +### Баги +- [ ] **#8** — Ошибка при попытке принять план на вкладке «Планы». + +--- + +## Выполнено +- [x] **#9** — Расширить вправо левую панель информации на вкладке «Редактор» (цифры у границы смотрелись плохо). _Готово: commit 69dfd80, рейл 15.5rem → 17rem._ +- [x] **#10** — Проставлять статусы слотов только после того, как план заложен на КА. _Готово: commit 69dfd80, слоты занимаются по `LAID_IN`, а не по `ACCEPT`._ +- [x] **#11** — Запретить нажимать «Подтвердить закладку», пока начало закладки в будущем. _Готово: commit 69dfd80._ diff --git a/docs/задачи для pcp-tgu-ops-ui.txt b/docs/задачи для pcp-tgu-ops-ui.txt deleted file mode 100644 index f26a955..0000000 --- a/docs/задачи для pcp-tgu-ops-ui.txt +++ /dev/null @@ -1,21 +0,0 @@ -TODO: -- Отображение трасс полос исходя их возможности съемки КА. Для оптики нужно отображать только части, которые находятся в допустимом угле солнца. -- Отображение заявок на вкладке редактор только тех, которые может удовлетворить аппарат (не показывать только локацию для оптики например) -- Чуть расширить вправо левую панель с информацией на вкладке Редактор ибо цифры на границе смотрятся плохо -- Проставлять статусы слотов только после того, как план заложен на КА -- Когда мы кликаем по полосам обзора, если там пересекаются несколько, то их надо выводить списком на панели справа. И при нажатии на полосу обзора на панели выделять объект на карте. -- Ошибка при попытке принять план во вкладке планы -- Перевод сервиса станций на ordinis по примеру получения аппаратов в сервисе tgu -- Отображение предыдущих планов и следующих. Предлагаю не просто сделать отображение как рамку, я делать еле заметные все другие планы, а эти обводить яркими рамками, чтобы было видно какой план прошлый, а какой будущий. -Прошлый: #94A3B8 slate-400 -Текущий: #2563EB blue-600 -Следующий: #F59E0B amber-500 -Предлагаю использовать эти цвета -- Нужна кнопка создания нового плана от этого, аля как черновик, но не черновик -- При создании черновика, базовый план у черновика должен быть как у исходного плана, а не исходный план. -- Деактивировать отмененный план, чтобы он не портил статистику -- Если план заложен, то все отмененные планы до него (время начала которых меньше времени начала этого) не должны влиять на цветовую индикацию плана на вкладке Планы - - - -