pcp-tgu-ops-ui: вкладка «Группировка» (состояние группировки) на реальных данных

Встраивание дизайн-хэндоффа constellation dashboard отдельной вкладкой рядом с
Планы/Карта/Комплексный план/Редактор/Заявки.

- модуль src/features/constellation/ (прототип на JSX + realData.ts/ConstellationTab.tsx)
- реальные данные вместо симуляции: платформы (OPERATIONAL), станции, заявки→ROI (WKT),
  трасса flight-line как эфемерида (сэмплер позиции через env.subSat), ЗРВ→сброс,
  съёмка по пролёту над ROI; оптика/локация — по имени КА из НСИ
- d3-geo + topojson-client; карта world-atlas локально (без CDN)
- изоляция стилей на .cdash (конфликт --line с theme.css), высота .app=100%
- локальное время в часах и оси таймлайна (конвенция проекта)
- allowJs:true в tsconfig.app.json под JSX-модуль
This commit is contained in:
Дмитрий Соловьев
2026-06-05 23:37:47 +03:00
parent ea207088bc
commit 821de2b0c0
18 changed files with 1755 additions and 2 deletions
+50
View File
@@ -9,8 +9,10 @@
"version": "0.1.0",
"dependencies": {
"@vitejs/plugin-react": "^5.1.1",
"d3-geo": "^3.1.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"topojson-client": "^3.1.0",
"typescript": "^5.9.3",
"vite": "^7.2.4"
},
@@ -1513,6 +1515,11 @@
"node": ">=18"
}
},
"node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1561,6 +1568,28 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-geo": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
"dependencies": {
"d3-array": "2.5.0 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/data-urls": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz",
@@ -1769,6 +1798,14 @@
"node": ">= 14"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"engines": {
"node": ">=12"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
@@ -2211,6 +2248,19 @@
"integrity": "sha512-sc2nGvGbixlJRHwTh/qQdPXTxJU1UDJboGPQm4d/01YUJ9r/u6aeIulQvEaxUlvKDN7hb1qCLjax+jhVAPLa/g==",
"dev": true
},
"node_modules/topojson-client": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz",
"integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==",
"dependencies": {
"commander": "2"
},
"bin": {
"topo2geo": "bin/topo2geo",
"topomerge": "bin/topomerge",
"topoquantize": "bin/topoquantize"
}
},
"node_modules/tough-cookie": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
+2
View File
@@ -11,8 +11,10 @@
},
"dependencies": {
"@vitejs/plugin-react": "^5.1.1",
"d3-geo": "^3.1.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"topojson-client": "^3.1.0",
"typescript": "^5.9.3",
"vite": "^7.2.4"
},
@@ -0,0 +1,217 @@
/* ============================================================
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;
}
// Без пропсов — встроенная симуляция (buildEnv). С пропсами env/domain/sched —
// реальные данные из API (см. ConstellationTab + realData.ts, INTEGRATION.md §5).
// Обёртка монтирует компонент только когда данные готовы, поэтому cfg стабилен
// от первого рендера и mount-once замыкания RAF/apiRef остаются валидными.
function App({ env: extEnv, domain: extDomain, sched: extSched } = {}) {
const cfg = useMemo(
() => (extEnv ? { env: extEnv, domain: extDomain, sched: extSched } : buildEnv()),
[extEnv, extDomain, extSched]
);
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 cdash">
<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,75 @@
/* ============================================================
ConstellationTab.tsx — обёртка вкладки «Группировка» с реальными данными.
Грузит состав/геометрию/расписание из API (realData.ts) и монтирует
прототипный ConstellationDashboard уже с готовыми env/domain/sched — поэтому
его mount-once замыкания (RAF/canvas) видят стабильные данные с первого рендера.
============================================================ */
import { useEffect, useState, type ComponentType } from "react";
import ConstellationDashboardImpl from "./ConstellationDashboard.jsx";
import { loadConstellationEnv, type ConstellationConfig } from "./realData";
import "./dashboard.css";
// ConstellationDashboard — JS-модуль прототипа; типизируем точку монтирования.
const ConstellationDashboard = ConstellationDashboardImpl as ComponentType<ConstellationConfig>;
type LoadState =
| { kind: "loading" }
| { kind: "error"; message: string }
| { kind: "ready"; cfg: ConstellationConfig };
export function ConstellationTab() {
const [state, setState] = useState<LoadState>({ kind: "loading" });
useEffect(() => {
let cancelled = false;
setState({ kind: "loading" });
loadConstellationEnv()
.then((cfg) => {
if (!cancelled) setState({ kind: "ready", cfg });
})
.catch((error: unknown) => {
if (!cancelled) {
setState({ kind: "error", message: error instanceof Error ? error.message : "Не удалось загрузить данные группировки." });
}
});
return () => {
cancelled = true;
};
}, []);
if (state.kind === "loading") {
return <ConstellationNotice text="Загрузка состояния группировки…" />;
}
if (state.kind === "error") {
return <ConstellationNotice text={`Ошибка загрузки: ${state.message}`} tone="error" />;
}
if (state.cfg.env.sats.length === 0) {
return <ConstellationNotice text="Нет эксплуатируемых КА с доступной трассой на горизонте планирования." />;
}
return <ConstellationDashboard env={state.cfg.env} domain={state.cfg.domain} sched={state.cfg.sched} />;
}
function ConstellationNotice({ text, tone }: { text: string; tone?: "error" }) {
return (
<div
className="cdash"
style={{
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#0a0c0b",
color: tone === "error" ? "#d9685f" : "#95a19d",
fontFamily: "'IBM Plex Mono', monospace",
fontSize: 13,
letterSpacing: ".04em",
padding: 24,
textAlign: "center"
}}
>
{text}
</div>
);
}
export default ConstellationTab;
@@ -0,0 +1,340 @@
/* ============================================================
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";
// Карта мира раздаётся со своего origin (Vite ?url), без рантайм-зависимости от CDN
// (см. INTEGRATION.md §3). Файл: assets/land-110m.json (world-atlas@2).
import landUrl from "./assets/land-110m.json?url";
// keep the original call-sites (d3.* / topojson.*) working unchanged
const d3 = { geoEquirectangular, geoPath, geoGraticule, geoCircle };
const topojson = { feature };
// Подспутниковая точка: реальный сэмплер env.subSat (flight-line) или встроенная
// симуляция SIM.subSat. См. INTEGRATION.md §5.2.
function subSatOf(env, sat, tMs) {
return env.subSat ? env.subSat(sat, tMs) : SIM.subSat(sat, tMs, env.epochMs);
}
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(landUrl)
.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 = subSatOf(env, sat, tMs + dm * 60000);
if (p) 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 = subSatOf(env, sat, tMs + dm * 60000);
if (q) 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 = subSatOf(env, sat, apiRef.current.simTime());
const pp = p && 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,182 @@
/* ============================================================
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: "ЗАПРЕТ" },
};
// Время в проекте — локальное настенное (см. память project_tgu_ui_utc): часы, планы
// и flight-line идут в зоне браузера без UTC-семантики. Поэтому local-геттеры, не UTC.
function fmtClock(ms) {
const d = new Date(ms);
const p = (n) => String(n).padStart(2, "0");
return {
time: `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`,
date: `${p(d.getDate())}.${p(d.getMonth() + 1)}.${d.getFullYear()}`,
dow: ["ВС", "ПН", "ВТ", "СР", "ЧТ", "ПТ", "СБ"][d.getDay()],
};
}
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">местн.</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.getHours()).padStart(2, "0");
const isDay = d.getHours() === 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.getDate()).padStart(2, "0")}.${String(d.getMonth() + 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;
File diff suppressed because one or more lines are too long
@@ -0,0 +1,184 @@
/* 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");
/* Переменные и резеты дашборда оскоплены на .cdash, чтобы не течь в остальное
приложение (см. INTEGRATION.md §6). В частности, --line здесь свой и не должен
переопределять --line из styles/theme.css на уровне всего приложения. */
.cdash {
--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;
color:var(--hi); font-family:'IBM Plex Sans',system-ui,sans-serif; font-size:13px;
-webkit-font-smoothing:antialiased;
}
.cdash *{box-sizing:border-box;}
.mono,.clock-time,.sr-norad,.map-cursor,.tl-tick,.fc-meta{font-family:'IBM Plex Mono',monospace;}
.cdash button{font-family:inherit; cursor:pointer; color:inherit;}
.cdash ::-webkit-scrollbar{width:8px;height:8px;}
.cdash ::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.07);border-radius:4px;}
.cdash ::-webkit-scrollbar-track{background:transparent;}
/* Встроено вкладкой в shell с собственной шапкой: высота от родителя, не 100vh
(см. INTEGRATION.md §4). display:contents у обёртки вкладки делает .app прямым
элементом grid-строки shell (minmax(0,1fr)). */
.app{height:100%; 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,270 @@
/* ============================================================
realData.ts — реальный источник данных для дашборда «Группировка».
Заменяет встроенную симуляцию (data.js/sim.js) данными из API по контрактам
INTEGRATION.md §5:
• состав — платформы НСИ (tgu-planning), станции, заявки (ROI);
• положение — реальная трасса flight-line (сэмплер позиции по времени);
• сброс — зоны радиовидимости (ЗРВ) из баллистики;
• съёмка — пролёт подспутниковой точки над полигоном заявки;
• тень — астрономическая модель из sim.js.
Возвращает { env, domain, sched } в том же виде, что buildEnv() прототипа,
так что ConstellationDashboard рендерит реальные данные без изменений разметки.
============================================================ */
import { fetchPlatforms, fetchFlightLine, type FlightLinePoint } from "../../api/tguApi";
import { fetchStations, type StationDto } from "../../api/stationsApi";
import { fetchRequestsForMap } from "../../api/requestApi";
import { fetchSatelliteRva } from "../../api/ballisticsApi";
import type { TguPlatform } from "../../model/tguTypes";
import type { RequestMapItem } from "../../model/requestTypes";
import { isOperationalPlatform } from "../tgu-planning/tguTimelineMapper";
import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser";
import { parseLocal } from "../../utils/localTime";
// sim.js/data.js — JS-модули прототипа (allowJs); типы any, нам достаточно.
import { buildSchedule, wrapLon } from "./sim.js";
import { COL } from "./data.js";
type Payload = "optic" | "radar";
export interface ConstellationSat {
id: string;
name: string;
norad: number;
payload: Payload;
domain: "ОПТИКА" | "ЛОКАЦИЯ";
group: string;
color: string;
mem0: number;
}
export interface ConstellationStation {
id: string;
name: string;
lon: number;
lat: number;
}
export interface ConstellationRoi {
id: string;
name: string;
payload: Payload;
poly: [number, number][];
}
export interface Interval {
start: number;
end: number;
label?: string;
}
export interface ConstellationEnv {
epochMs: number;
sats: ConstellationSat[];
stations: ConstellationStation[];
rois: ConstellationRoi[];
prohibits: Record<string, Interval[]>;
/** Подспутниковая точка по реальной трассе; null — нет данных на это время. */
subSat: (sat: ConstellationSat, tMs: number) => { lon: number; lat: number } | null;
}
export interface ConstellationConfig {
env: ConstellationEnv;
domain: { start: number; end: number };
sched: Record<string, { capture: Interval[]; downlink: Interval[]; prohibit: Interval[]; shadow: Interval[] }>;
}
// --- классификация полезной нагрузки по имени КА (из НСИ) -------------------
// Поля payload у платформы нет — определяем по имени/миссии (радиолокация vs оптика).
const RADAR_KEYS = [
"кондор", "kondor", "обзор", "obzor", "sar", "рса", "radar", "локац",
"аркон", "arkon", "стрела", "strela", "пион", "neitron", "нейтрон"
];
function classifyPayload(name: string, mission: string | null | undefined): Payload {
const s = `${name} ${mission ?? ""}`.toLowerCase();
return RADAR_KEYS.some((k) => s.includes(k)) ? "radar" : "optic";
}
// Человеческие названия известных миссий НСИ → подгруппа в списке/таймлайне.
const MISSION_RU: Record<string, string> = {
RESURS_P: "Ресурс-П",
KANOPUS_V: "Канопус-В",
KONDOR_FKA: "Кондор-ФКА",
OBZOR_R: "Обзор-Р",
TERRA_AQUA: "Terra/Aqua"
};
function prettifyMission(mission: string): string {
return mission
.toLowerCase()
.split("_")
.map((w) => (w ? w[0].toUpperCase() + w.slice(1) : w))
.join(" ");
}
function deriveGroup(name: string, mission: string | null | undefined): string {
if (mission) return MISSION_RU[mission] ?? prettifyMission(mission);
// нет миссии — отрезаем хвост «No.3» / «№3» / завершающий номер
const base = name.replace(/\s*(No\.?|№)\s*\d+.*$/i, "").trim();
return base || name;
}
// --- сэмплер позиции по трассе flight-line ---------------------------------
type Sample = { t: number; lon: number; lat: number };
function buildSampler(points: FlightLinePoint[]): (t: number) => { lon: number; lat: number } | null {
const arr: Sample[] = points
.map((p) => ({ t: parseLocal(p.time), lon: wrapLon(p.long), lat: p.lat }))
.filter((s) => Number.isFinite(s.t) && Number.isFinite(s.lon) && Number.isFinite(s.lat))
.sort((a, b) => a.t - b.t);
if (arr.length === 0) return () => null;
const first = arr[0];
const last = arr[arr.length - 1];
return (t: number) => {
if (t <= first.t) return { lon: first.lon, lat: first.lat };
if (t >= last.t) return { lon: last.lon, lat: last.lat };
let lo = 0;
let hi = arr.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (arr[mid].t <= t) lo = mid;
else hi = mid;
}
const a = arr[lo];
const b = arr[hi];
const f = (t - a.t) / (b.t - a.t || 1);
const lat = a.lat + (b.lat - a.lat) * f;
// долготу интерполируем по кратчайшей дельте, чтобы не прыгать через антимеридиан
let dlon = b.lon - a.lon;
if (dlon > 180) dlon -= 360;
else if (dlon < -180) dlon += 360;
return { lon: wrapLon(a.lon + dlon * f), lat };
};
}
// --- ROI из заявок ----------------------------------------------------------
const DEAD_REQUEST = new Set(["DELETED", "EXPIRED"]);
function roiFromRequest(item: RequestMapItem): ConstellationRoi | null {
if (DEAD_REQUEST.has(item.status)) return null;
const rings = parseWktPolygon(item.geometry);
const ring = rings[0];
if (!ring || ring.length < 3) return null;
const poly: [number, number][] = ring.map((pt) => [wrapLon(pt.lon), pt.lat]);
const payload: Payload = item.surveyType === "RSA" ? "radar" : "optic";
return { id: item.id, name: item.name || item.id, payload, poly };
}
// --- порядок КА: домен (ОПТИКА → ЛОКАЦИЯ) → группа → имя --------------------
// Список и таймлайн группируют по соседству, поэтому порядок важен.
function compareSats(a: ConstellationSat, b: ConstellationSat): number {
if (a.domain !== b.domain) return a.domain === "ОПТИКА" ? -1 : 1;
if (a.group !== b.group) return a.group.localeCompare(b.group, "ru");
return a.name.localeCompare(b.name, "ru");
}
/**
* Грузит реальный состав и геометрию группировки и собирает конфиг дашборда.
* Горизонт по умолчанию now-6ч … now+18ч (как в прототипе). КА без трассы
* (например, не эксплуатируемые) на карту не попадают.
*/
export async function loadConstellationEnv(): Promise<ConstellationConfig> {
const now = Date.now();
const domain = { start: now - 6 * 3600e3, end: now + 18 * 3600e3 };
const [platforms, stationDtos, requestItems] = await Promise.all([
fetchPlatforms(),
fetchStations(),
fetchRequestsForMap({})
.then((r) => r.items)
.catch(() => [] as RequestMapItem[])
]);
const stations: ConstellationStation[] = (stationDtos as StationDto[]).map((s) => ({
id: s.id ?? String(s.number),
name: s.name,
lon: s.position.long,
lat: s.position.lat
}));
const stationNameByNumber = new Map<number, string>(
(stationDtos as StationDto[]).map((s) => [s.number, s.name])
);
const rois: ConstellationRoi[] = requestItems
.map(roiFromRequest)
.filter((r): r is ConstellationRoi => r !== null);
// Эксплуатируемые платформы с NORAD — кандидаты в группировку.
const candidates = (platforms as TguPlatform[]).filter(
(p) => isOperationalPlatform(p) && p.noradId != null
);
// Трассу и ЗРВ тянем по каждому КА параллельно; сбой по одному непрозрачен.
const perSat = await Promise.all(
candidates.map(async (p) => {
const norad = Number(p.noradId);
const [line, rva] = await Promise.all([
fetchFlightLine(String(norad), domain.start, domain.end).catch(() => [] as FlightLinePoint[]),
fetchSatelliteRva(norad, domain.start, domain.end).catch(() => [])
]);
return { p, norad, line, rva };
})
);
const sats: ConstellationSat[] = [];
const samplers: Record<string, (t: number) => { lon: number; lat: number } | null> = {};
const rvaBySat: Record<string, typeof perSat[number]["rva"]> = {};
for (const { p, norad, line, rva } of perSat) {
if (line.length === 0) continue; // нет трассы — на карте не разместить
const id = String(norad);
const name = p.name ?? id;
const payload = classifyPayload(name, p.mission);
sats.push({
id,
name,
norad,
payload,
domain: payload === "radar" ? "ЛОКАЦИЯ" : "ОПТИКА",
group: deriveGroup(name, p.mission),
color: COL[payload],
mem0: (norad % 60) + 15 // памяти борта нет в телеметрии — модельная стартовая
});
samplers[id] = buildSampler(line);
rvaBySat[id] = rva;
}
sats.sort(compareSats);
const prohibits: Record<string, Interval[]> = Object.fromEntries(sats.map((s) => [s.id, []]));
const env: ConstellationEnv = {
epochMs: now,
sats,
stations,
rois,
prohibits,
subSat: (sat, t) => (samplers[sat.id] ? samplers[sat.id](t) : null)
};
// Съёмка (по пролёту над ROI), тень и геометрический сброс — из реальных позиций.
// Шаг 0.5 мин, чтобы не проскочить мелкие полигоны заявок.
const sched = buildSchedule(sats, env, domain.start, domain.end, 0.5) as ConstellationConfig["sched"];
// Сброс заменяем авторитетными зонами радиовидимости, где они есть.
for (const s of sats) {
const rva = rvaBySat[s.id];
if (!rva || rva.length === 0) continue;
sched[s.id].downlink = rva
.map((z) => ({
start: parseLocal(z.onStart.time),
end: parseLocal(z.onStop.time),
label: stationNameByNumber.get(z.stationId) ?? `ст. ${z.stationId}`
}))
.filter((iv) => Number.isFinite(iv.start) && Number.isFinite(iv.end) && iv.end > iv.start)
.sort((a, b) => a.start - b.start);
}
return { env, domain, sched };
}
@@ -0,0 +1,164 @@
/* ============================================================
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 = (lat2 - lat1) * D2R;
const = (lon2 - lon1) * D2R;
const a = Math.sin( / 2) ** 2 + Math.cos(φ1) * Math.cos(φ2) * Math.sin( / 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;
}
// sub-satellite point: реальный сэмплер из env.subSat (эфемерида/flight-line),
// иначе встроенная синусоидальная симуляция. См. INTEGRATION.md §5.2.
function subSatOf(sat, tMs, env) {
return env.subSat ? env.subSat(sat, tMs) : subSat(sat, tMs, env.epochMs);
}
// live status of a sat at time t (priority order)
// returns {state, roi, station, eclipse, prohibit}
function statusAt(sat, tMs, env) {
const p = subSatOf(sat, tMs, env);
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 = subSatOf(s, t, env);
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})`;
}
@@ -10,6 +10,7 @@ import {
import type { TguPlan, TguPlanDecision, TguPlatform } from "../../model/tguTypes";
import type { TguActiveTab, TguPlanUi, TimelineRange } from "../../model/timelineTypes";
import { TguComplexPlanTab } from "../tgu-complex-plan/TguComplexPlanTab";
import { ConstellationTab } from "../constellation/ConstellationTab";
import { TguEditorTab } from "../tgu-editor/TguEditorTab";
import { Tgu2DMapTab } from "../tgu-map-2d/Tgu2DMapTab";
import { TguRequestsTab } from "../tgu-requests/TguRequestsTab";
@@ -586,6 +587,12 @@ export function TguPlanningPage() {
</div>
)}
{visitedTabs.has("constellation") && (
<div style={tabStyle("constellation")}>
<ConstellationTab />
</div>
)}
{visitedTabs.has("editor") && (
<div style={tabStyle("editor")}>
<TguEditorTab
@@ -83,6 +83,15 @@ export function TguToolbar({
>
Заявки
</button>
<button
className={activeTab === "constellation" ? "is-active" : ""}
type="button"
role="tab"
aria-selected={activeTab === "constellation"}
onClick={() => onTabChange("constellation")}
>
Группировка
</button>
</div>
<label className="tgu-field">
@@ -22,7 +22,7 @@ export type TimelineRange = {
toMs: number;
};
export type TguActiveTab = "timeline" | "map" | "complex" | "editor" | "requests";
export type TguActiveTab = "timeline" | "map" | "complex" | "editor" | "requests" | "constellation";
export type TimelineRow = {
spacecraftId: string;
+1 -1
View File
@@ -4,7 +4,7 @@
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,