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
@@ -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 };