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:
+50
@@ -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",
|
||||
|
||||
@@ -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 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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
|
||||
Reference in New Issue
Block a user