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:
@@ -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 };
|
||||
Reference in New Issue
Block a user