Files
dc-observatio/services/pcp-tgu-ops-ui/src/features/constellation/Panels.jsx
T
Дмитрий Соловьев 821de2b0c0 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-модуль
2026-06-05 23:37:47 +03:00

183 lines
7.7 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================
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 };