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 (
+
+ );
+}
+
+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) => (
+
+ ))}
+
+
+ ОПТИКА {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 (
+
+ );
+}
+
+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) => (
+
+ ))}
+
+
+ ОПТИКА {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
-Предлагаю использовать эти цвета
-- Нужна кнопка создания нового плана от этого, аля как черновик, но не черновик
-- При создании черновика, базовый план у черновика должен быть как у исходного плана, а не исходный план.
-- Деактивировать отмененный план, чтобы он не портил статистику
-- Если план заложен, то все отмененные планы до него (время начала которых меньше времени начала этого) не должны влиять на цветовую индикацию плана на вкладке Планы
-
-
-
-