docs: бэклог и бриф pcp-tgu-ops-ui, ui-прототипы tgu-ops
Добавлены docs/бэклог-pcp-tgu-ops-ui.md, бриф дашборда состояния группировки и прототипы docs/ui-prototypes/tgu-ops/; удалён устаревший docs/задачи для pcp-tgu-ops-ui.txt.
This commit is contained in:
@@ -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: <PlansView /> },
|
||||
{ id: "map", label: "Карта", element: <MapTab /> },
|
||||
// ...
|
||||
{ id: "constel", label: "Группировка", element: <ConstellationDashboard /> }, // ← новая
|
||||
];
|
||||
```
|
||||
|
||||
или с react-router:
|
||||
|
||||
```jsx
|
||||
<Route path="/constellation" element={<ConstellationDashboard />} />
|
||||
```
|
||||
|
||||
### Важно про высоту
|
||||
Корневой `.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`-переменные.
|
||||
Если боитесь конфликтов:
|
||||
- оберните корень во `<div className="cdash">…</div>` и добавьте префикс к селекторам
|
||||
(или прогоните через 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 «про высоту»).
|
||||
@@ -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`** — пошагово: зависимости, монтирование как вкладки,
|
||||
контракты данных и где подключить реальные эфемериды/расписание.
|
||||
+203
@@ -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 (
|
||||
<div className="app">
|
||||
<Header simTime={tNow} playing={playing} speed={speed} live={live}
|
||||
onPlay={onPlay} onSpeed={onSpeed} onNow={onNow} onStep={onStep} />
|
||||
<div className="body">
|
||||
<SidePanel sats={env.sats} statuses={statuses} memory={memRef.current} counts={counts}
|
||||
events={events} focusId={focusId} onFocus={setFocus} query={query} setQuery={setQuery} />
|
||||
<div className="main">
|
||||
<div className="map-area">
|
||||
<MapView env={env} apiRef={apiRef} />
|
||||
<div className="map-caption">
|
||||
{focusSat ? (
|
||||
<FocusCard sat={focusSat} status={statuses[focusId]} mem={memRef.current[focusId]} onClose={() => setFocus(null)} />
|
||||
) : (
|
||||
<div className="cap-hint">КЛИК ПО АППАРАТУ — ФОКУС И ПОДСВЕТКА СОБЫТИЙ</div>
|
||||
)}
|
||||
</div>
|
||||
<MapLegend />
|
||||
<div className="map-cursor">
|
||||
{cursor ? `${Math.abs(cursor.lat).toFixed(3)}° ${cursor.lat >= 0 ? "с.ш." : "ю.ш."}, ${Math.abs(cursor.lon).toFixed(3)}° ${cursor.lon >= 0 ? "в.д." : "з.д."}` : "—"}
|
||||
</div>
|
||||
{hover && hover.id !== focusId && (
|
||||
<div className="map-tip" style={{ left: hover.x + 14, top: hover.y - 8 }}>
|
||||
{env.sats.find((s) => s.id === hover.id)?.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="tl-area">
|
||||
<div className="tl-head">
|
||||
<span className="tl-title">ТАЙМЛАЙН СОБЫТИЙ</span>
|
||||
<div className="tl-legend">
|
||||
<LegItem c="#d99a4e" t="СЪЁМКА" />
|
||||
<LegItem c="#5b9bd5" t="СБРОС" />
|
||||
<LegItem c="#d9685f" t="ЗАПРЕТ РАБОТЫ" hatch />
|
||||
<LegItem c="rgba(120,140,180,0.5)" t="ТЕНЬ ЗЕМЛИ" />
|
||||
</div>
|
||||
<span className="tl-hint">{live ? "РЕЖИМ LIVE · ×1" : "СИМУЛЯЦИЯ ×" + speed} · перетащите для скраба</span>
|
||||
</div>
|
||||
<Timeline env={env} sched={sched} simTime={tNow} domain={domain} focusId={focusId}
|
||||
onScrub={onScrub} onFocus={setFocus} live={live} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FocusCard({ sat, status, mem, onClose }) {
|
||||
const meta = STATUS_META[status.state];
|
||||
return (
|
||||
<div className="focus-card">
|
||||
<span className="fc-pay" style={{ background: sat.color }} />
|
||||
<div className="fc-info">
|
||||
<div className="fc-name">{sat.name}<button className="fc-x" onClick={onClose}>✕</button></div>
|
||||
<div className="fc-meta">
|
||||
<span>NORAD {sat.norad}</span><span className="fc-dot">·</span><span>{sat.group}</span>
|
||||
<span className="fc-dot">·</span><span style={{ color: meta.c }}>{status.eclipse ? "☾ " : ""}{meta.t}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="fc-pos">
|
||||
<div>{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"}</div>
|
||||
<div className="fc-mem">ПАМЯТЬ <b>{Math.round(mem)}%</b></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MapLegend() {
|
||||
return (
|
||||
<div className="map-legend">
|
||||
<div className="ml-row"><span className="ml-tri" />СТАНЦИЯ ПРИЁМА</div>
|
||||
<div className="ml-row"><span className="ml-sw" style={{ background: window.OPS.COL.optic }} />ОПТИКА</div>
|
||||
<div className="ml-row"><span className="ml-sw" style={{ background: window.OPS.COL.radar }} />ЛОКАЦИЯ</div>
|
||||
<div className="ml-row"><span className="ml-sw amber" />МАРШРУТ СЪЁМКИ</div>
|
||||
<div className="ml-row"><span className="ml-sw blue dash" />СБРОС НА СТАНЦИЮ</div>
|
||||
<div className="ml-row"><span className="ml-sw night" />ТЕНЬ ЗЕМЛИ</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function LegItem({ c, t, hatch }) {
|
||||
return <span className="leg-item"><i className={"leg-sw" + (hatch ? " hatch" : "")} style={{ background: hatch ? undefined : c }} />{t}</span>;
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
|
||||
+73
@@ -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 };
|
||||
})();
|
||||
+331
@@ -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 (
|
||||
<div className="map-wrap" ref={wrapRef}>
|
||||
<canvas ref={canvasRef} onMouseMove={onMove} onMouseLeave={() => apiRef.current.onCursor && apiRef.current.onCursor(null)} onClick={onClick} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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;
|
||||
+180
@@ -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 (
|
||||
<header className="hdr">
|
||||
<div className="hdr-brand">
|
||||
<span className="brand-mark" />
|
||||
<div className="brand-txt">
|
||||
<div className="brand-1">СОСТОЯНИЕ ГРУППИРОВКИ</div>
|
||||
<div className="brand-2">MISSION OPS · ТГУ</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hdr-transport">
|
||||
<button className="t-btn" onClick={() => onStep(-3600e3)} title="−1 час">−1ч</button>
|
||||
<button className={"t-btn t-play" + (playing ? " on" : "")} onClick={onPlay}>
|
||||
{playing ? "❚❚" : "▶"}
|
||||
</button>
|
||||
<button className="t-btn" onClick={() => onStep(3600e3)} title="+1 час">+1ч</button>
|
||||
<div className="t-sep" />
|
||||
<div className="speed-seg">
|
||||
{speeds.map((s) => (
|
||||
<button key={s} className={"sp" + (speed === s ? " on" : "")} onClick={() => onSpeed(s)}>×{s}</button>
|
||||
))}
|
||||
</div>
|
||||
<button className={"t-btn t-now" + (live ? " live" : "")} onClick={onNow}>СЕЙЧАС</button>
|
||||
</div>
|
||||
|
||||
<div className="hdr-clock">
|
||||
<div className={"live-badge" + (live ? " on" : "")}>
|
||||
<span className="live-dot" />{live ? "LIVE" : "СИМ"}
|
||||
</div>
|
||||
<div className="clock-main">
|
||||
<span className="clock-time">{ck.time}</span>
|
||||
<span className="clock-utc">UTC</span>
|
||||
</div>
|
||||
<div className="clock-date">{ck.dow} · {ck.date}</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="summary">
|
||||
<div className="summary-tiles">
|
||||
{tiles.map((t) => (
|
||||
<div key={t.k} className="s-tile">
|
||||
<div className="s-num" style={{ color: t.c }}>{t.n}</div>
|
||||
<div className="s-lbl">{t.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="summary-foot">
|
||||
<span><i className="dot" style={{ background: window.OPS.COL.optic }} />ОПТИКА {counts.optic}</span>
|
||||
<span><i className="dot" style={{ background: window.OPS.COL.radar }} />ЛОКАЦИЯ {counts.radar}</span>
|
||||
<span className="muted">{counts.total} КА</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SatRow({ sat, status, mem, focus, onClick }) {
|
||||
const meta = STATUS_META[status.state];
|
||||
const memColor = mem > 92 ? "#d9685f" : mem > 78 ? "#d99a4e" : "#5fb3a3";
|
||||
return (
|
||||
<div className={"sat-row" + (focus ? " focus" : "")} onClick={onClick}>
|
||||
<span className="sr-payload" style={{ background: sat.color }} />
|
||||
<div className="sr-main">
|
||||
<div className="sr-top">
|
||||
<span className="sr-name">{sat.name}</span>
|
||||
<span className="sr-status" style={{ color: meta.c }}>
|
||||
{status.eclipse && <span className="ecl">☾</span>}{meta.t}
|
||||
</span>
|
||||
</div>
|
||||
<div className="sr-bot">
|
||||
<span className="sr-norad">NORAD {sat.norad}</span>
|
||||
<div className="mem-bar"><span style={{ width: mem + "%", background: memColor }} /></div>
|
||||
<span className="sr-mem" style={{ color: memColor }}>{Math.round(mem)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="roster">
|
||||
{groups.map((g, i) => {
|
||||
if (g.type === "domain") return <div key={i} className="r-domain"><i className="dot" style={{ background: g.color }} />{g.label}</div>;
|
||||
if (g.type === "group") return <div key={i} className="r-group">{g.label}</div>;
|
||||
const s = g.sat;
|
||||
return <SatRow key={s.id} sat={s} status={statuses[s.id]} mem={memory[s.id]}
|
||||
focus={focusId === s.id} onClick={() => onFocus(focusId === s.id ? null : s.id)} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityFeed({ events, onFocus, focusId }) {
|
||||
return (
|
||||
<div className="activity">
|
||||
<div className="sec-label">АКТИВНЫЕ СОБЫТИЯ <span className="count">{events.length}</span></div>
|
||||
<div className="act-list">
|
||||
{events.length === 0 && <div className="act-empty">нет активных событий</div>}
|
||||
{events.map((e) => (
|
||||
<div key={e.id} className={"act-row" + (focusId === e.satId ? " focus" : "")} onClick={() => onFocus(e.satId)}>
|
||||
<span className="act-bar" style={{ background: e.color }} />
|
||||
<div className="act-body">
|
||||
<div className="act-top"><span className="act-type" style={{ color: e.color }}>{e.type}</span><span className="act-tgt">{e.target}</span></div>
|
||||
<div className="act-sub">{e.sat}<span className="act-rem">ещё {e.remain}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidePanel({ sats, statuses, memory, counts, events, focusId, onFocus, query, setQuery }) {
|
||||
return (
|
||||
<aside className="side">
|
||||
<div className="sec-label">СВОДКА ГРУППИРОВКИ</div>
|
||||
<SummaryTiles counts={counts} />
|
||||
<div className="side-search">
|
||||
<input placeholder="Поиск name / NORAD / группа" value={query} onChange={(e) => setQuery(e.target.value)} />
|
||||
</div>
|
||||
<div className="sec-label tight">КОСМИЧЕСКИЕ АППАРАТЫ</div>
|
||||
<Roster sats={sats} statuses={statuses} memory={memory} focusId={focusId} onFocus={onFocus} query={query} />
|
||||
<ActivityFeed events={events} onFocus={onFocus} focusId={focusId} />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
window.Header = Header;
|
||||
window.SidePanel = SidePanel;
|
||||
window.STATUS_META = STATUS_META;
|
||||
window.fmtDur = fmtDur;
|
||||
+158
@@ -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,
|
||||
};
|
||||
})();
|
||||
+160
@@ -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 (
|
||||
<div className="tl-wrap" ref={wrapRef} onMouseDown={down}>
|
||||
<svg width={w} height={totalH} style={{ display: "block" }}>
|
||||
<defs>
|
||||
<pattern id="hatch" width="6" height="6" patternTransform="rotate(45)" patternUnits="userSpaceOnUse">
|
||||
<rect width="6" height="6" fill="rgba(217,104,95,0.12)" />
|
||||
<line x1="0" y1="0" x2="0" y2="6" stroke="rgba(217,104,95,0.6)" strokeWidth="2" />
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
{/* axis */}
|
||||
<line x1={TL_GUTTER} y1={TL_AXIS} x2={w} y2={TL_AXIS} stroke="rgba(255,255,255,0.08)" />
|
||||
{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 (
|
||||
<g key={i}>
|
||||
<line x1={x} y1={TL_AXIS - 5} x2={x} y2={totalH} stroke="rgba(255,255,255,0.035)" />
|
||||
<text x={x + 3} y={TL_AXIS - 9} className="tl-tick">
|
||||
{isDay ? `${String(d.getUTCDate()).padStart(2, "0")}.${String(d.getUTCMonth() + 1).padStart(2, "0")}` : `${hh}:00`}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* rows */}
|
||||
{rows.map((r, i) => {
|
||||
if (r.type === "group") {
|
||||
return <text key={i} x={10} y={r.y + 11} className="tl-group">{r.label}</text>;
|
||||
}
|
||||
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 <rect key={key} x={x1} y={cy - h / 2} width={Math.max(1.5, x2 - x1)} height={h}
|
||||
fill={fill} rx="1" opacity={dim ? 0.3 : 1} />;
|
||||
};
|
||||
return (
|
||||
<g key={i} onClick={() => onFocus(focusId === s.id ? null : s.id)} style={{ cursor: "pointer" }}>
|
||||
<rect x={0} y={r.y} width={w} height={TL_ROW} fill={focusId === s.id ? "rgba(95,179,163,0.06)" : "transparent"} />
|
||||
<line x1={TL_GUTTER} y1={r.y + TL_ROW} x2={w} y2={r.y + TL_ROW} stroke="rgba(255,255,255,0.025)" />
|
||||
<circle cx={14} cy={cy} r={3} fill={s.color} opacity={dim ? 0.4 : 1} />
|
||||
<text x={24} y={cy + 3.5} className="tl-name" opacity={dim ? 0.45 : 1}>{s.name}</text>
|
||||
{/* baseline */}
|
||||
<line x1={TL_GUTTER} y1={cy} x2={w} y2={cy} stroke="rgba(255,255,255,0.04)" />
|
||||
{/* 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 <rect key={"pr" + k} x={x1} y={r.y + 2} width={Math.max(2, x2 - x1)} height={TL_ROW - 4}
|
||||
fill="url(#hatch)" stroke="rgba(217,104,95,0.5)" strokeWidth="0.8" opacity={dim ? 0.4 : 1} />;
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* now marker (real wall clock) */}
|
||||
{nowX > TL_GUTTER && nowX < w && (
|
||||
<g>
|
||||
<line x1={nowX} y1={TL_AXIS - 5} x2={nowX} y2={totalH} stroke="rgba(95,179,163,0.35)" strokeDasharray="2 3" />
|
||||
<text x={nowX + 3} y={totalH - 3} className="tl-nowlbl">СЕЙЧАС</text>
|
||||
</g>
|
||||
)}
|
||||
{/* playhead */}
|
||||
<g>
|
||||
<line x1={headX} y1={TL_AXIS - 6} x2={headX} y2={totalH} stroke={live ? "#5fb3a3" : "#d99a4e"} strokeWidth="1.5" />
|
||||
<polygon points={`${headX - 5},${TL_AXIS - 6} ${headX + 5},${TL_AXIS - 6} ${headX},${TL_AXIS + 1}`} fill={live ? "#5fb3a3" : "#d99a4e"} />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.Timeline = Timeline;
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Состояние группировки — Mission Ops</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<!-- geo libs -->
|
||||
<script src="https://unpkg.com/d3@7.9.0/dist/d3.min.js"></script>
|
||||
<script src="https://unpkg.com/topojson-client@3.1.0/dist/topojson-client.min.js"></script>
|
||||
|
||||
<!-- react + babel (pinned) -->
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
|
||||
<!-- data + sim (plain) -->
|
||||
<script src="js/data.js"></script>
|
||||
<script src="js/sim.js"></script>
|
||||
|
||||
<!-- components (babel) -->
|
||||
<script type="text/babel" src="js/map.jsx"></script>
|
||||
<script type="text/babel" src="js/timeline.jsx"></script>
|
||||
<script type="text/babel" src="js/panels.jsx"></script>
|
||||
<script type="text/babel" src="js/app.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+180
@@ -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;}
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
/* ============================================================
|
||||
ConstellationDashboard.jsx — wiring, RAF clock, memory sim (ESM)
|
||||
Default export: <ConstellationDashboard /> — монтируется как вкладка.
|
||||
============================================================ */
|
||||
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 (
|
||||
<div className="app">
|
||||
<Header simTime={tNow} playing={playing} speed={speed} live={live}
|
||||
onPlay={onPlay} onSpeed={onSpeed} onNow={onNow} onStep={onStep} />
|
||||
<div className="body">
|
||||
<SidePanel sats={env.sats} statuses={statuses} memory={memRef.current} counts={counts}
|
||||
events={events} focusId={focusId} onFocus={setFocus} query={query} setQuery={setQuery} />
|
||||
<div className="main">
|
||||
<div className="map-area">
|
||||
<MapView env={env} apiRef={apiRef} />
|
||||
<div className="map-caption">
|
||||
{focusSat ? (
|
||||
<FocusCard sat={focusSat} status={statuses[focusId]} mem={memRef.current[focusId]} onClose={() => setFocus(null)} />
|
||||
) : (
|
||||
<div className="cap-hint">КЛИК ПО АППАРАТУ — ФОКУС И ПОДСВЕТКА СОБЫТИЙ</div>
|
||||
)}
|
||||
</div>
|
||||
<MapLegend />
|
||||
<div className="map-cursor">
|
||||
{cursor ? `${Math.abs(cursor.lat).toFixed(3)}° ${cursor.lat >= 0 ? "с.ш." : "ю.ш."}, ${Math.abs(cursor.lon).toFixed(3)}° ${cursor.lon >= 0 ? "в.д." : "з.д."}` : "—"}
|
||||
</div>
|
||||
{hover && hover.id !== focusId && (
|
||||
<div className="map-tip" style={{ left: hover.x + 14, top: hover.y - 8 }}>
|
||||
{env.sats.find((s) => s.id === hover.id)?.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="tl-area">
|
||||
<div className="tl-head">
|
||||
<span className="tl-title">ТАЙМЛАЙН СОБЫТИЙ</span>
|
||||
<div className="tl-legend">
|
||||
<LegItem c="#d99a4e" t="СЪЁМКА" />
|
||||
<LegItem c="#5b9bd5" t="СБРОС" />
|
||||
<LegItem c="#d9685f" t="ЗАПРЕТ РАБОТЫ" hatch />
|
||||
<LegItem c="rgba(120,140,180,0.5)" t="ТЕНЬ ЗЕМЛИ" />
|
||||
</div>
|
||||
<span className="tl-hint">{live ? "РЕЖИМ LIVE · ×1" : "СИМУЛЯЦИЯ ×" + speed} · перетащите для скраба</span>
|
||||
</div>
|
||||
<Timeline env={env} sched={sched} simTime={tNow} domain={domain} focusId={focusId}
|
||||
onScrub={onScrub} onFocus={setFocus} live={live} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FocusCard({ sat, status, mem, onClose }) {
|
||||
const meta = STATUS_META[status.state];
|
||||
return (
|
||||
<div className="focus-card">
|
||||
<span className="fc-pay" style={{ background: sat.color }} />
|
||||
<div className="fc-info">
|
||||
<div className="fc-name">{sat.name}<button className="fc-x" onClick={onClose}>✕</button></div>
|
||||
<div className="fc-meta">
|
||||
<span>NORAD {sat.norad}</span><span className="fc-dot">·</span><span>{sat.group}</span>
|
||||
<span className="fc-dot">·</span><span style={{ color: meta.c }}>{status.eclipse ? "☾ " : ""}{meta.t}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="fc-pos">
|
||||
<div>{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"}</div>
|
||||
<div className="fc-mem">ПАМЯТЬ <b>{Math.round(mem)}%</b></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MapLegend() {
|
||||
return (
|
||||
<div className="map-legend">
|
||||
<div className="ml-row"><span className="ml-tri" />СТАНЦИЯ ПРИЁМА</div>
|
||||
<div className="ml-row"><span className="ml-sw" style={{ background: COL.optic }} />ОПТИКА</div>
|
||||
<div className="ml-row"><span className="ml-sw" style={{ background: COL.radar }} />ЛОКАЦИЯ</div>
|
||||
<div className="ml-row"><span className="ml-sw amber" />МАРШРУТ СЪЁМКИ</div>
|
||||
<div className="ml-row"><span className="ml-sw blue dash" />СБРОС НА СТАНЦИЮ</div>
|
||||
<div className="ml-row"><span className="ml-sw night" />ТЕНЬ ЗЕМЛИ</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function LegItem({ c, t, hatch }) {
|
||||
return <span className="leg-item"><i className={"leg-sw" + (hatch ? " hatch" : "")} style={{ background: hatch ? undefined : c }} />{t}</span>;
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -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 (
|
||||
<div className="map-wrap" ref={wrapRef}>
|
||||
<canvas ref={canvasRef} onMouseMove={onMove} onMouseLeave={() => apiRef.current.onCursor && apiRef.current.onCursor(null)} onClick={onClick} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -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 (
|
||||
<header className="hdr">
|
||||
<div className="hdr-brand">
|
||||
<span className="brand-mark" />
|
||||
<div className="brand-txt">
|
||||
<div className="brand-1">СОСТОЯНИЕ ГРУППИРОВКИ</div>
|
||||
<div className="brand-2">MISSION OPS · ТГУ</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hdr-transport">
|
||||
<button className="t-btn" onClick={() => onStep(-3600e3)} title="−1 час">−1ч</button>
|
||||
<button className={"t-btn t-play" + (playing ? " on" : "")} onClick={onPlay}>
|
||||
{playing ? "❚❚" : "▶"}
|
||||
</button>
|
||||
<button className="t-btn" onClick={() => onStep(3600e3)} title="+1 час">+1ч</button>
|
||||
<div className="t-sep" />
|
||||
<div className="speed-seg">
|
||||
{speeds.map((s) => (
|
||||
<button key={s} className={"sp" + (speed === s ? " on" : "")} onClick={() => onSpeed(s)}>×{s}</button>
|
||||
))}
|
||||
</div>
|
||||
<button className={"t-btn t-now" + (live ? " live" : "")} onClick={onNow}>СЕЙЧАС</button>
|
||||
</div>
|
||||
|
||||
<div className="hdr-clock">
|
||||
<div className={"live-badge" + (live ? " on" : "")}>
|
||||
<span className="live-dot" />{live ? "LIVE" : "СИМ"}
|
||||
</div>
|
||||
<div className="clock-main">
|
||||
<span className="clock-time">{ck.time}</span>
|
||||
<span className="clock-utc">UTC</span>
|
||||
</div>
|
||||
<div className="clock-date">{ck.dow} · {ck.date}</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="summary">
|
||||
<div className="summary-tiles">
|
||||
{tiles.map((t) => (
|
||||
<div key={t.k} className="s-tile">
|
||||
<div className="s-num" style={{ color: t.c }}>{t.n}</div>
|
||||
<div className="s-lbl">{t.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="summary-foot">
|
||||
<span><i className="dot" style={{ background: COL.optic }} />ОПТИКА {counts.optic}</span>
|
||||
<span><i className="dot" style={{ background: COL.radar }} />ЛОКАЦИЯ {counts.radar}</span>
|
||||
<span className="muted">{counts.total} КА</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SatRow({ sat, status, mem, focus, onClick }) {
|
||||
const meta = STATUS_META[status.state];
|
||||
const memColor = mem > 92 ? "#d9685f" : mem > 78 ? "#d99a4e" : "#5fb3a3";
|
||||
return (
|
||||
<div className={"sat-row" + (focus ? " focus" : "")} onClick={onClick}>
|
||||
<span className="sr-payload" style={{ background: sat.color }} />
|
||||
<div className="sr-main">
|
||||
<div className="sr-top">
|
||||
<span className="sr-name">{sat.name}</span>
|
||||
<span className="sr-status" style={{ color: meta.c }}>
|
||||
{status.eclipse && <span className="ecl">☾</span>}{meta.t}
|
||||
</span>
|
||||
</div>
|
||||
<div className="sr-bot">
|
||||
<span className="sr-norad">NORAD {sat.norad}</span>
|
||||
<div className="mem-bar"><span style={{ width: mem + "%", background: memColor }} /></div>
|
||||
<span className="sr-mem" style={{ color: memColor }}>{Math.round(mem)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="roster">
|
||||
{groups.map((g, i) => {
|
||||
if (g.type === "domain") return <div key={i} className="r-domain"><i className="dot" style={{ background: g.color }} />{g.label}</div>;
|
||||
if (g.type === "group") return <div key={i} className="r-group">{g.label}</div>;
|
||||
const s = g.sat;
|
||||
return <SatRow key={s.id} sat={s} status={statuses[s.id]} mem={memory[s.id]}
|
||||
focus={focusId === s.id} onClick={() => onFocus(focusId === s.id ? null : s.id)} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityFeed({ events, onFocus, focusId }) {
|
||||
return (
|
||||
<div className="activity">
|
||||
<div className="sec-label">АКТИВНЫЕ СОБЫТИЯ <span className="count">{events.length}</span></div>
|
||||
<div className="act-list">
|
||||
{events.length === 0 && <div className="act-empty">нет активных событий</div>}
|
||||
{events.map((e) => (
|
||||
<div key={e.id} className={"act-row" + (focusId === e.satId ? " focus" : "")} onClick={() => onFocus(e.satId)}>
|
||||
<span className="act-bar" style={{ background: e.color }} />
|
||||
<div className="act-body">
|
||||
<div className="act-top"><span className="act-type" style={{ color: e.color }}>{e.type}</span><span className="act-tgt">{e.target}</span></div>
|
||||
<div className="act-sub">{e.sat}<span className="act-rem">ещё {e.remain}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidePanel({ sats, statuses, memory, counts, events, focusId, onFocus, query, setQuery }) {
|
||||
return (
|
||||
<aside className="side">
|
||||
<div className="sec-label">СВОДКА ГРУППИРОВКИ</div>
|
||||
<SummaryTiles counts={counts} />
|
||||
<div className="side-search">
|
||||
<input placeholder="Поиск name / NORAD / группа" value={query} onChange={(e) => setQuery(e.target.value)} />
|
||||
</div>
|
||||
<div className="sec-label tight">КОСМИЧЕСКИЕ АППАРАТЫ</div>
|
||||
<Roster sats={sats} statuses={statuses} memory={memory} focusId={focusId} onFocus={onFocus} query={query} />
|
||||
<ActivityFeed events={events} onFocus={onFocus} focusId={focusId} />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export { Header, SidePanel, STATUS_META, fmtDur };
|
||||
@@ -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 (
|
||||
<div className="tl-wrap" ref={wrapRef} onMouseDown={down}>
|
||||
<svg width={w} height={totalH} style={{ display: "block" }}>
|
||||
<defs>
|
||||
<pattern id="hatch" width="6" height="6" patternTransform="rotate(45)" patternUnits="userSpaceOnUse">
|
||||
<rect width="6" height="6" fill="rgba(217,104,95,0.12)" />
|
||||
<line x1="0" y1="0" x2="0" y2="6" stroke="rgba(217,104,95,0.6)" strokeWidth="2" />
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
{/* axis */}
|
||||
<line x1={TL_GUTTER} y1={TL_AXIS} x2={w} y2={TL_AXIS} stroke="rgba(255,255,255,0.08)" />
|
||||
{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 (
|
||||
<g key={i}>
|
||||
<line x1={x} y1={TL_AXIS - 5} x2={x} y2={totalH} stroke="rgba(255,255,255,0.035)" />
|
||||
<text x={x + 3} y={TL_AXIS - 9} className="tl-tick">
|
||||
{isDay ? `${String(d.getUTCDate()).padStart(2, "0")}.${String(d.getUTCMonth() + 1).padStart(2, "0")}` : `${hh}:00`}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* rows */}
|
||||
{rows.map((r, i) => {
|
||||
if (r.type === "group") {
|
||||
return <text key={i} x={10} y={r.y + 11} className="tl-group">{r.label}</text>;
|
||||
}
|
||||
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 <rect key={key} x={x1} y={cy - h / 2} width={Math.max(1.5, x2 - x1)} height={h}
|
||||
fill={fill} rx="1" opacity={dim ? 0.3 : 1} />;
|
||||
};
|
||||
return (
|
||||
<g key={i} onClick={() => onFocus(focusId === s.id ? null : s.id)} style={{ cursor: "pointer" }}>
|
||||
<rect x={0} y={r.y} width={w} height={TL_ROW} fill={focusId === s.id ? "rgba(95,179,163,0.06)" : "transparent"} />
|
||||
<line x1={TL_GUTTER} y1={r.y + TL_ROW} x2={w} y2={r.y + TL_ROW} stroke="rgba(255,255,255,0.025)" />
|
||||
<circle cx={14} cy={cy} r={3} fill={s.color} opacity={dim ? 0.4 : 1} />
|
||||
<text x={24} y={cy + 3.5} className="tl-name" opacity={dim ? 0.45 : 1}>{s.name}</text>
|
||||
{/* baseline */}
|
||||
<line x1={TL_GUTTER} y1={cy} x2={w} y2={cy} stroke="rgba(255,255,255,0.04)" />
|
||||
{/* 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 <rect key={"pr" + k} x={x1} y={r.y + 2} width={Math.max(2, x2 - x1)} height={TL_ROW - 4}
|
||||
fill="url(#hatch)" stroke="rgba(217,104,95,0.5)" strokeWidth="0.8" opacity={dim ? 0.4 : 1} />;
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* now marker (real wall clock) */}
|
||||
{nowX > TL_GUTTER && nowX < w && (
|
||||
<g>
|
||||
<line x1={nowX} y1={TL_AXIS - 5} x2={nowX} y2={totalH} stroke="rgba(95,179,163,0.35)" strokeDasharray="2 3" />
|
||||
<text x={nowX + 3} y={totalH - 3} className="tl-nowlbl">СЕЙЧАС</text>
|
||||
</g>
|
||||
)}
|
||||
{/* playhead */}
|
||||
<g>
|
||||
<line x1={headX} y1={TL_AXIS - 6} x2={headX} y2={totalH} stroke={live ? "#5fb3a3" : "#d99a4e"} strokeWidth="1.5" />
|
||||
<polygon points={`${headX - 5},${TL_AXIS - 6} ${headX + 5},${TL_AXIS - 6} ${headX},${TL_AXIS + 1}`} fill={live ? "#5fb3a3" : "#d99a4e"} />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Timeline;
|
||||
@@ -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;}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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})`;
|
||||
}
|
||||
@@ -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`, если подходят.
|
||||
@@ -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._
|
||||
@@ -1,21 +0,0 @@
|
||||
TODO:
|
||||
- Отображение трасс полос исходя их возможности съемки КА. Для оптики нужно отображать только части, которые находятся в допустимом угле солнца.
|
||||
- Отображение заявок на вкладке редактор только тех, которые может удовлетворить аппарат (не показывать только локацию для оптики например)
|
||||
- Чуть расширить вправо левую панель с информацией на вкладке Редактор ибо цифры на границе смотрятся плохо
|
||||
- Проставлять статусы слотов только после того, как план заложен на КА
|
||||
- Когда мы кликаем по полосам обзора, если там пересекаются несколько, то их надо выводить списком на панели справа. И при нажатии на полосу обзора на панели выделять объект на карте.
|
||||
- Ошибка при попытке принять план во вкладке планы
|
||||
- Перевод сервиса станций на ordinis по примеру получения аппаратов в сервисе tgu
|
||||
- Отображение предыдущих планов и следующих. Предлагаю не просто сделать отображение как рамку, я делать еле заметные все другие планы, а эти обводить яркими рамками, чтобы было видно какой план прошлый, а какой будущий.
|
||||
Прошлый: #94A3B8 slate-400
|
||||
Текущий: #2563EB blue-600
|
||||
Следующий: #F59E0B amber-500
|
||||
Предлагаю использовать эти цвета
|
||||
- Нужна кнопка создания нового плана от этого, аля как черновик, но не черновик
|
||||
- При создании черновика, базовый план у черновика должен быть как у исходного плана, а не исходный план.
|
||||
- Деактивировать отмененный план, чтобы он не портил статистику
|
||||
- Если план заложен, то все отмененные планы до него (время начала которых меньше времени начала этого) не должны влиять на цветовую индикацию плана на вкладке Планы
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user