pcp-tgu интерфейс
This commit is contained in:
@@ -54,3 +54,4 @@ out/
|
|||||||
/pcp-chatgpt-context*.zip
|
/pcp-chatgpt-context*.zip
|
||||||
/deploy/local-bundle/build/
|
/deploy/local-bundle/build/
|
||||||
/deploy/local-bundle/pcp-local-bundle-*.tar.gz
|
/deploy/local-bundle/pcp-local-bundle-*.tar.gz
|
||||||
|
/services/pcp-tgu-ops-ui/node_modules/
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 5.6 KiB |
@@ -0,0 +1,221 @@
|
|||||||
|
// Корневой компонент: состояние, фильтры, масштаб, тулбар, сборка строк.
|
||||||
|
const { useState, useMemo, useEffect, useRef } = React;
|
||||||
|
|
||||||
|
// Пресеты масштаба: имя → видимое окно в мс (центрируется на «сейчас» при выборе).
|
||||||
|
const HOUR = D.HOUR, DAY = D.DAY;
|
||||||
|
const SCALES = [
|
||||||
|
{ id: "week", label: "Неделя", span: 7 * DAY, minPx: 64 },
|
||||||
|
{ id: "2week", label: "2 недели", span: 14 * DAY, minPx: 70 },
|
||||||
|
{ id: "month", label: "Месяц", span: 30 * DAY, minPx: 80 },
|
||||||
|
{ id: "range", label: "Весь диапазон", span: D.RANGE_END - D.RANGE_START, minPx: 90 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
// Видимость групп / скрытые КА.
|
||||||
|
const [groupShown, setGroupShown] = useState(() => {
|
||||||
|
const o = {}; D.GROUP_DEFS.forEach((g) => (o[g.id] = true)); return o;
|
||||||
|
});
|
||||||
|
const [scHidden, setScHidden] = useState(() => new Set());
|
||||||
|
const [typeFilter, setTypeFilter] = useState({ optical: true, radar: true, combo: true });
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [scaleId, setScaleId] = useState("week");
|
||||||
|
const [selectedIv, setSelectedIv] = useState(null);
|
||||||
|
const [selectedSc, setSelectedSc] = useState(null);
|
||||||
|
const [timelineCollapsed, setTimelineCollapsed] = useState(() => new Set());
|
||||||
|
const [scrollSignal, setScrollSignal] = useState(0);
|
||||||
|
const [winCenter, setWinCenter] = useState(D.NOW);
|
||||||
|
|
||||||
|
const byId = useMemo(() => {
|
||||||
|
const m = {}; D.plans.forEach((iv) => (m[iv.id] = iv)); return m;
|
||||||
|
}, []);
|
||||||
|
const scById = useMemo(() => {
|
||||||
|
const m = {}; D.spacecraft.forEach((sc) => (m[sc.id] = sc)); return m;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Масштаб → px/ms (умещаем span в условные 1100px рабочей ширины).
|
||||||
|
const scale = SCALES.find((s) => s.id === scaleId);
|
||||||
|
const VIEW_W = 1180;
|
||||||
|
const pxPerMs = VIEW_W / scale.span;
|
||||||
|
const rangeStart = D.RANGE_START;
|
||||||
|
const rangeEnd = D.RANGE_END;
|
||||||
|
|
||||||
|
// Фильтрация КА.
|
||||||
|
const scByGroup = useMemo(() => {
|
||||||
|
const m = {};
|
||||||
|
D.spacecraft.forEach((sc) => { (m[sc.groupId] = m[sc.groupId] || []).push(sc); });
|
||||||
|
return m;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
const matchesSearch = (sc) => !q || sc.name.toLowerCase().includes(q) || sc.short.toLowerCase().includes(q);
|
||||||
|
|
||||||
|
// Счётчики для легенды.
|
||||||
|
const counts = useMemo(() => {
|
||||||
|
const byType = {};
|
||||||
|
D.spacecraft.forEach((sc) => { byType[sc.type] = (byType[sc.type] || 0) + 1; });
|
||||||
|
return { byType };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Интервалы по КА (с учётом типового фильтра не трогаем — фильтр по типу скрывает строки целиком).
|
||||||
|
const ivBySc = useMemo(() => {
|
||||||
|
const m = {};
|
||||||
|
D.plans.forEach((iv) => { (m[iv.scId] = m[iv.scId] || []).push(iv); });
|
||||||
|
return m;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Сборка строк таймлайна.
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
const out = [];
|
||||||
|
for (const g of D.GROUP_DEFS) {
|
||||||
|
if (!groupShown[g.id]) continue;
|
||||||
|
const list = (scByGroup[g.id] || [])
|
||||||
|
.filter((sc) => typeFilter[sc.type])
|
||||||
|
.filter((sc) => !scHidden.has(sc.id))
|
||||||
|
.filter(matchesSearch);
|
||||||
|
if (list.length === 0) continue;
|
||||||
|
out.push({ kind: "group", group: g, count: list.length });
|
||||||
|
if (timelineCollapsed.has(g.id)) continue;
|
||||||
|
for (const sc of list) {
|
||||||
|
out.push({ kind: "sc", sc, ivs: ivBySc[sc.id] || [] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [groupShown, scHidden, typeFilter, q, timelineCollapsed]);
|
||||||
|
|
||||||
|
// Цепочка родословной выбранного интервала.
|
||||||
|
const lineageSet = useMemo(() => {
|
||||||
|
if (!selectedIv) return null;
|
||||||
|
const lin = lineageOf(selectedIv, byId);
|
||||||
|
return new Set(lin.chain);
|
||||||
|
}, [selectedIv]);
|
||||||
|
|
||||||
|
const selIv = selectedIv ? byId[selectedIv] : null;
|
||||||
|
const selSc = selIv ? scById[selIv.scId] : null;
|
||||||
|
const selGroup = selSc ? D.GROUP_DEFS.find((g) => g.id === selSc.groupId) : null;
|
||||||
|
|
||||||
|
const toggleGroup = (id) => setGroupShown((s) => ({ ...s, [id]: !s[id] }));
|
||||||
|
const toggleSc = (id) => setScHidden((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
|
||||||
|
const toggleCollapse = (id) => setTimelineCollapsed((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
|
||||||
|
|
||||||
|
const onPickSc = (id) => {
|
||||||
|
setSelectedSc(id);
|
||||||
|
// прокрутка к первому интервалу этого КА
|
||||||
|
const ivs = ivBySc[id] || [];
|
||||||
|
if (ivs.length) { setSelectedIv(null); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Статистика по видимым.
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
let sc = 0, iv = 0, ov = 0;
|
||||||
|
rows.forEach((r) => {
|
||||||
|
if (r.kind === "sc") {
|
||||||
|
sc++;
|
||||||
|
iv += r.ivs.length;
|
||||||
|
ov += overlapSegments(r.ivs).length;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { sc, iv, ov };
|
||||||
|
}, [rows]);
|
||||||
|
|
||||||
|
const allOn = D.GROUP_DEFS.every((g) => groupShown[g.id]) && scHidden.size === 0;
|
||||||
|
const showAll = () => { const o = {}; D.GROUP_DEFS.forEach((g) => (o[g.id] = true)); setGroupShown(o); setScHidden(new Set()); };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: `268px 1fr ${selIv ? "340px" : "0px"}`, height: "100vh", transition: "grid-template-columns .18s" }}>
|
||||||
|
<Sidebar
|
||||||
|
groups={D.GROUP_DEFS} scByGroup={scByGroup}
|
||||||
|
typeFilter={typeFilter} setTypeFilter={setTypeFilter}
|
||||||
|
groupShown={groupShown} toggleGroup={toggleGroup}
|
||||||
|
scHidden={scHidden} toggleSc={toggleSc}
|
||||||
|
search={search} setSearch={setSearch}
|
||||||
|
counts={counts} selectedSc={selectedSc} onPickSc={onPickSc}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", minWidth: 0 }}>
|
||||||
|
{/* ТУЛБАР */}
|
||||||
|
<div style={{ height: 50, flex: "none", display: "flex", alignItems: "center", gap: 14, padding: "0 16px", borderBottom: "1px solid var(--line)", background: "var(--bg-1)" }}>
|
||||||
|
{/* масштаб */}
|
||||||
|
<div style={{ display: "flex", gap: 2, background: "var(--bg-0)", borderRadius: 7, padding: 3, border: "1px solid var(--line)" }}>
|
||||||
|
{SCALES.map((s) => (
|
||||||
|
<button key={s.id} onClick={() => { setScaleId(s.id); setScrollSignal((x) => x + 1); }}
|
||||||
|
style={{ padding: "5px 11px", borderRadius: 5, border: "none", fontSize: 11.5, fontWeight: 600, background: scaleId === s.id ? "var(--bg-3)" : "transparent", color: scaleId === s.id ? "var(--ink)" : "var(--ink-3)" }}>{s.label}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onClick={() => setScrollSignal((x) => x + 1)} style={{ display: "flex", alignItems: "center", gap: 6, padding: "6px 11px", borderRadius: 6, border: "1px solid var(--line)", background: "var(--bg-2)", fontSize: 11.5, fontWeight: 600, color: "var(--now)" }}>
|
||||||
|
<span style={{ width: 6, height: 6, borderRadius: 9, background: "var(--now)", boxShadow: "0 0 6px var(--now)" }} />Сейчас
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div style={{ width: 1, height: 22, background: "var(--line)" }} />
|
||||||
|
<WorksLegend />
|
||||||
|
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
|
||||||
|
{/* статистика */}
|
||||||
|
<div style={{ display: "flex", gap: 16, alignItems: "center" }}>
|
||||||
|
<Stat label="АППАРАТОВ" v={stats.sc} />
|
||||||
|
<Stat label="ПЛАНОВ" v={stats.iv} />
|
||||||
|
<Stat label="ПЕРЕСЕЧЕНИЙ" v={stats.ov} accent={stats.ov > 0 ? "var(--now)" : null} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!allOn && (
|
||||||
|
<button onClick={showAll} style={{ padding: "6px 11px", borderRadius: 6, border: "1px solid var(--line)", background: "var(--bg-2)", fontSize: 11.5, color: "var(--ink-1)" }}>Показать все</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<div style={{ flex: 1, display: "grid", placeItems: "center", color: "var(--ink-3)", fontSize: 13 }}>
|
||||||
|
Нет аппаратов по текущим фильтрам.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ flex: 1, minHeight: 0 }} onClick={() => {}}>
|
||||||
|
<Timeline
|
||||||
|
rows={rows} pxPerMs={pxPerMs} rangeStart={rangeStart} rangeEnd={rangeEnd}
|
||||||
|
now={D.NOW} selectedIv={selectedIv} onSelectIv={setSelectedIv}
|
||||||
|
lineageSet={lineageSet} byId={byId} scrollSignal={scrollSignal}
|
||||||
|
timelineCollapsed={timelineCollapsed} toggleCollapse={toggleCollapse}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Details iv={selIv} sc={selSc} group={selGroup} byId={byId} onSelectIv={setSelectedIv} onClose={() => setSelectedIv(null)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorksLegend() {
|
||||||
|
const items = [
|
||||||
|
{ k: "shoot", label: "Съёмка", stripes: true },
|
||||||
|
{ k: "downlink", label: "Сброс", fill: "var(--accent)" },
|
||||||
|
{ k: "calib", label: "Калибровка", fill: "oklch(0.78 0.02 200)" },
|
||||||
|
{ k: "service", label: "Служебная", dashed: true },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 11 }}>
|
||||||
|
<span className="mono" style={{ fontSize: 8.5, color: "var(--ink-3)", letterSpacing: ".08em" }}>РАБОТЫ:</span>
|
||||||
|
{items.map((it) => (
|
||||||
|
<div key={it.k} style={{ display: "flex", alignItems: "center", gap: 5 }}>
|
||||||
|
<span style={{
|
||||||
|
width: 11, height: 9, borderRadius: 2, flex: "none",
|
||||||
|
background: it.stripes
|
||||||
|
? "linear-gradient(90deg, var(--t-optical) 0 33%, var(--t-radar) 33% 66%, var(--t-combo) 66% 100%)"
|
||||||
|
: it.dashed ? "transparent" : it.fill,
|
||||||
|
border: it.dashed ? "1px dashed var(--ink-3)" : "none",
|
||||||
|
}} />
|
||||||
|
<span style={{ fontSize: 10.5, color: "var(--ink-2)" }}>{it.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ label, v, accent }) { return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", lineHeight: 1.1 }}>
|
||||||
|
<span className="mono tnum" style={{ fontSize: 15, fontWeight: 600, color: accent || "var(--ink)" }}>{v}</span>
|
||||||
|
<span className="mono" style={{ fontSize: 8, color: "var(--ink-3)", letterSpacing: ".08em" }}>{label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
// Тестовые данные планов КА. Детерминированная генерация (seeded RNG),
|
||||||
|
// чтобы при перезагрузке данные были стабильны.
|
||||||
|
(function () {
|
||||||
|
function mulberry32(a) {
|
||||||
|
return function () {
|
||||||
|
a |= 0; a = (a + 0x6D2B79F5) | 0;
|
||||||
|
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const rnd = mulberry32(20260529);
|
||||||
|
const pick = (arr) => arr[Math.floor(rnd() * arr.length)];
|
||||||
|
const randint = (a, b) => a + Math.floor(rnd() * (b - a + 1));
|
||||||
|
|
||||||
|
const HOUR = 3600 * 1000;
|
||||||
|
const DAY = 24 * HOUR;
|
||||||
|
// "Сейчас" — фиксируем под текущую дату проекта.
|
||||||
|
const NOW = new Date("2026-05-29T11:20:00Z").getTime();
|
||||||
|
|
||||||
|
// Три типа по аппаратуре (все аппараты — ДЗЗ).
|
||||||
|
const TYPES = {
|
||||||
|
optical: { id: "optical", label: "Оптическая", short: "ОПТ" },
|
||||||
|
radar: { id: "radar", label: "Радиолокационная", short: "РЛ" },
|
||||||
|
combo: { id: "combo", label: "Комбинированная", short: "ОПТ+РЛ" },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Программы/созвездия (название группы). Каждая группа тяготеет к своим типам.
|
||||||
|
const GROUP_DEFS = [
|
||||||
|
{ id: "resurs", name: "Ресурс-ОП", code: "RSO", weights: { optical: 0.8, radar: 0.05, combo: 0.15 } },
|
||||||
|
{ id: "kanopus", name: "Канопус-В", code: "KNP", weights: { optical: 0.7, radar: 0.1, combo: 0.2 } },
|
||||||
|
{ id: "kondor", name: "Кондор-ФКА", code: "KND", weights: { optical: 0.05, radar: 0.85, combo: 0.1 } },
|
||||||
|
{ id: "obzor", name: "Обзор-Р", code: "OBZ", weights: { optical: 0.05, radar: 0.8, combo: 0.15 } },
|
||||||
|
{ id: "meteor", name: "Метеор-М", code: "MTR", weights: { optical: 0.55, radar: 0.15, combo: 0.3 } },
|
||||||
|
{ id: "aist", name: "Аист-2", code: "AST", weights: { optical: 0.6, radar: 0.1, combo: 0.3 } },
|
||||||
|
{ id: "smotr", name: "Смотр-Р", code: "SMT", weights: { optical: 0.1, radar: 0.55, combo: 0.35 } },
|
||||||
|
];
|
||||||
|
|
||||||
|
function weightedType(weights) {
|
||||||
|
const r = rnd();
|
||||||
|
let acc = 0;
|
||||||
|
for (const k of Object.keys(weights)) {
|
||||||
|
acc += weights[k];
|
||||||
|
if (r <= acc) return k;
|
||||||
|
}
|
||||||
|
return "optical";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Виды РАБОТ внутри плана. У каждой работы — своя категория (kind).
|
||||||
|
const WORK_KINDS = {
|
||||||
|
shoot: { id: "shoot", label: "Съёмка" },
|
||||||
|
downlink: { id: "downlink", label: "Сброс на НКПОР" },
|
||||||
|
calib: { id: "calib", label: "Калибровка" },
|
||||||
|
service: { id: "service", label: "Служебная операция" },
|
||||||
|
};
|
||||||
|
const WORK_KIND_ORDER = ["shoot", "downlink", "calib", "service"];
|
||||||
|
// Подпись работы-съёмки зависит от аппаратуры КА.
|
||||||
|
const SHOOT_LABEL = {
|
||||||
|
optical: ["Панхром. съёмка", "Мультиспектр. съёмка", "Стереосъёмка", "Площадная съёмка"],
|
||||||
|
radar: ["РСА съёмка", "Интерферометрия", "Прожекторный режим", "Сканирующий режим"],
|
||||||
|
combo: ["Совмещ. съёмка", "РСА + панхром.", "Площадная съёмка", "Мониторинг ЧС"],
|
||||||
|
};
|
||||||
|
// Названия самих ПЛАНОВ (объемлющих интервалов).
|
||||||
|
const PLAN_NAMES = ["Суточная программа", "Целевая программа", "Спецзадание", "Программа мониторинга", "Сеансовый план", "Программа ДЗЗ"];
|
||||||
|
|
||||||
|
function genWorks(planStart, planEnd, scType, makeId) {
|
||||||
|
const works = [];
|
||||||
|
const span = planEnd - planStart;
|
||||||
|
// Плотность работ ~ 1 на 4–9 часов плана.
|
||||||
|
let cursor = planStart + randint(0, 2) * HOUR;
|
||||||
|
let guard = 0;
|
||||||
|
while (cursor < planEnd - 1 * HOUR && guard < 40) {
|
||||||
|
guard++;
|
||||||
|
const kind = pick(WORK_KIND_ORDER);
|
||||||
|
let dur;
|
||||||
|
if (kind === "shoot") dur = randint(20, 140) * 60 * 1000; // 20м–2.3ч
|
||||||
|
else if (kind === "downlink") dur = randint(8, 35) * 60 * 1000; // сброс
|
||||||
|
else if (kind === "calib") dur = randint(30, 90) * 60 * 1000;
|
||||||
|
else dur = randint(10, 40) * 60 * 1000; // служебная
|
||||||
|
const wStart = cursor;
|
||||||
|
const wEnd = Math.min(planEnd, wStart + dur);
|
||||||
|
if (wEnd <= wStart) break;
|
||||||
|
const label = kind === "shoot" ? pick(SHOOT_LABEL[scType]) : WORK_KINDS[kind].label;
|
||||||
|
works.push({ id: makeId(), kind, label, start: wStart, end: wEnd });
|
||||||
|
// пауза между работами
|
||||||
|
cursor = wEnd + randint(20, 180) * 60 * 1000;
|
||||||
|
}
|
||||||
|
return works;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Генерация КА.
|
||||||
|
const spacecraft = [];
|
||||||
|
let scCounter = 0;
|
||||||
|
for (const g of GROUP_DEFS) {
|
||||||
|
const count = randint(8, 16); // суммарно ~80
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const type = weightedType(g.weights);
|
||||||
|
scCounter++;
|
||||||
|
const num = i + 1;
|
||||||
|
spacecraft.push({
|
||||||
|
id: `${g.id}-${num}`,
|
||||||
|
name: `${g.name} №${num}`,
|
||||||
|
short: `${g.code}-${String(num).padStart(2, "0")}`,
|
||||||
|
groupId: g.id,
|
||||||
|
type,
|
||||||
|
orbit: pick(["ССО 510 км", "ССО 575 км", "ССО 700 км", "ССО 480 км"]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Видимый горизонт планирования: ~ -16 сут .. +30 сут от "сейчас".
|
||||||
|
const RANGE_START = NOW - 16 * DAY;
|
||||||
|
const RANGE_END = NOW + 30 * DAY;
|
||||||
|
|
||||||
|
function statusFor(start, end) {
|
||||||
|
if (end < NOW) return "executed";
|
||||||
|
if (start <= NOW && NOW <= end) return "active";
|
||||||
|
return "planned";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Генерация ПЛАНОВ (объемлющих интервалов) с родословной (ревизии плана = потомки).
|
||||||
|
// Каждый план содержит набор РАБОТ внутри своего интервала.
|
||||||
|
const plans = [];
|
||||||
|
let ivCounter = 0;
|
||||||
|
let workCounter = 0;
|
||||||
|
const makeWorkId = () => `W-${String(++workCounter).padStart(5, "0")}`;
|
||||||
|
for (const sc of spacecraft) {
|
||||||
|
// стартовая точка цепочки
|
||||||
|
let cursor = RANGE_START + randint(0, 3) * DAY + randint(0, 20) * HOUR;
|
||||||
|
let prevId = null;
|
||||||
|
const nChains = randint(2, 4);
|
||||||
|
for (let c = 0; c < nChains; c++) {
|
||||||
|
// базовый план в цепочке
|
||||||
|
const dur = randint(8, 60) * HOUR;
|
||||||
|
const start = cursor;
|
||||||
|
const end = start + dur;
|
||||||
|
ivCounter++;
|
||||||
|
const baseId = `PL-${String(ivCounter).padStart(4, "0")}`;
|
||||||
|
const planName = pick(PLAN_NAMES);
|
||||||
|
plans.push({
|
||||||
|
id: baseId,
|
||||||
|
scId: sc.id,
|
||||||
|
start, end,
|
||||||
|
title: planName,
|
||||||
|
rev: 1,
|
||||||
|
parentId: prevId,
|
||||||
|
status: statusFor(start, end),
|
||||||
|
author: pick(["ЦУП-1", "ЦУП-2", "ПЦ Москва", "ПЦ Дубна", "Авто-планировщик"]),
|
||||||
|
works: genWorks(start, end, sc.type, makeWorkId),
|
||||||
|
});
|
||||||
|
prevId = baseId;
|
||||||
|
|
||||||
|
// c вероятностью — ревизия, перекрывающая базовый план (создаёт пересечение)
|
||||||
|
if (rnd() < 0.45) {
|
||||||
|
const overlapStart = start + Math.floor(dur * (0.35 + rnd() * 0.4));
|
||||||
|
const revDur = randint(10, 50) * HOUR;
|
||||||
|
const revEnd = overlapStart + revDur;
|
||||||
|
ivCounter++;
|
||||||
|
const revId = `PL-${String(ivCounter).padStart(4, "0")}`;
|
||||||
|
plans.push({
|
||||||
|
id: revId,
|
||||||
|
scId: sc.id,
|
||||||
|
start: overlapStart,
|
||||||
|
end: revEnd,
|
||||||
|
title: planName,
|
||||||
|
rev: 2,
|
||||||
|
parentId: baseId,
|
||||||
|
status: statusFor(overlapStart, revEnd),
|
||||||
|
author: pick(["ЦУП-1", "ЦУП-2", "ПЦ Москва", "Авто-планировщик"]),
|
||||||
|
works: genWorks(overlapStart, revEnd, sc.type, makeWorkId),
|
||||||
|
});
|
||||||
|
prevId = revId;
|
||||||
|
cursor = revEnd + randint(4, 36) * HOUR;
|
||||||
|
} else {
|
||||||
|
cursor = end + randint(6, 48) * HOUR;
|
||||||
|
}
|
||||||
|
if (cursor > RANGE_END) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Индексы родословной: дети по родителю.
|
||||||
|
const childrenOf = {};
|
||||||
|
for (const iv of plans) {
|
||||||
|
if (iv.parentId) {
|
||||||
|
(childrenOf[iv.parentId] = childrenOf[iv.parentId] || []).push(iv.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.MissionData = {
|
||||||
|
HOUR, DAY, NOW, RANGE_START, RANGE_END,
|
||||||
|
TYPES, GROUP_DEFS, WORK_KINDS, WORK_KIND_ORDER,
|
||||||
|
spacecraft, plans, childrenOf,
|
||||||
|
typeOrder: ["optical", "radar", "combo"],
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
// Правая панель деталей выбранного интервала + лента родословной (предки/потомки).
|
||||||
|
function StatusBadge({ status }) {
|
||||||
|
const map = {
|
||||||
|
executed: { t: "ВЫПОЛНЕН", c: "var(--ink-2)", bg: "var(--bg-3)" },
|
||||||
|
active: { t: "АКТИВЕН", c: "var(--now)", bg: "oklch(0.86 0.16 25 / 0.16)" },
|
||||||
|
planned: { t: "ЗАПЛАНИРОВАН", c: "var(--accent)", bg: "oklch(0.82 0.13 200 / 0.14)" },
|
||||||
|
};
|
||||||
|
const s = map[status] || map.planned;
|
||||||
|
return <span className="mono" style={{ fontSize: 9.5, fontWeight: 600, letterSpacing: ".06em", color: s.c, background: s.bg, padding: "2px 7px", borderRadius: 4 }}>{s.t}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, children, mono }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
|
||||||
|
<span className="mono" style={{ fontSize: 9, color: "var(--ink-3)", letterSpacing: ".08em" }}>{label}</span>
|
||||||
|
<span className={mono ? "mono tnum" : ""} style={{ fontSize: 12.5, color: "var(--ink)" }}>{children}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Details({ iv, sc, group, byId, onSelectIv, onClose }) {
|
||||||
|
if (!iv) {
|
||||||
|
return (
|
||||||
|
<div style={{ height: "100%", background: "var(--bg-1)", borderLeft: "1px solid var(--line)", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 10, padding: 24, textAlign: "center" }}>
|
||||||
|
<svg width="34" height="34" viewBox="0 0 24 24" fill="none" style={{ opacity: .3 }}><rect x="3" y="6" width="18" height="3.4" rx="1.5" stroke="var(--ink-2)" strokeWidth="1.6" /><rect x="3" y="13" width="11" height="3.4" rx="1.5" stroke="var(--ink-2)" strokeWidth="1.6" /></svg>
|
||||||
|
<div style={{ fontSize: 12, color: "var(--ink-3)", maxWidth: 180 }}>Выберите план на таймлайне, чтобы увидеть его работы и цепочку ревизий.</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const lin = lineageOf(iv.id, byId);
|
||||||
|
const dur = iv.end - iv.start;
|
||||||
|
const col = TYPE_COLOR[sc.type];
|
||||||
|
|
||||||
|
const chainRow = (id) => {
|
||||||
|
const it = byId[id];
|
||||||
|
const isSel = id === iv.id;
|
||||||
|
return (
|
||||||
|
<button key={id} onClick={() => onSelectIv(id)} style={{
|
||||||
|
display: "flex", alignItems: "center", gap: 8, width: "100%", textAlign: "left",
|
||||||
|
padding: "7px 9px", borderRadius: 6, border: `1px solid ${isSel ? "var(--accent)" : "var(--line-soft)"}`,
|
||||||
|
background: isSel ? "oklch(0.82 0.13 200 / 0.12)" : "var(--bg-2)",
|
||||||
|
}}>
|
||||||
|
<span className="mono tnum" style={{ fontSize: 9.5, color: "var(--ink-3)", flex: "none" }}>{it.id}</span>
|
||||||
|
<span style={{ flex: 1, fontSize: 11.5, color: isSel ? "var(--ink)" : "var(--ink-1)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{it.title}</span>
|
||||||
|
<span className="mono" style={{ fontSize: 8.5, color: "var(--ink-3)", border: "1px solid var(--line)", borderRadius: 3, padding: "0 4px", flex: "none" }}>r{it.rev}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: "100%", background: "var(--bg-1)", borderLeft: "1px solid var(--line)", display: "flex", flexDirection: "column" }}>
|
||||||
|
<div style={{ padding: "14px 16px 12px", borderBottom: "1px solid var(--line-soft)" }}>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 8 }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
|
||||||
|
<span style={{ width: 10, height: 10, borderRadius: 3, background: col, boxShadow: `0 0 8px ${col}`, flex: "none" }} />
|
||||||
|
<span className="mono" style={{ fontSize: 10, color: "var(--ink-3)" }}>{iv.id}</span>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} style={{ background: "none", border: "none", color: "var(--ink-3)", fontSize: 18, lineHeight: 1, padding: 0 }}>×</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 17, fontWeight: 700, marginTop: 8, lineHeight: 1.2 }}>{iv.title}</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 9 }}>
|
||||||
|
<StatusBadge status={iv.status} />
|
||||||
|
<span className="mono" style={{ fontSize: 10.5, color: "var(--ink-2)" }}>{sc.short} · {group.name}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, overflowY: "auto", padding: "14px 16px 20px", display: "flex", flexDirection: "column", gap: 16 }}>
|
||||||
|
{/* Время */}
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
|
||||||
|
<Field label="НАЧАЛО" mono>{fmtDateTime(iv.start)}</Field>
|
||||||
|
<Field label="КОНЕЦ" mono>{fmtDateTime(iv.end)}</Field>
|
||||||
|
<Field label="ДЛИТЕЛЬНОСТЬ" mono>{fmtDur(dur)}</Field>
|
||||||
|
<Field label="РАБОТ В ПЛАНЕ" mono>{iv.works.length}</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ height: 1, background: "var(--line-soft)" }} />
|
||||||
|
|
||||||
|
{/* Аппарат */}
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||||
|
<span className="mono" style={{ fontSize: 9, color: "var(--ink-3)", letterSpacing: ".08em" }}>АППАРАТ</span>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 9 }}>
|
||||||
|
<TypeDot type={sc.type} size={9} />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600 }}>{sc.name}</div>
|
||||||
|
<div style={{ fontSize: 10.5, color: "var(--ink-3)" }}>{D.TYPES[sc.type].label} · {sc.orbit}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Field label="ИСТОЧНИК ПЛАНА">{iv.author}</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ height: 1, background: "var(--line-soft)" }} />
|
||||||
|
|
||||||
|
{/* Работы внутри плана */}
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 9 }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||||
|
<span className="mono" style={{ fontSize: 9, color: "var(--ink-3)", letterSpacing: ".08em" }}>РАБОТЫ В ПЛАНЕ</span>
|
||||||
|
<span className="mono" style={{ fontSize: 9, color: "var(--ink-3)" }}>{iv.works.length}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
|
||||||
|
{iv.works.length === 0 && <div style={{ fontSize: 11, color: "var(--ink-3)" }}>Работы не заданы.</div>}
|
||||||
|
{iv.works.map((wk) => {
|
||||||
|
const fill = workFill(wk.kind, sc.type);
|
||||||
|
const isService = wk.kind === "service";
|
||||||
|
return (
|
||||||
|
<div key={wk.id} style={{ display: "flex", alignItems: "center", gap: 8, padding: "5px 8px", borderRadius: 6, background: "var(--bg-2)" }}>
|
||||||
|
<span style={{ width: 9, height: 9, borderRadius: 2, flex: "none", background: isService ? "transparent" : fill, border: isService ? `1px dashed ${fill}` : "none" }} />
|
||||||
|
<span style={{ flex: 1, fontSize: 11.5, color: "var(--ink-1)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{wk.label}</span>
|
||||||
|
<span className="mono tnum" style={{ fontSize: 10, color: "var(--ink-3)", flex: "none" }}>{fmtTime(wk.start)}–{fmtTime(wk.end)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ height: 1, background: "var(--line-soft)" }} />
|
||||||
|
|
||||||
|
{/* Родословная */}
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 9 }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||||
|
<span className="mono" style={{ fontSize: 9, color: "var(--ink-3)", letterSpacing: ".08em" }}>РОДОСЛОВНАЯ ПЛАНА</span>
|
||||||
|
<span className="mono" style={{ fontSize: 9, color: "var(--ink-3)" }}>{lin.chain.length} ревизий</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lin.ancestors.length > 0 && (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
|
||||||
|
<span style={{ fontSize: 9.5, color: "var(--ink-3)" }}>◀ Предки</span>
|
||||||
|
{lin.ancestors.map(chainRow)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
|
||||||
|
<span style={{ fontSize: 9.5, color: "var(--accent)" }}>● Текущий</span>
|
||||||
|
{chainRow(iv.id)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lin.descendants.length > 0 && (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
|
||||||
|
<span style={{ fontSize: 9.5, color: "var(--ink-3)" }}>▶ Потомки</span>
|
||||||
|
{lin.descendants.map(chainRow)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{lin.ancestors.length === 0 && lin.descendants.length === 0 && (
|
||||||
|
<div style={{ fontSize: 11, color: "var(--ink-3)", padding: "4px 0" }}>Это единственная ревизия плана — без предков и потомков.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(window, { Details });
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
// Левая панель: поиск, фильтр по типам (легенда), дерево групп → КА с чекбоксами.
|
||||||
|
const { useState: useStateSB } = React;
|
||||||
|
|
||||||
|
function TypeDot({ type, size = 9 }) {
|
||||||
|
return (
|
||||||
|
<span style={{
|
||||||
|
width: size, height: size, borderRadius: 2, flex: "none",
|
||||||
|
background: TYPE_COLOR[type], display: "inline-block",
|
||||||
|
boxShadow: `0 0 6px ${TYPE_COLOR[type]}`,
|
||||||
|
}} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Check({ on, onClick, dim }) {
|
||||||
|
return (
|
||||||
|
<button onClick={onClick} aria-label="toggle" style={{
|
||||||
|
width: 16, height: 16, flex: "none", borderRadius: 4,
|
||||||
|
border: `1.5px solid ${on ? "var(--accent)" : "var(--line)"}`,
|
||||||
|
background: on ? "var(--accent)" : "transparent",
|
||||||
|
opacity: dim ? 0.4 : 1,
|
||||||
|
display: "grid", placeItems: "center", padding: 0, transition: "all .12s",
|
||||||
|
}}>
|
||||||
|
{on && (
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10"><path d="M1.5 5.2 4 7.6 8.6 2.4" stroke="var(--bg-0)" strokeWidth="1.8" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sidebar(props) {
|
||||||
|
const { groups, scByGroup, typeFilter, setTypeFilter, groupShown, toggleGroup,
|
||||||
|
scHidden, toggleSc, search, setSearch, counts, selectedSc, onPickSc } = props;
|
||||||
|
const [open, setOpen] = useStateSB(() => {
|
||||||
|
const o = {}; D.GROUP_DEFS.forEach((g) => (o[g.id] = true)); return o;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", height: "100%", background: "var(--bg-1)", borderRight: "1px solid var(--line)" }}>
|
||||||
|
{/* Заголовок */}
|
||||||
|
<div style={{ padding: "14px 14px 10px", borderBottom: "1px solid var(--line-soft)" }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<div style={{ width: 22, height: 22, borderRadius: 5, background: "var(--accent)", display: "grid", placeItems: "center", boxShadow: "0 0 12px var(--accent)" }}>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="3" fill="var(--bg-0)" /><ellipse cx="12" cy="12" rx="10" ry="4.3" stroke="var(--bg-0)" strokeWidth="1.7" transform="rotate(28 12 12)" /></svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 700, letterSpacing: ".02em", fontSize: 13 }}>ПЛАН КА</div>
|
||||||
|
<div className="mono" style={{ fontSize: 9.5, color: "var(--ink-3)", letterSpacing: ".08em" }}>MISSION OPS · ДЗЗ</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Поиск */}
|
||||||
|
<div style={{ padding: "10px 12px 8px" }}>
|
||||||
|
<div style={{ position: "relative" }}>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" style={{ position: "absolute", left: 9, top: 9, opacity: .5 }}><circle cx="11" cy="11" r="7" stroke="var(--ink-2)" strokeWidth="2" /><path d="m20 20-3.5-3.5" stroke="var(--ink-2)" strokeWidth="2" strokeLinecap="round" /></svg>
|
||||||
|
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Поиск КА…"
|
||||||
|
style={{ width: "100%", padding: "7px 8px 7px 28px", background: "var(--bg-0)", border: "1px solid var(--line)", borderRadius: 6, color: "var(--ink)", fontFamily: "var(--mono)", fontSize: 12, outline: "none" }} />
|
||||||
|
{search && <button onClick={() => setSearch("")} style={{ position: "absolute", right: 6, top: 6, background: "none", border: "none", color: "var(--ink-3)", fontSize: 14, padding: 2 }}>×</button>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Легенда / фильтр по типам */}
|
||||||
|
<div style={{ padding: "4px 12px 10px", borderBottom: "1px solid var(--line-soft)" }}>
|
||||||
|
<div className="mono" style={{ fontSize: 9.5, color: "var(--ink-3)", letterSpacing: ".1em", marginBottom: 6 }}>АППАРАТУРА</div>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
|
||||||
|
{D.typeOrder.map((t) => {
|
||||||
|
const on = typeFilter[t];
|
||||||
|
return (
|
||||||
|
<button key={t} onClick={() => setTypeFilter({ ...typeFilter, [t]: !on })}
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: 8, padding: "5px 7px", borderRadius: 6, border: "1px solid transparent", background: on ? "var(--bg-2)" : "transparent", opacity: on ? 1 : 0.45, textAlign: "left" }}>
|
||||||
|
<TypeDot type={t} />
|
||||||
|
<span style={{ flex: 1, fontSize: 12 }}>{D.TYPES[t].label}</span>
|
||||||
|
<span className="mono tnum" style={{ fontSize: 10.5, color: "var(--ink-3)" }}>{counts.byType[t] || 0}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Дерево групп → КА */}
|
||||||
|
<div style={{ flex: 1, overflowY: "auto", padding: "8px 8px 16px" }}>
|
||||||
|
<div className="mono" style={{ fontSize: 9.5, color: "var(--ink-3)", letterSpacing: ".1em", margin: "2px 6px 6px" }}>ГРУППЫ / КА</div>
|
||||||
|
{groups.map((g) => {
|
||||||
|
const list = scByGroup[g.id] || [];
|
||||||
|
const isOpen = open[g.id];
|
||||||
|
const shownCount = list.filter((sc) => !scHidden.has(sc.id)).length;
|
||||||
|
return (
|
||||||
|
<div key={g.id} style={{ marginBottom: 2 }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 6, padding: "5px 6px", borderRadius: 6, background: "var(--bg-2)" }}>
|
||||||
|
<button onClick={() => setOpen({ ...open, [g.id]: !isOpen })} style={{ background: "none", border: "none", padding: 0, width: 14, height: 14, display: "grid", placeItems: "center", color: "var(--ink-2)" }}>
|
||||||
|
<svg width="9" height="9" viewBox="0 0 10 10" style={{ transform: isOpen ? "rotate(90deg)" : "none", transition: "transform .12s" }}><path d="M3 1.5 7 5 3 8.5" stroke="currentColor" strokeWidth="1.6" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
||||||
|
</button>
|
||||||
|
<Check on={groupShown[g.id]} onClick={() => toggleGroup(g.id)} />
|
||||||
|
<span className="mono" style={{ fontSize: 10, color: "var(--ink-3)" }}>{g.code}</span>
|
||||||
|
<span style={{ flex: 1, fontSize: 12, fontWeight: 600, color: groupShown[g.id] ? "var(--ink)" : "var(--ink-3)" }}>{g.name}</span>
|
||||||
|
<span className="mono tnum" style={{ fontSize: 10, color: "var(--ink-3)" }}>{shownCount}/{list.length}</span>
|
||||||
|
</div>
|
||||||
|
{isOpen && (
|
||||||
|
<div style={{ marginLeft: 14, borderLeft: "1px solid var(--line-soft)", paddingLeft: 4, marginTop: 2 }}>
|
||||||
|
{list.map((sc) => {
|
||||||
|
const vis = !scHidden.has(sc.id);
|
||||||
|
const sel = selectedSc === sc.id;
|
||||||
|
return (
|
||||||
|
<div key={sc.id} onClick={() => onPickSc(sc.id)}
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: 7, padding: "4px 7px", borderRadius: 6, cursor: "pointer", background: sel ? "var(--bg-3)" : "transparent" }}>
|
||||||
|
<Check on={vis} dim={!groupShown[g.id]} onClick={(e) => { e.stopPropagation(); toggleSc(sc.id); }} />
|
||||||
|
<TypeDot type={sc.type} size={7} />
|
||||||
|
<span className="mono" style={{ flex: 1, fontSize: 11, color: vis && groupShown[g.id] ? "var(--ink-1)" : "var(--ink-3)" }}>{sc.short}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(window, { Sidebar, TypeDot });
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
// Таймлайн (Gantt): ось времени, сетка, маркер «сейчас», секции групп,
|
||||||
|
// раскладка интервалов по лейнам, пометка пересечений, стрелки родословной.
|
||||||
|
const { useRef: useRefTL, useMemo: useMemoTL, useEffect: useEffectTL, useLayoutEffect } = React;
|
||||||
|
|
||||||
|
const LABEL_W = 234;
|
||||||
|
const LANE_H = 38;
|
||||||
|
const ROW_PAD = 8;
|
||||||
|
const GROUP_H = 30;
|
||||||
|
const AXIS_H = 46;
|
||||||
|
|
||||||
|
function Timeline(props) {
|
||||||
|
const { rows, pxPerMs, rangeStart, rangeEnd, now, selectedIv, onSelectIv,
|
||||||
|
lineageSet, byId, scrollSignal, timelineCollapsed, toggleCollapse } = props;
|
||||||
|
|
||||||
|
const bodyRef = useRefTL(null);
|
||||||
|
const axisRef = useRefTL(null);
|
||||||
|
const leftRef = useRefTL(null);
|
||||||
|
|
||||||
|
const x = (ms) => (ms - rangeStart) * pxPerMs;
|
||||||
|
const contentW = (rangeEnd - rangeStart) * pxPerMs;
|
||||||
|
|
||||||
|
// Раскладка строк по вертикали + позиции интервалов.
|
||||||
|
const layout = useMemoTL(() => {
|
||||||
|
let y = 0;
|
||||||
|
const placed = [];
|
||||||
|
const ivPos = {};
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.kind === "group") {
|
||||||
|
placed.push({ ...r, y, h: GROUP_H });
|
||||||
|
y += GROUP_H;
|
||||||
|
} else {
|
||||||
|
const { laneOf, laneCount } = assignLanes(r.ivs);
|
||||||
|
const h = laneCount * LANE_H + ROW_PAD * 2;
|
||||||
|
const overlaps = overlapSegments(r.ivs);
|
||||||
|
for (const iv of r.ivs) {
|
||||||
|
const lane = laneOf[iv.id];
|
||||||
|
ivPos[iv.id] = {
|
||||||
|
x: x(iv.start), x2: x(iv.end),
|
||||||
|
cy: y + ROW_PAD + lane * LANE_H + LANE_H / 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
placed.push({ ...r, y, h, laneOf, laneCount, overlaps });
|
||||||
|
y += h;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { placed, total: y, ivPos };
|
||||||
|
}, [rows, pxPerMs, rangeStart]);
|
||||||
|
|
||||||
|
// Сетка по дням + засечки оси.
|
||||||
|
const days = useMemoTL(() => {
|
||||||
|
const out = [];
|
||||||
|
const startD = new Date(rangeStart);
|
||||||
|
let t = Date.UTC(startD.getUTCFullYear(), startD.getUTCMonth(), startD.getUTCDate());
|
||||||
|
while (t <= rangeEnd) {
|
||||||
|
const d = new Date(t);
|
||||||
|
out.push({ t, weekend: d.getUTCDay() === 0 || d.getUTCDay() === 6, dow: d.getUTCDay() });
|
||||||
|
t += 86400000;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [rangeStart, rangeEnd]);
|
||||||
|
|
||||||
|
const sub = useMemoTL(() => niceTicks(rangeStart, rangeEnd, pxPerMs, 64), [rangeStart, rangeEnd, pxPerMs]);
|
||||||
|
const showHourTicks = sub.step < 86400000;
|
||||||
|
|
||||||
|
// Синхронизация скролла.
|
||||||
|
const onBodyScroll = () => {
|
||||||
|
const b = bodyRef.current;
|
||||||
|
if (axisRef.current) axisRef.current.scrollLeft = b.scrollLeft;
|
||||||
|
if (leftRef.current) leftRef.current.scrollTop = b.scrollTop;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Прокрутка к «сейчас».
|
||||||
|
useEffectTL(() => {
|
||||||
|
const b = bodyRef.current;
|
||||||
|
if (!b) return;
|
||||||
|
const target = x(now) - b.clientWidth * 0.32;
|
||||||
|
b.scrollTo({ left: Math.max(0, target), behavior: scrollSignal === 0 ? "auto" : "smooth" });
|
||||||
|
onBodyScroll();
|
||||||
|
}, [scrollSignal]);
|
||||||
|
|
||||||
|
// Стрелки родословной для выделенной цепочки.
|
||||||
|
const arrows = useMemoTL(() => {
|
||||||
|
if (!selectedIv) return [];
|
||||||
|
const lin = lineageOf(selectedIv, byId);
|
||||||
|
const segs = [];
|
||||||
|
for (const id of lin.chain) {
|
||||||
|
(D.childrenOf[id] || []).forEach((c) => {
|
||||||
|
if (lin.chain.includes(c) && layout.ivPos[id] && layout.ivPos[c]) {
|
||||||
|
segs.push({ from: layout.ivPos[id], to: layout.ivPos[c] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return segs;
|
||||||
|
}, [selectedIv, layout, lineageSet]);
|
||||||
|
|
||||||
|
const anySelected = !!selectedIv;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", height: "100%", background: "var(--bg-0)", minWidth: 0 }}>
|
||||||
|
{/* ОСЬ ВРЕМЕНИ */}
|
||||||
|
<div style={{ display: "flex", height: AXIS_H, flex: "none", borderBottom: "1px solid var(--line)", background: "var(--bg-1)" }}>
|
||||||
|
<div style={{ width: LABEL_W, flex: "none", borderRight: "1px solid var(--line)", display: "flex", alignItems: "flex-end", padding: "0 12px 7px" }}>
|
||||||
|
<span className="mono" style={{ fontSize: 9.5, color: "var(--ink-3)", letterSpacing: ".1em" }}>КОСМИЧЕСКИЙ АППАРАТ</span>
|
||||||
|
</div>
|
||||||
|
<div ref={axisRef} style={{ flex: 1, overflow: "hidden", position: "relative" }}>
|
||||||
|
<div style={{ width: contentW, height: "100%", position: "relative" }}>
|
||||||
|
{/* верхний ярус — дни */}
|
||||||
|
{days.map((d, i) => {
|
||||||
|
const w = 86400000 * pxPerMs;
|
||||||
|
return (
|
||||||
|
<div key={i} style={{ position: "absolute", left: x(d.t), top: 0, width: w, height: 24, borderLeft: "1px solid var(--line-soft)", display: "flex", alignItems: "center", gap: 5, padding: "0 7px", color: d.weekend ? "var(--ink-3)" : "var(--ink-1)" }}>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 600 }}>{pad(new Date(d.t).getUTCDate())}</span>
|
||||||
|
<span style={{ fontSize: 9.5, color: "var(--ink-3)" }}>{MONTHS[new Date(d.t).getUTCMonth()]}</span>
|
||||||
|
<span className="mono" style={{ fontSize: 8.5, color: "var(--ink-3)", letterSpacing: ".05em" }}>{WDAYS[d.dow]}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* нижний ярус — засечки */}
|
||||||
|
{sub.ticks.map((tk, i) => (
|
||||||
|
<div key={i} style={{ position: "absolute", left: x(tk.t), top: 24, height: 22, borderLeft: "1px solid var(--grid)", paddingLeft: 4, display: "flex", alignItems: "center" }}>
|
||||||
|
{showHourTicks && <span className="mono" style={{ fontSize: 9, color: "var(--ink-3)" }}>{fmtTime(tk.t)}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{/* метка «сейчас» */}
|
||||||
|
<div style={{ position: "absolute", left: x(now), top: 0, height: "100%", transform: "translateX(-50%)", display: "flex", alignItems: "flex-start" }}>
|
||||||
|
<span className="mono" style={{ marginTop: 3, fontSize: 9, fontWeight: 600, color: "var(--bg-0)", background: "var(--now)", padding: "1px 5px", borderRadius: 4, whiteSpace: "nowrap", boxShadow: "0 0 8px var(--now)" }}>СЕЙЧАС {fmtTime(now)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ТЕЛО */}
|
||||||
|
<div style={{ flex: 1, display: "flex", minHeight: 0 }}>
|
||||||
|
{/* Левая колонка подписей */}
|
||||||
|
<div ref={leftRef} style={{ width: LABEL_W, flex: "none", overflow: "hidden", borderRight: "1px solid var(--line)", background: "var(--bg-1)" }}>
|
||||||
|
<div style={{ height: layout.total, position: "relative" }}>
|
||||||
|
{layout.placed.map((r) => r.kind === "group" ? (
|
||||||
|
<div key={"g" + r.group.id} onClick={() => toggleCollapse(r.group.id)}
|
||||||
|
style={{ position: "absolute", top: r.y, left: 0, right: 0, height: r.h, display: "flex", alignItems: "center", gap: 7, padding: "0 12px", background: "var(--bg-2)", borderBottom: "1px solid var(--line)", borderTop: "1px solid var(--line)", cursor: "pointer" }}>
|
||||||
|
<svg width="9" height="9" viewBox="0 0 10 10" style={{ transform: timelineCollapsed.has(r.group.id) ? "none" : "rotate(90deg)", transition: "transform .12s", color: "var(--ink-2)", flex: "none" }}><path d="M3 1.5 7 5 3 8.5" stroke="currentColor" strokeWidth="1.6" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
||||||
|
<span className="mono" style={{ fontSize: 9.5, color: "var(--ink-3)" }}>{r.group.code}</span>
|
||||||
|
<span style={{ flex: 1, fontSize: 12, fontWeight: 700 }}>{r.group.name}</span>
|
||||||
|
<span className="mono tnum" style={{ fontSize: 10, color: "var(--ink-3)" }}>{r.count}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div key={r.sc.id} style={{ position: "absolute", top: r.y, left: 0, right: 0, height: r.h, display: "flex", alignItems: "center", gap: 8, padding: "0 12px", borderBottom: "1px solid var(--line-soft)" }}>
|
||||||
|
<TypeDot type={r.sc.type} size={8} />
|
||||||
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<div className="mono" style={{ fontSize: 11.5, color: "var(--ink-1)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{r.sc.short}</div>
|
||||||
|
<div style={{ fontSize: 9.5, color: "var(--ink-3)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{r.sc.orbit}</div>
|
||||||
|
</div>
|
||||||
|
<span className="mono tnum" style={{ fontSize: 9.5, color: "var(--ink-3)" }}>{r.ivs.length}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Правый холст */}
|
||||||
|
<div ref={bodyRef} onScroll={onBodyScroll} style={{ flex: 1, overflow: "auto", position: "relative" }}>
|
||||||
|
<div style={{ width: contentW, height: layout.total, position: "relative" }}>
|
||||||
|
{/* фон выходных + сетка дней */}
|
||||||
|
{days.map((d, i) => (
|
||||||
|
<div key={i} style={{ position: "absolute", left: x(d.t), top: 0, width: 86400000 * pxPerMs, height: "100%", borderLeft: "1px solid var(--grid)", background: d.weekend ? "oklch(0.22 0.012 165 / 0.5)" : "transparent" }} />
|
||||||
|
))}
|
||||||
|
{/* линия «сейчас» */}
|
||||||
|
<div style={{ position: "absolute", left: x(now), top: 0, height: "100%", width: 0, borderLeft: "1.5px dashed var(--now)", zIndex: 6 }} />
|
||||||
|
|
||||||
|
{/* строки */}
|
||||||
|
{layout.placed.map((r) => r.kind === "group" ? (
|
||||||
|
<div key={"g" + r.group.id} style={{ position: "absolute", top: r.y, left: 0, width: contentW, height: r.h, background: "var(--bg-2)", borderTop: "1px solid var(--line)", borderBottom: "1px solid var(--line)", opacity: .95 }} />
|
||||||
|
) : (
|
||||||
|
<Row key={r.sc.id} r={r} x={x} contentW={contentW} selectedIv={selectedIv}
|
||||||
|
onSelectIv={onSelectIv} lineageSet={lineageSet} anySelected={anySelected} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* стрелки родословной */}
|
||||||
|
{arrows.length > 0 && (
|
||||||
|
<svg style={{ position: "absolute", left: 0, top: 0, width: contentW, height: layout.total, pointerEvents: "none", zIndex: 7 }}>
|
||||||
|
<defs>
|
||||||
|
<marker id="ah" markerWidth="7" markerHeight="7" refX="5.5" refY="3" orient="auto">
|
||||||
|
<path d="M0 0 L6 3 L0 6 z" fill="var(--accent)" />
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
{arrows.map((a, i) => {
|
||||||
|
const x1 = a.from.x2, y1 = a.from.cy, x2 = a.to.x, y2 = a.to.cy;
|
||||||
|
const mid = x1 + Math.max(14, (x2 - x1) / 2);
|
||||||
|
const d = `M ${x1} ${y1} C ${mid} ${y1}, ${mid} ${y2}, ${x2 - 3} ${y2}`;
|
||||||
|
return <path key={i} d={d} fill="none" stroke="var(--accent)" strokeWidth="1.6" markerEnd="url(#ah)" opacity="0.9" />;
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Одна строка КА: ПЛАНЫ (объемлющие интервалы) по лейнам, внутри — РАБОТЫ.
|
||||||
|
function Row({ r, x, contentW, selectedIv, onSelectIv, lineageSet, anySelected }) {
|
||||||
|
return (
|
||||||
|
<div style={{ position: "absolute", top: r.y, left: 0, width: contentW, height: r.h, borderBottom: "1px solid var(--line-soft)" }}>
|
||||||
|
{/* пересечения планов — штриховка */}
|
||||||
|
{r.overlaps.map((ov, i) => (
|
||||||
|
<div key={"ov" + i} className="hatch" style={{ position: "absolute", left: x(ov.start), width: Math.max(2, x(ov.end) - x(ov.start)), top: 2, height: r.h - 4, borderLeft: "1px solid var(--now)", borderRight: "1px solid var(--now)", borderRadius: 2, zIndex: 1, pointerEvents: "none" }} />
|
||||||
|
))}
|
||||||
|
{r.ivs.map((plan) => {
|
||||||
|
const lane = r.laneOf[plan.id];
|
||||||
|
const left = x(plan.start);
|
||||||
|
const w = Math.max(4, x(plan.end) - x(plan.start));
|
||||||
|
const inChain = lineageSet && lineageSet.has(plan.id);
|
||||||
|
const isSel = selectedIv === plan.id;
|
||||||
|
const dim = anySelected && !inChain;
|
||||||
|
const scType = r.sc.type;
|
||||||
|
const col = TYPE_COLOR[scType];
|
||||||
|
const hasParent = !!plan.parentId;
|
||||||
|
const hasChild = (D.childrenOf[plan.id] || []).length > 0;
|
||||||
|
const bandTop = ROW_PAD + lane * LANE_H;
|
||||||
|
const bandH = LANE_H - 8;
|
||||||
|
const wide = w > 78;
|
||||||
|
const showWorks = w > 14;
|
||||||
|
const executed = plan.status === "executed";
|
||||||
|
return (
|
||||||
|
<div key={plan.id} onClick={(e) => { e.stopPropagation(); onSelectIv(plan.id); }}
|
||||||
|
title={`План ${plan.id} · ${plan.title} · ${fmtDateTime(plan.start)} → ${fmtDateTime(plan.end)} · работ: ${plan.works.length}`}
|
||||||
|
style={{
|
||||||
|
position: "absolute", left, width: w, top: bandTop, height: bandH,
|
||||||
|
background: `color-mix(in oklch, ${col} 13%, var(--bg-1))`,
|
||||||
|
border: `1px solid color-mix(in oklch, ${col} 45%, var(--bg-1))`,
|
||||||
|
borderLeft: `3px solid ${col}`,
|
||||||
|
borderRadius: 5, cursor: "pointer", zIndex: isSel ? 5 : 3,
|
||||||
|
opacity: dim ? 0.26 : executed ? 0.7 : 1,
|
||||||
|
outline: isSel ? `1.5px solid var(--accent)` : inChain ? `1px solid color-mix(in oklch, var(--accent) 55%, transparent)` : "none",
|
||||||
|
boxShadow: isSel ? "0 0 0 3px oklch(0.82 0.13 200 / 0.18), var(--shadow)" : "none",
|
||||||
|
overflow: "hidden", transition: "opacity .12s",
|
||||||
|
}}>
|
||||||
|
{/* заголовок плана */}
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 4, height: 14, padding: "0 4px 0 5px" }}>
|
||||||
|
{hasParent && <span style={{ flex: "none", color: "var(--accent)", fontSize: 8, lineHeight: 1 }} title="есть предок">◀</span>}
|
||||||
|
{wide && <span className="mono" style={{ flex: 1, fontSize: 9, color: "var(--ink-1)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", letterSpacing: ".01em" }}>{plan.title}</span>}
|
||||||
|
{wide && plan.status === "active" && <span style={{ width: 4, height: 4, borderRadius: 9, background: "var(--now)", boxShadow: "0 0 5px var(--now)", flex: "none" }} />}
|
||||||
|
{wide && plan.rev > 1 && <span className="mono" style={{ flex: "none", fontSize: 7.5, color: "var(--ink-3)", border: "1px solid var(--line)", borderRadius: 3, padding: "0 2px" }}>r{plan.rev}</span>}
|
||||||
|
{!wide && <span style={{ flex: 1 }} />}
|
||||||
|
{hasChild && <span style={{ flex: "none", color: "var(--accent)", fontSize: 8, lineHeight: 1 }} title="есть потомок">▶</span>}
|
||||||
|
</div>
|
||||||
|
{/* трек работ внутри плана */}
|
||||||
|
{showWorks && (
|
||||||
|
<div style={{ position: "absolute", left: 0, right: 0, bottom: 2, height: 11 }}>
|
||||||
|
{plan.works.map((wk) => {
|
||||||
|
const wl = x(wk.start) - left;
|
||||||
|
const ww = Math.max(1.5, x(wk.end) - x(wk.start));
|
||||||
|
const fill = workFill(wk.kind, scType);
|
||||||
|
const isService = wk.kind === "service";
|
||||||
|
return (
|
||||||
|
<div key={wk.id} title={`${wk.label} · ${fmtTime(wk.start)}–${fmtTime(wk.end)}`}
|
||||||
|
style={{
|
||||||
|
position: "absolute", left: wl, width: ww, top: 0, height: 11,
|
||||||
|
background: isService ? "transparent" : fill,
|
||||||
|
border: isService ? `1px dashed ${fill}` : "none",
|
||||||
|
borderRadius: 2, boxSizing: "border-box",
|
||||||
|
}} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(window, { Timeline });
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
// Общие утилиты, формат времени, раскладка интервалов по лейнам.
|
||||||
|
const D = window.MissionData;
|
||||||
|
|
||||||
|
const TYPE_COLOR = {
|
||||||
|
optical: "var(--t-optical)",
|
||||||
|
radar: "var(--t-radar)",
|
||||||
|
combo: "var(--t-combo)",
|
||||||
|
};
|
||||||
|
const TYPE_DIM = {
|
||||||
|
optical: "var(--t-optical-dim)",
|
||||||
|
radar: "var(--t-radar-dim)",
|
||||||
|
combo: "var(--t-combo-dim)",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Стиль работы внутри плана по её категории. shoot тонируется типом аппаратуры,
|
||||||
|
// остальные — служебной палитрой, отличной от трёх цветов аппаратуры.
|
||||||
|
const WORK_KIND_COLOR = {
|
||||||
|
shoot: null, // = цвет типа КА
|
||||||
|
downlink: "var(--accent)",
|
||||||
|
calib: "oklch(0.78 0.02 200)",
|
||||||
|
service: "var(--ink-3)",
|
||||||
|
};
|
||||||
|
function workFill(kind, scType) {
|
||||||
|
if (kind === "shoot") return TYPE_COLOR[scType];
|
||||||
|
return WORK_KIND_COLOR[kind];
|
||||||
|
}
|
||||||
|
|
||||||
|
const MONTHS = ["янв", "фев", "мар", "апр", "май", "июн", "июл", "авг", "сен", "окт", "ноя", "дек"];
|
||||||
|
const WDAYS = ["вс", "пн", "вт", "ср", "чт", "пт", "сб"];
|
||||||
|
|
||||||
|
function pad(n) { return String(n).padStart(2, "0"); }
|
||||||
|
function fmtTime(ms) {
|
||||||
|
const d = new Date(ms);
|
||||||
|
return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
|
||||||
|
}
|
||||||
|
function fmtDate(ms) {
|
||||||
|
const d = new Date(ms);
|
||||||
|
return `${pad(d.getUTCDate())} ${MONTHS[d.getUTCMonth()]}`;
|
||||||
|
}
|
||||||
|
function fmtDateTime(ms) {
|
||||||
|
const d = new Date(ms);
|
||||||
|
return `${pad(d.getUTCDate())} ${MONTHS[d.getUTCMonth()]} ${fmtTime(ms)}`;
|
||||||
|
}
|
||||||
|
function fmtDur(ms) {
|
||||||
|
const h = ms / 3600000;
|
||||||
|
if (h < 24) return `${h % 1 === 0 ? h : h.toFixed(1)} ч`;
|
||||||
|
const d = Math.floor(h / 24);
|
||||||
|
const rh = Math.round(h - d * 24);
|
||||||
|
return rh ? `${d} сут ${rh} ч` : `${d} сут`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Раскладка интервалов одного КА по лейнам (greedy interval graph).
|
||||||
|
function assignLanes(ivs) {
|
||||||
|
const sorted = [...ivs].sort((a, b) => a.start - b.start || a.end - b.end);
|
||||||
|
const laneEnds = []; // конец последнего интервала в каждом лейне
|
||||||
|
const out = {};
|
||||||
|
for (const iv of sorted) {
|
||||||
|
let placed = false;
|
||||||
|
for (let l = 0; l < laneEnds.length; l++) {
|
||||||
|
if (iv.start >= laneEnds[l]) {
|
||||||
|
out[iv.id] = l;
|
||||||
|
laneEnds[l] = iv.end;
|
||||||
|
placed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!placed) {
|
||||||
|
out[iv.id] = laneEnds.length;
|
||||||
|
laneEnds.push(iv.end);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { laneOf: out, laneCount: Math.max(1, laneEnds.length) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пересечения по времени между интервалами одного КА.
|
||||||
|
function overlapSegments(ivs) {
|
||||||
|
const segs = [];
|
||||||
|
for (let i = 0; i < ivs.length; i++) {
|
||||||
|
for (let j = i + 1; j < ivs.length; j++) {
|
||||||
|
const a = ivs[i], b = ivs[j];
|
||||||
|
const s = Math.max(a.start, b.start);
|
||||||
|
const e = Math.min(a.end, b.end);
|
||||||
|
if (e > s) segs.push({ start: s, end: e, a: a.id, b: b.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return segs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Цепочка родословной (предки + потомки) для интервала.
|
||||||
|
function lineageOf(id, byId) {
|
||||||
|
const anc = [];
|
||||||
|
let cur = byId[id];
|
||||||
|
while (cur && cur.parentId) {
|
||||||
|
anc.unshift(cur.parentId);
|
||||||
|
cur = byId[cur.parentId];
|
||||||
|
}
|
||||||
|
const desc = [];
|
||||||
|
const stack = [...(D.childrenOf[id] || [])];
|
||||||
|
while (stack.length) {
|
||||||
|
const c = stack.shift();
|
||||||
|
desc.push(c);
|
||||||
|
(D.childrenOf[c] || []).forEach((x) => stack.push(x));
|
||||||
|
}
|
||||||
|
return { ancestors: anc, descendants: desc, chain: [...anc, id, ...desc] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Хорошие" шаги сетки времени для оси.
|
||||||
|
function niceTicks(rangeStart, rangeEnd, pxPerMs, minPx) {
|
||||||
|
const HOUR = 3600000, DAY = 86400000;
|
||||||
|
const steps = [1 * HOUR, 2 * HOUR, 3 * HOUR, 6 * HOUR, 12 * HOUR, 1 * DAY, 2 * DAY, 7 * DAY];
|
||||||
|
let step = steps[steps.length - 1];
|
||||||
|
for (const s of steps) { if (s * pxPerMs >= minPx) { step = s; break; } }
|
||||||
|
const ticks = [];
|
||||||
|
const startTick = Math.ceil(rangeStart / step) * step;
|
||||||
|
for (let t = startTick; t <= rangeEnd; t += step) ticks.push({ t, step });
|
||||||
|
return { ticks, step };
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(window, {
|
||||||
|
TYPE_COLOR, TYPE_DIM, MONTHS, WDAYS, WORK_KIND_COLOR, workFill,
|
||||||
|
pad, fmtTime, fmtDate, fmtDateTime, fmtDur,
|
||||||
|
assignLanes, overlapSegments, lineageOf, niceTicks,
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>План КА — Mission Ops</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
/* Фон — очень тёмный десатурированный тёмно-зелёный (OPS) */
|
||||||
|
--bg-0: oklch(0.18 0.012 165);
|
||||||
|
--bg-1: oklch(0.215 0.013 165);
|
||||||
|
--bg-2: oklch(0.255 0.014 165);
|
||||||
|
--bg-3: oklch(0.30 0.015 165);
|
||||||
|
--line: oklch(0.34 0.014 165);
|
||||||
|
--line-soft: oklch(0.28 0.013 165);
|
||||||
|
--grid: oklch(0.26 0.012 165);
|
||||||
|
|
||||||
|
--ink: oklch(0.95 0.01 150);
|
||||||
|
--ink-1: oklch(0.80 0.012 160);
|
||||||
|
--ink-2: oklch(0.64 0.012 165);
|
||||||
|
--ink-3: oklch(0.50 0.012 165);
|
||||||
|
|
||||||
|
/* Типы аппаратуры — одинаковая C/L, разный hue */
|
||||||
|
--t-optical: oklch(0.80 0.14 158);
|
||||||
|
--t-radar: oklch(0.82 0.13 82);
|
||||||
|
--t-combo: oklch(0.74 0.15 6);
|
||||||
|
--t-optical-dim: oklch(0.80 0.14 158 / 0.16);
|
||||||
|
--t-radar-dim: oklch(0.82 0.13 82 / 0.16);
|
||||||
|
--t-combo-dim: oklch(0.74 0.15 6 / 0.16);
|
||||||
|
|
||||||
|
--accent: oklch(0.82 0.13 200);
|
||||||
|
--warn: oklch(0.80 0.15 55);
|
||||||
|
--now: oklch(0.86 0.16 25);
|
||||||
|
|
||||||
|
--radius: 7px;
|
||||||
|
--mono: "IBM Plex Mono", ui-monospace, monospace;
|
||||||
|
--sans: "IBM Plex Sans", system-ui, sans-serif;
|
||||||
|
--shadow: 0 8px 30px oklch(0.12 0.01 165 / 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { height: 100%; margin: 0; }
|
||||||
|
body {
|
||||||
|
font-family: var(--sans);
|
||||||
|
background: var(--bg-0);
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 13px;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
#root { height: 100vh; }
|
||||||
|
|
||||||
|
::-webkit-scrollbar { width: 11px; height: 11px; }
|
||||||
|
::-webkit-scrollbar-track { background: var(--bg-0); }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--bg-3); border-radius: 6px; border: 2px solid var(--bg-0); }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: var(--line); }
|
||||||
|
::-webkit-scrollbar-corner { background: var(--bg-0); }
|
||||||
|
|
||||||
|
.mono { font-family: var(--mono); }
|
||||||
|
.tnum { font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
button { font-family: inherit; color: inherit; cursor: pointer; }
|
||||||
|
|
||||||
|
/* диагональная штриховка для пометки пересечений */
|
||||||
|
.hatch {
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
|
-45deg,
|
||||||
|
oklch(0.86 0.16 25 / 0.55) 0px,
|
||||||
|
oklch(0.86 0.16 25 / 0.55) 2px,
|
||||||
|
transparent 2px,
|
||||||
|
transparent 6px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||||
|
|
||||||
|
<script src="data.jsx"></script>
|
||||||
|
<script type="text/babel" src="util.jsx"></script>
|
||||||
|
<script type="text/babel" src="sidebar.jsx"></script>
|
||||||
|
<script type="text/babel" src="timeline.jsx"></script>
|
||||||
|
<script type="text/babel" src="details.jsx"></script>
|
||||||
|
<script type="text/babel" src="app.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine AS runtime
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Планирование ТГУ Ops UI</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://host.docker.internal:7008/api/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { TguPlanningPage } from "./features/tgu-planning/TguPlanningPage";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return <TguPlanningPage />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import {
|
||||||
|
TguApiError,
|
||||||
|
type TguPlan,
|
||||||
|
type TguPlanDecision,
|
||||||
|
type TguPlanDecisionMessage,
|
||||||
|
type TguPlatform
|
||||||
|
} from "../model/tguTypes";
|
||||||
|
|
||||||
|
export async function fetchPlatforms(): Promise<TguPlatform[]> {
|
||||||
|
return fetchJson<TguPlatform[]>("/api/tgu-planning/platforms");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPlans(historyDays: number, spacecraftId?: string): Promise<TguPlan[]> {
|
||||||
|
const query = new URLSearchParams({ historyDays: String(historyDays) });
|
||||||
|
const path = spacecraftId
|
||||||
|
? `/api/tgu-planning/spacecraft/${encodeURIComponent(spacecraftId)}/plans?${query}`
|
||||||
|
: `/api/tgu-planning/plans?${query}`;
|
||||||
|
|
||||||
|
return fetchJson<TguPlan[]>(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendPlanDecision(
|
||||||
|
planId: string,
|
||||||
|
decision: TguPlanDecision,
|
||||||
|
reason?: string
|
||||||
|
): Promise<TguPlanDecisionMessage> {
|
||||||
|
const query = new URLSearchParams({ decision });
|
||||||
|
if (reason?.trim()) {
|
||||||
|
query.set("reason", reason.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchJson<TguPlanDecisionMessage>(
|
||||||
|
`/api/tgu-planning/plans/${encodeURIComponent(planId)}/decision?${query}`,
|
||||||
|
{ method: "POST" },
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson<T>(url: string, init?: RequestInit, decisionRequest = false): Promise<T> {
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(url, {
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
...init
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw new TguApiError("Не удалось подключиться к backend API.", "network");
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new TguApiError(errorMessage(response.status, text, decisionRequest), "http", response.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!text.trim()) {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(text) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(status: number, body: string, decisionRequest: boolean): string {
|
||||||
|
if (decisionRequest && status === 404) {
|
||||||
|
return "План не найден.";
|
||||||
|
}
|
||||||
|
if (decisionRequest && status === 409) {
|
||||||
|
return "План не является активным планом, ожидающим решения, или для него нет active attempt.";
|
||||||
|
}
|
||||||
|
if (decisionRequest && status >= 500) {
|
||||||
|
return "Ошибка сервиса при отправке решения.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return extractErrorText(body) || "Ошибка backend API.";
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractErrorText(body: string): string {
|
||||||
|
const trimmed = body.trim();
|
||||||
|
if (!trimmed) return "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
||||||
|
for (const field of ["reason", "message", "error", "detail"]) {
|
||||||
|
const value = parsed[field];
|
||||||
|
if (typeof value === "string" && value.trim()) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { STATUS_STYLES } from "./tguStatus";
|
||||||
|
|
||||||
|
export function TguLegend() {
|
||||||
|
return (
|
||||||
|
<div className="tgu-legend" aria-label="Легенда статусов">
|
||||||
|
{STATUS_STYLES.map((item) => (
|
||||||
|
<span className="tgu-legend__item" key={item.label}>
|
||||||
|
<span className={`tgu-legend__swatch ${item.className}`} />
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<span className="tgu-legend__note">Стрелки показывают следующий план того же КА по времени.</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import type { TguPlanDecision } from "../../model/tguTypes";
|
||||||
|
import type { TguPlanUi } from "../../model/timelineTypes";
|
||||||
|
import { platformLabel } from "./tguTimelineMapper";
|
||||||
|
import { statusStyle } from "./tguStatus";
|
||||||
|
|
||||||
|
type TguPlanDetailsProps = {
|
||||||
|
plan?: TguPlanUi;
|
||||||
|
decisionInFlight: boolean;
|
||||||
|
notice?: string;
|
||||||
|
error?: string;
|
||||||
|
onDecision: (planId: string, decision: TguPlanDecision, reason?: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TguPlanDetails({ plan, decisionInFlight, notice, error, onDecision, onClose }: TguPlanDetailsProps) {
|
||||||
|
if (!plan) {
|
||||||
|
return (
|
||||||
|
<aside className="tgu-details tgu-details--empty">
|
||||||
|
<div className="tgu-details__placeholder">
|
||||||
|
<span className="tgu-details__placeholder-icon" aria-hidden="true" />
|
||||||
|
<span>Выберите plan bar на timeline.</span>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const style = statusStyle(plan.status);
|
||||||
|
const duration = formatDuration(plan.endMs - plan.startMs);
|
||||||
|
|
||||||
|
const reject = () => {
|
||||||
|
const reason = window.prompt("Reason для REJECTED", "UI_TEST_REJECTED");
|
||||||
|
if (reason === null) return;
|
||||||
|
onDecision(plan.planId, "REJECTED", reason);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="tgu-details">
|
||||||
|
<div className="tgu-details__header">
|
||||||
|
<div>
|
||||||
|
<div className="tgu-details__eyebrow">Выбранный план</div>
|
||||||
|
<h2>{plan.planId}</h2>
|
||||||
|
</div>
|
||||||
|
<button className="tgu-icon-button" type="button" onClick={onClose} aria-label="Закрыть детали">
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`tgu-status-pill ${style.className}`}>{style.label}</div>
|
||||||
|
|
||||||
|
{notice && <div className="tgu-alert tgu-alert--success">{notice}</div>}
|
||||||
|
{error && <div className="tgu-alert tgu-alert--danger">{error}</div>}
|
||||||
|
|
||||||
|
<dl className="tgu-details-grid">
|
||||||
|
<Detail label="planId" value={plan.planId} mono />
|
||||||
|
<Detail label="spacecraftId" value={plan.spacecraftId} mono />
|
||||||
|
<Detail label="КА" value={platformLabel(plan.platform, plan.spacecraftId)} />
|
||||||
|
<Detail label="startTime" value={formatDateTime(plan.startTime)} mono />
|
||||||
|
<Detail label="endTime" value={formatDateTime(plan.endTime)} mono />
|
||||||
|
<Detail label="duration" value={duration} mono />
|
||||||
|
<Detail label="kppId" value={plan.kppId} mono />
|
||||||
|
<Detail label="status" value={plan.status} mono />
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{plan.status === "WAITING_DECISION" && (
|
||||||
|
<div className="tgu-details__actions">
|
||||||
|
<button
|
||||||
|
className="tgu-button tgu-button--accept"
|
||||||
|
type="button"
|
||||||
|
disabled={decisionInFlight}
|
||||||
|
onClick={() => onDecision(plan.planId, "ACCEPTED", "UI_TEST_ACCEPTED")}
|
||||||
|
>
|
||||||
|
Принять
|
||||||
|
</button>
|
||||||
|
<button className="tgu-button tgu-button--reject" type="button" disabled={decisionInFlight} onClick={reject}>
|
||||||
|
Отклонить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Detail({ label, value, mono }: { label: string; value?: string; mono?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<dt>{label}</dt>
|
||||||
|
<dd className={mono ? "mono" : undefined}>{value || "-"}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: string): string {
|
||||||
|
return new Date(value).toLocaleString("ru-RU");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(ms: number): string {
|
||||||
|
const minutes = Math.max(0, Math.round(ms / 60_000));
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const restMinutes = minutes % 60;
|
||||||
|
if (hours === 0) return `${restMinutes} мин`;
|
||||||
|
return `${hours} ч ${restMinutes} мин`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
type TguPlanningLayoutProps = {
|
||||||
|
toolbar: ReactNode;
|
||||||
|
sidebar: ReactNode;
|
||||||
|
timeline: ReactNode;
|
||||||
|
details: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TguPlanningLayout({ toolbar, sidebar, timeline, details }: TguPlanningLayoutProps) {
|
||||||
|
return (
|
||||||
|
<div className="tgu-app-shell">
|
||||||
|
{toolbar}
|
||||||
|
<main className="tgu-workspace">
|
||||||
|
{sidebar}
|
||||||
|
<section className="tgu-center">{timeline}</section>
|
||||||
|
{details}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { fetchPlans, fetchPlatforms, sendPlanDecision } from "../../api/tguApi";
|
||||||
|
import type { TguPlanDecision, TguPlatform } from "../../model/tguTypes";
|
||||||
|
import type { TguPlanUi, TimelineRange } from "../../model/timelineTypes";
|
||||||
|
import { TguPlanDetails } from "./TguPlanDetails";
|
||||||
|
import { TguPlanningLayout } from "./TguPlanningLayout";
|
||||||
|
import { TguSidebar } from "./TguSidebar";
|
||||||
|
import { TguTimeline } from "./TguTimeline";
|
||||||
|
import { TguToolbar } from "./TguToolbar";
|
||||||
|
import { buildPlatformsForInterval, buildTimelineRows, mapPlans } from "./tguTimelineMapper";
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export function TguPlanningPage() {
|
||||||
|
const initialRange = useMemo(defaultRange, []);
|
||||||
|
const [fromValue, setFromValue] = useState(formatDateTimeLocal(initialRange.fromMs));
|
||||||
|
const [toValue, setToValue] = useState(formatDateTimeLocal(initialRange.toMs));
|
||||||
|
const [appliedRange, setAppliedRange] = useState<TimelineRange>(initialRange);
|
||||||
|
const [rawPlatforms, setRawPlatforms] = useState<TguPlatform[]>([]);
|
||||||
|
const [plans, setPlans] = useState<TguPlanUi[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [pageError, setPageError] = useState<string>();
|
||||||
|
const [platformLoadFailed, setPlatformLoadFailed] = useState(false);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [selectedSpacecraftId, setSelectedSpacecraftId] = useState<string>();
|
||||||
|
const [selectedPlanId, setSelectedPlanId] = useState<string>();
|
||||||
|
const [decisionInFlight, setDecisionInFlight] = useState(false);
|
||||||
|
const [decisionNotice, setDecisionNotice] = useState<string>();
|
||||||
|
const [decisionError, setDecisionError] = useState<string>();
|
||||||
|
|
||||||
|
const parsedInputRange = useMemo(
|
||||||
|
() => ({
|
||||||
|
fromMs: new Date(fromValue).getTime(),
|
||||||
|
toMs: new Date(toValue).getTime()
|
||||||
|
}),
|
||||||
|
[fromValue, toValue]
|
||||||
|
);
|
||||||
|
const invalidRange =
|
||||||
|
!Number.isFinite(parsedInputRange.fromMs) ||
|
||||||
|
!Number.isFinite(parsedInputRange.toMs) ||
|
||||||
|
parsedInputRange.fromMs >= parsedInputRange.toMs;
|
||||||
|
|
||||||
|
const loadData = useCallback(
|
||||||
|
async (range: TimelineRange) => {
|
||||||
|
setLoading(true);
|
||||||
|
setPageError(undefined);
|
||||||
|
setDecisionError(undefined);
|
||||||
|
|
||||||
|
const historyDays = historyDaysForRange(range);
|
||||||
|
const [platformResult, planResult] = await Promise.allSettled([
|
||||||
|
fetchPlatforms(),
|
||||||
|
fetchPlans(historyDays)
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (platformResult.status === "rejected") {
|
||||||
|
setPlatformLoadFailed(true);
|
||||||
|
} else {
|
||||||
|
setPlatformLoadFailed(false);
|
||||||
|
setRawPlatforms(platformResult.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (planResult.status === "rejected") {
|
||||||
|
setPlans([]);
|
||||||
|
setPageError(planResult.reason instanceof Error ? planResult.reason.message : "Не удалось загрузить планы.");
|
||||||
|
} else {
|
||||||
|
const platforms = platformResult.status === "fulfilled" ? platformResult.value : [];
|
||||||
|
setPlans(mapPlans(planResult.value, platforms));
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadData(appliedRange);
|
||||||
|
}, [appliedRange, loadData]);
|
||||||
|
|
||||||
|
const platforms = useMemo(
|
||||||
|
() => buildPlatformsForInterval(rawPlatforms, plans, appliedRange),
|
||||||
|
[rawPlatforms, plans, appliedRange]
|
||||||
|
);
|
||||||
|
const rows = useMemo(
|
||||||
|
() => buildTimelineRows(plans, platforms, appliedRange, selectedSpacecraftId),
|
||||||
|
[plans, platforms, appliedRange, selectedSpacecraftId]
|
||||||
|
);
|
||||||
|
const selectedPlan = useMemo(() => {
|
||||||
|
const plan = plans.find((item) => item.planId === selectedPlanId);
|
||||||
|
if (!plan) return undefined;
|
||||||
|
const platform = platforms.find((item) => item.spacecraftId === plan.spacecraftId);
|
||||||
|
return { ...plan, platform };
|
||||||
|
}, [plans, platforms, selectedPlanId]);
|
||||||
|
|
||||||
|
const applyRange = () => {
|
||||||
|
setDecisionNotice(undefined);
|
||||||
|
if (invalidRange) {
|
||||||
|
setPageError("Некорректный интервал: from должен быть меньше to.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPageError(undefined);
|
||||||
|
setAppliedRange(parsedInputRange);
|
||||||
|
};
|
||||||
|
|
||||||
|
const quickRange = (hours: number) => {
|
||||||
|
const fromMs = startOfCurrentDayMs();
|
||||||
|
const toMs = fromMs + hours * 60 * 60 * 1000;
|
||||||
|
setFromValue(formatDateTimeLocal(fromMs));
|
||||||
|
setToValue(formatDateTimeLocal(toMs));
|
||||||
|
setAppliedRange({ fromMs, toMs });
|
||||||
|
setPageError(undefined);
|
||||||
|
setDecisionNotice(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
if (invalidRange) {
|
||||||
|
setPageError("Некорректный интервал: from должен быть меньше to.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void loadData(appliedRange);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDecision = async (planId: string, decision: TguPlanDecision, reason?: string) => {
|
||||||
|
setDecisionInFlight(true);
|
||||||
|
setDecisionError(undefined);
|
||||||
|
setDecisionNotice(undefined);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendPlanDecision(planId, decision, reason);
|
||||||
|
setDecisionNotice(
|
||||||
|
decision === "REJECTED"
|
||||||
|
? "Решение REJECTED отправлено. План может вернуться в PLANNED для повторной выдачи."
|
||||||
|
: "Решение ACCEPTED отправлено."
|
||||||
|
);
|
||||||
|
await loadData(appliedRange);
|
||||||
|
} catch (error) {
|
||||||
|
setDecisionError(error instanceof Error ? error.message : "Не удалось отправить решение.");
|
||||||
|
} finally {
|
||||||
|
setDecisionInFlight(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TguPlanningLayout
|
||||||
|
toolbar={
|
||||||
|
<TguToolbar
|
||||||
|
fromValue={fromValue}
|
||||||
|
toValue={toValue}
|
||||||
|
loading={loading}
|
||||||
|
error={pageError}
|
||||||
|
onFromChange={setFromValue}
|
||||||
|
onToChange={setToValue}
|
||||||
|
onApply={applyRange}
|
||||||
|
onQuickRange={quickRange}
|
||||||
|
onRefresh={refresh}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
sidebar={
|
||||||
|
<TguSidebar
|
||||||
|
platforms={platforms}
|
||||||
|
selectedSpacecraftId={selectedSpacecraftId}
|
||||||
|
search={search}
|
||||||
|
platformLoadFailed={platformLoadFailed}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
onSelectSpacecraft={(spacecraftId) => {
|
||||||
|
setSelectedSpacecraftId(spacecraftId);
|
||||||
|
setSelectedPlanId(undefined);
|
||||||
|
setDecisionNotice(undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
timeline={
|
||||||
|
<TguTimeline
|
||||||
|
rows={rows}
|
||||||
|
range={appliedRange}
|
||||||
|
invalidRange={invalidRange}
|
||||||
|
selectedPlanId={selectedPlanId}
|
||||||
|
onSelectPlan={setSelectedPlanId}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
details={
|
||||||
|
<TguPlanDetails
|
||||||
|
plan={selectedPlan}
|
||||||
|
decisionInFlight={decisionInFlight}
|
||||||
|
notice={decisionNotice}
|
||||||
|
error={decisionError}
|
||||||
|
onDecision={handleDecision}
|
||||||
|
onClose={() => setSelectedPlanId(undefined)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultRange(): TimelineRange {
|
||||||
|
const fromMs = startOfCurrentDayMs();
|
||||||
|
return { fromMs, toMs: fromMs + DAY_MS };
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOfCurrentDayMs(): number {
|
||||||
|
const now = new Date();
|
||||||
|
return new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTimeLocal(ms: number): string {
|
||||||
|
const date = new Date(ms);
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(date.getDate()).padStart(2, "0");
|
||||||
|
const hours = String(date.getHours()).padStart(2, "0");
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||||
|
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function historyDaysForRange(range: TimelineRange): number {
|
||||||
|
const daysBack = Math.ceil(Math.max(0, Date.now() - range.fromMs) / DAY_MS);
|
||||||
|
return Math.max(1, daysBack + 1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import type { TguPlatformUi } from "../../model/timelineTypes";
|
||||||
|
import { filterPlatforms, platformLabel } from "./tguTimelineMapper";
|
||||||
|
|
||||||
|
type TguSidebarProps = {
|
||||||
|
platforms: TguPlatformUi[];
|
||||||
|
selectedSpacecraftId?: string;
|
||||||
|
search: string;
|
||||||
|
platformLoadFailed: boolean;
|
||||||
|
onSearchChange: (value: string) => void;
|
||||||
|
onSelectSpacecraft: (spacecraftId?: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TguSidebar({
|
||||||
|
platforms,
|
||||||
|
selectedSpacecraftId,
|
||||||
|
search,
|
||||||
|
platformLoadFailed,
|
||||||
|
onSearchChange,
|
||||||
|
onSelectSpacecraft
|
||||||
|
}: TguSidebarProps) {
|
||||||
|
const filtered = filterPlatforms(platforms, search);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="tgu-sidebar">
|
||||||
|
<div className="tgu-panel-heading">
|
||||||
|
<span>Космические аппараты</span>
|
||||||
|
<button className="tgu-link-button" type="button" onClick={() => onSelectSpacecraft(undefined)}>
|
||||||
|
Все
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tgu-search">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
placeholder="Поиск name / NORAD / mission"
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => onSearchChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{platformLoadFailed && (
|
||||||
|
<div className="tgu-alert tgu-alert--warning">
|
||||||
|
Список платформ не загрузился. Timeline построен по spacecraftId из планов.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="tgu-spacecraft-list">
|
||||||
|
{filtered.map((platform) => {
|
||||||
|
const selected = selectedSpacecraftId === platform.spacecraftId;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`tgu-spacecraft ${selected ? "is-selected" : ""}`}
|
||||||
|
type="button"
|
||||||
|
key={platform.spacecraftId}
|
||||||
|
onClick={() => onSelectSpacecraft(platform.spacecraftId)}
|
||||||
|
>
|
||||||
|
<span className={`tgu-spacecraft__problem ${platform.hasProblemStatus ? "is-problem" : ""}`} />
|
||||||
|
<span className="tgu-spacecraft__body">
|
||||||
|
<span className="tgu-spacecraft__name">{platformLabel(platform, platform.spacecraftId)}</span>
|
||||||
|
<span className="tgu-spacecraft__meta">
|
||||||
|
NORAD {platform.noradId || platform.spacecraftId}
|
||||||
|
{platform.mission ? ` · ${platform.mission}` : ""}
|
||||||
|
</span>
|
||||||
|
{platform.status && <span className="tgu-spacecraft__status">{platform.status}</span>}
|
||||||
|
</span>
|
||||||
|
<span className="tgu-spacecraft__count">{platform.planCount}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{filtered.length === 0 && <div className="tgu-empty tgu-empty--compact">Нет КА по текущему поиску.</div>}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import type { TimelinePlanSegment, TimelineRange, TimelineRow } from "../../model/timelineTypes";
|
||||||
|
import {
|
||||||
|
buildSequentialLinks,
|
||||||
|
buildTimeTicks,
|
||||||
|
buildTimelineSegments,
|
||||||
|
TIMELINE_AXIS_HEIGHT,
|
||||||
|
TIMELINE_BOTTOM_PADDING,
|
||||||
|
TIMELINE_LABEL_WIDTH,
|
||||||
|
TIMELINE_ROW_HEIGHT,
|
||||||
|
timeToX,
|
||||||
|
timelineHeight
|
||||||
|
} from "./tguTimelineLayout";
|
||||||
|
import { platformLabel } from "./tguTimelineMapper";
|
||||||
|
import { statusStyle } from "./tguStatus";
|
||||||
|
|
||||||
|
type TguTimelineProps = {
|
||||||
|
rows: TimelineRow[];
|
||||||
|
range: TimelineRange;
|
||||||
|
selectedPlanId?: string;
|
||||||
|
invalidRange?: boolean;
|
||||||
|
onSelectPlan: (planId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TguTimeline({ rows, range, selectedPlanId, invalidRange, onSelectPlan }: TguTimelineProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [containerWidth, setContainerWidth] = useState(1200);
|
||||||
|
const timelineWidth = Math.max(720, containerWidth - TIMELINE_LABEL_WIDTH - 28);
|
||||||
|
const fullWidth = TIMELINE_LABEL_WIDTH + timelineWidth;
|
||||||
|
const height = Math.max(260, timelineHeight(rows.length));
|
||||||
|
const nowMs = Date.now();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(([entry]) => {
|
||||||
|
setContainerWidth(Math.floor(entry.contentRect.width));
|
||||||
|
});
|
||||||
|
observer.observe(containerRef.current);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const segments = useMemo(
|
||||||
|
() => buildTimelineSegments(rows, range, timelineWidth),
|
||||||
|
[rows, range, timelineWidth]
|
||||||
|
);
|
||||||
|
const segmentByPlanId = useMemo(
|
||||||
|
() => new Map(segments.map((segment) => [segment.plan.planId, segment])),
|
||||||
|
[segments]
|
||||||
|
);
|
||||||
|
const links = useMemo(() => buildSequentialLinks(rows), [rows]);
|
||||||
|
const ticks = useMemo(() => buildTimeTicks(range), [range]);
|
||||||
|
const nowX =
|
||||||
|
nowMs >= range.fromMs && nowMs <= range.toMs
|
||||||
|
? TIMELINE_LABEL_WIDTH + timeToX(nowMs, range, timelineWidth)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (invalidRange) {
|
||||||
|
return <div className="tgu-empty">Некорректный интервал: from должен быть меньше to.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.length === 0 || segments.length === 0) {
|
||||||
|
return <div className="tgu-empty">Нет планов в выбранном интервале.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tgu-timeline" ref={containerRef}>
|
||||||
|
<svg className="tgu-timeline__svg" viewBox={`0 0 ${fullWidth} ${height}`} role="img" aria-label="Timeline планов ТГУ">
|
||||||
|
<defs>
|
||||||
|
<marker id="tgu-arrow-head" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
|
||||||
|
<path d="M0 0 L8 4 L0 8 z" fill="#8aa2a6" />
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<rect x="0" y="0" width={fullWidth} height={height} className="tgu-timeline__background" />
|
||||||
|
<rect x="0" y="0" width={TIMELINE_LABEL_WIDTH} height={height} className="tgu-timeline__labels-background" />
|
||||||
|
|
||||||
|
<line
|
||||||
|
x1={TIMELINE_LABEL_WIDTH}
|
||||||
|
x2={fullWidth}
|
||||||
|
y1={TIMELINE_AXIS_HEIGHT}
|
||||||
|
y2={TIMELINE_AXIS_HEIGHT}
|
||||||
|
className="tgu-timeline__axis"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{ticks.map((tick) => {
|
||||||
|
const x = TIMELINE_LABEL_WIDTH + timeToX(tick, range, timelineWidth);
|
||||||
|
return (
|
||||||
|
<g key={tick}>
|
||||||
|
<line x1={x} x2={x} y1="0" y2={height - TIMELINE_BOTTOM_PADDING} className="tgu-timeline__grid" />
|
||||||
|
<text x={x + 6} y="18" className="tgu-timeline__tick-date">
|
||||||
|
{formatTickDate(tick)}
|
||||||
|
</text>
|
||||||
|
<text x={x + 6} y="34" className="tgu-timeline__tick-time">
|
||||||
|
{formatTickTime(tick)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{nowX !== undefined && (
|
||||||
|
<g>
|
||||||
|
<line x1={nowX} x2={nowX} y1="0" y2={height - TIMELINE_BOTTOM_PADDING} className="tgu-timeline__now" />
|
||||||
|
<text x={nowX + 7} y="16" className="tgu-timeline__now-label">
|
||||||
|
сейчас
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rows.map((row, rowIndex) => {
|
||||||
|
const y = TIMELINE_AXIS_HEIGHT + rowIndex * TIMELINE_ROW_HEIGHT;
|
||||||
|
return (
|
||||||
|
<g key={row.spacecraftId}>
|
||||||
|
<rect
|
||||||
|
x="0"
|
||||||
|
y={y}
|
||||||
|
width={fullWidth}
|
||||||
|
height={TIMELINE_ROW_HEIGHT}
|
||||||
|
className={rowIndex % 2 === 0 ? "tgu-timeline__row" : "tgu-timeline__row tgu-timeline__row--alt"}
|
||||||
|
/>
|
||||||
|
<text x="16" y={y + 24} className="tgu-timeline__row-title">
|
||||||
|
{platformLabel(row.platform, row.spacecraftId)}
|
||||||
|
</text>
|
||||||
|
<text x="16" y={y + 42} className="tgu-timeline__row-meta">
|
||||||
|
NORAD {row.platform?.noradId || row.spacecraftId}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{links.map((link) => {
|
||||||
|
const from = segmentByPlanId.get(link.fromPlanId);
|
||||||
|
const to = segmentByPlanId.get(link.toPlanId);
|
||||||
|
if (!from || !to) return null;
|
||||||
|
return <TimelineArrow key={`${link.fromPlanId}-${link.toPlanId}`} from={from} to={to} />;
|
||||||
|
})}
|
||||||
|
|
||||||
|
{segments.map((segment) => {
|
||||||
|
const y = TIMELINE_AXIS_HEIGHT + segment.rowIndex * TIMELINE_ROW_HEIGHT + TIMELINE_ROW_HEIGHT / 2;
|
||||||
|
const x1 = TIMELINE_LABEL_WIDTH + segment.x;
|
||||||
|
const x2 = TIMELINE_LABEL_WIDTH + segment.x + segment.width;
|
||||||
|
const style = statusStyle(segment.plan.status);
|
||||||
|
const selected = selectedPlanId === segment.plan.planId;
|
||||||
|
const labelVisible = segment.width > 92;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={segment.plan.planId}
|
||||||
|
className={`tgu-timeline-plan ${selected ? "is-selected" : ""}`}
|
||||||
|
onClick={() => onSelectPlan(segment.plan.planId)}
|
||||||
|
>
|
||||||
|
<title>
|
||||||
|
{segment.plan.planId} · {segment.plan.status} · {formatDateTime(segment.plan.startTime)} -{" "}
|
||||||
|
{formatDateTime(segment.plan.endTime)}
|
||||||
|
</title>
|
||||||
|
{selected && <line x1={x1} x2={x2} y1={y} y2={y} className="tgu-timeline-plan__selection" />}
|
||||||
|
<line
|
||||||
|
x1={x1}
|
||||||
|
x2={x2}
|
||||||
|
y1={y}
|
||||||
|
y2={y}
|
||||||
|
className={`tgu-timeline-plan__bar ${style.className}`}
|
||||||
|
stroke={style.color}
|
||||||
|
/>
|
||||||
|
{labelVisible && (
|
||||||
|
<text x={x1 + 10} y={y + 4} className="tgu-timeline-plan__label">
|
||||||
|
{segment.plan.kppId} · {segment.plan.status}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimelineArrow({ from, to }: { from: TimelinePlanSegment; to: TimelinePlanSegment }) {
|
||||||
|
const y1 = TIMELINE_AXIS_HEIGHT + from.rowIndex * TIMELINE_ROW_HEIGHT + TIMELINE_ROW_HEIGHT / 2;
|
||||||
|
const y2 = TIMELINE_AXIS_HEIGHT + to.rowIndex * TIMELINE_ROW_HEIGHT + TIMELINE_ROW_HEIGHT / 2;
|
||||||
|
const x1 = TIMELINE_LABEL_WIDTH + from.x + from.width + 4;
|
||||||
|
const x2 = TIMELINE_LABEL_WIDTH + to.x - 6;
|
||||||
|
const mid = x1 + Math.max(18, (x2 - x1) / 2);
|
||||||
|
const path = x2 > x1
|
||||||
|
? `M ${x1} ${y1} C ${mid} ${y1}, ${mid} ${y2}, ${x2} ${y2}`
|
||||||
|
: `M ${x1} ${y1} C ${x1 + 20} ${y1 - 18}, ${x2 - 20} ${y2 - 18}, ${x2} ${y2}`;
|
||||||
|
|
||||||
|
return <path d={path} className="tgu-timeline__arrow" markerEnd="url(#tgu-arrow-head)" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTickDate(ms: number): string {
|
||||||
|
return new Date(ms).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTickTime(ms: number): string {
|
||||||
|
return new Date(ms).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: string): string {
|
||||||
|
return new Date(value).toLocaleString("ru-RU");
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { TguLegend } from "./TguLegend";
|
||||||
|
|
||||||
|
type TguToolbarProps = {
|
||||||
|
fromValue: string;
|
||||||
|
toValue: string;
|
||||||
|
loading: boolean;
|
||||||
|
error?: string;
|
||||||
|
onFromChange: (value: string) => void;
|
||||||
|
onToChange: (value: string) => void;
|
||||||
|
onApply: () => void;
|
||||||
|
onQuickRange: (hours: number) => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TguToolbar({
|
||||||
|
fromValue,
|
||||||
|
toValue,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
onFromChange,
|
||||||
|
onToChange,
|
||||||
|
onApply,
|
||||||
|
onQuickRange,
|
||||||
|
onRefresh
|
||||||
|
}: TguToolbarProps) {
|
||||||
|
return (
|
||||||
|
<header className="tgu-toolbar">
|
||||||
|
<div className="tgu-toolbar__row">
|
||||||
|
<div className="tgu-toolbar__brand">
|
||||||
|
<span className="tgu-toolbar__mark" aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<div className="tgu-toolbar__title">Планирование ТГУ</div>
|
||||||
|
<div className="tgu-toolbar__subtitle">Mission Ops</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="tgu-field">
|
||||||
|
<span>С</span>
|
||||||
|
<input type="datetime-local" value={fromValue} onChange={(event) => onFromChange(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="tgu-field">
|
||||||
|
<span>По</span>
|
||||||
|
<input type="datetime-local" value={toValue} onChange={(event) => onToChange(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button className="tgu-button tgu-button--primary" type="button" onClick={onApply} disabled={loading}>
|
||||||
|
Показать
|
||||||
|
</button>
|
||||||
|
<button className="tgu-button" type="button" onClick={() => onQuickRange(24)} disabled={loading}>
|
||||||
|
24ч
|
||||||
|
</button>
|
||||||
|
<button className="tgu-button" type="button" onClick={() => onQuickRange(72)} disabled={loading}>
|
||||||
|
3 суток
|
||||||
|
</button>
|
||||||
|
<button className="tgu-button" type="button" onClick={() => onQuickRange(168)} disabled={loading}>
|
||||||
|
7 суток
|
||||||
|
</button>
|
||||||
|
<button className="tgu-button" type="button" onClick={onRefresh} disabled={loading}>
|
||||||
|
{loading ? "Обновление..." : "Обновить"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<TguLegend />
|
||||||
|
{error && <div className="tgu-alert tgu-alert--danger">{error}</div>}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { TguPlanStatus } from "../../model/tguTypes";
|
||||||
|
import type { TimelineStatusStyle } from "../../model/timelineTypes";
|
||||||
|
|
||||||
|
export const STATUS_STYLES: TimelineStatusStyle[] = [
|
||||||
|
{ status: "ACCEPTED", label: "ACCEPTED", color: "#2f9e44", className: "status-accepted" },
|
||||||
|
{ status: "REJECTED", label: "REJECTED", color: "#d64545", className: "status-rejected" },
|
||||||
|
{ status: "WAITING_DECISION", label: "WAITING_DECISION", color: "#f0ad2e", className: "status-waiting" },
|
||||||
|
{ status: "PLANNED", label: "PLANNED", color: "#8d99a6", className: "status-planned" },
|
||||||
|
{ status: "ISSUING", label: "ISSUING", color: "#2f80ed", className: "status-issuing" },
|
||||||
|
{ status: "EXPIRED", label: "EXPIRED", color: "#495057", className: "status-expired" },
|
||||||
|
{ status: "SUPERSEDED", label: "SUPERSEDED", color: "#c7ced6", className: "status-superseded" },
|
||||||
|
{ status: "START_AMBIGUOUS", label: "START_AMBIGUOUS", color: "#b42318", className: "status-ambiguous" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATUS_BY_NAME = new Map(STATUS_STYLES.map((style) => [style.status, style]));
|
||||||
|
|
||||||
|
export function statusStyle(status: TguPlanStatus): TimelineStatusStyle {
|
||||||
|
return (
|
||||||
|
STATUS_BY_NAME.get(status) ?? {
|
||||||
|
status,
|
||||||
|
label: status || "UNKNOWN",
|
||||||
|
color: "#79828d",
|
||||||
|
className: "status-unknown"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { TguPlanUi, TimelineRow } from "../../model/timelineTypes";
|
||||||
|
import { buildSequentialLinks, clipPlanToRange, timeToX } from "./tguTimelineLayout";
|
||||||
|
|
||||||
|
const range = {
|
||||||
|
fromMs: Date.parse("2026-05-29T00:00:00"),
|
||||||
|
toMs: Date.parse("2026-05-30T00:00:00")
|
||||||
|
};
|
||||||
|
|
||||||
|
function plan(id: string, startTime: string, endTime: string, spacecraftId = "56756"): TguPlanUi {
|
||||||
|
return {
|
||||||
|
planId: id,
|
||||||
|
spacecraftId,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
kppId: "KPP-1",
|
||||||
|
status: "PLANNED",
|
||||||
|
startMs: Date.parse(startTime),
|
||||||
|
endMs: Date.parse(endTime)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("tguTimelineLayout", () => {
|
||||||
|
it("maps time to x inside the selected interval", () => {
|
||||||
|
expect(timeToX(Date.parse("2026-05-29T12:00:00"), range, 1200)).toBe(600);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clips partially visible plans to the selected interval", () => {
|
||||||
|
const clipped = clipPlanToRange(plan("p1", "2026-05-28T23:00:00", "2026-05-29T02:00:00"), range);
|
||||||
|
|
||||||
|
expect(clipped).toEqual({
|
||||||
|
startMs: range.fromMs,
|
||||||
|
endMs: Date.parse("2026-05-29T02:00:00")
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds links only between sequential plans of the same spacecraft", () => {
|
||||||
|
const rows: TimelineRow[] = [
|
||||||
|
{
|
||||||
|
spacecraftId: "56756",
|
||||||
|
plans: [
|
||||||
|
plan("p2", "2026-05-29T04:00:00", "2026-05-29T05:00:00"),
|
||||||
|
plan("p1", "2026-05-29T01:00:00", "2026-05-29T02:00:00")
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
spacecraftId: "777",
|
||||||
|
plans: [
|
||||||
|
plan("p3", "2026-05-29T01:00:00", "2026-05-29T02:00:00", "777")
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(buildSequentialLinks(rows)).toEqual([
|
||||||
|
{ spacecraftId: "56756", fromPlanId: "p1", toPlanId: "p2" }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import type { TguPlanUi, TimelineLink, TimelinePlanSegment, TimelineRange, TimelineRow } from "../../model/timelineTypes";
|
||||||
|
|
||||||
|
export const TIMELINE_LABEL_WIDTH = 220;
|
||||||
|
export const TIMELINE_ROW_HEIGHT = 58;
|
||||||
|
export const TIMELINE_AXIS_HEIGHT = 46;
|
||||||
|
export const TIMELINE_BOTTOM_PADDING = 18;
|
||||||
|
|
||||||
|
export function timeToX(timeMs: number, range: TimelineRange, timelineWidth: number): number {
|
||||||
|
return ((timeMs - range.fromMs) / (range.toMs - range.fromMs)) * timelineWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clipPlanToRange(plan: TguPlanUi, range: TimelineRange): { startMs: number; endMs: number } | null {
|
||||||
|
if (plan.endMs < range.fromMs || plan.startMs > range.toMs) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
startMs: Math.max(plan.startMs, range.fromMs),
|
||||||
|
endMs: Math.min(plan.endMs, range.toMs)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTimelineSegments(
|
||||||
|
rows: TimelineRow[],
|
||||||
|
range: TimelineRange,
|
||||||
|
timelineWidth: number
|
||||||
|
): TimelinePlanSegment[] {
|
||||||
|
const segments: TimelinePlanSegment[] = [];
|
||||||
|
|
||||||
|
rows.forEach((row, rowIndex) => {
|
||||||
|
for (const plan of row.plans) {
|
||||||
|
const clipped = clipPlanToRange(plan, range);
|
||||||
|
if (!clipped) continue;
|
||||||
|
|
||||||
|
const x = timeToX(clipped.startMs, range, timelineWidth);
|
||||||
|
const x2 = timeToX(clipped.endMs, range, timelineWidth);
|
||||||
|
segments.push({
|
||||||
|
plan,
|
||||||
|
rowIndex,
|
||||||
|
x,
|
||||||
|
x2,
|
||||||
|
width: Math.max(3, x2 - x)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSequentialLinks(rows: TimelineRow[]): TimelineLink[] {
|
||||||
|
const links: TimelineLink[] = [];
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const sorted = [...row.plans].sort((a, b) => a.startMs - b.startMs || a.endMs - b.endMs);
|
||||||
|
for (let index = 0; index < sorted.length - 1; index += 1) {
|
||||||
|
links.push({
|
||||||
|
spacecraftId: row.spacecraftId,
|
||||||
|
fromPlanId: sorted[index].planId,
|
||||||
|
toPlanId: sorted[index + 1].planId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return links;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTimeTicks(range: TimelineRange, targetCount = 8): number[] {
|
||||||
|
const span = range.toMs - range.fromMs;
|
||||||
|
const roughStep = span / targetCount;
|
||||||
|
const candidates = [
|
||||||
|
15 * 60_000,
|
||||||
|
30 * 60_000,
|
||||||
|
60 * 60_000,
|
||||||
|
2 * 60 * 60_000,
|
||||||
|
3 * 60 * 60_000,
|
||||||
|
6 * 60 * 60_000,
|
||||||
|
12 * 60 * 60_000,
|
||||||
|
24 * 60 * 60_000
|
||||||
|
];
|
||||||
|
const step = candidates.find((candidate) => candidate >= roughStep) ?? 24 * 60 * 60_000;
|
||||||
|
const first = Math.ceil(range.fromMs / step) * step;
|
||||||
|
const ticks: number[] = [];
|
||||||
|
|
||||||
|
for (let tick = first; tick <= range.toMs; tick += step) {
|
||||||
|
ticks.push(tick);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ticks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function timelineHeight(rowCount: number): number {
|
||||||
|
return TIMELINE_AXIS_HEIGHT + rowCount * TIMELINE_ROW_HEIGHT + TIMELINE_BOTTOM_PADDING;
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import type { TguPlan, TguPlatform } from "../../model/tguTypes";
|
||||||
|
import type { TguPlanUi, TguPlatformUi, TimelineRange, TimelineRow } from "../../model/timelineTypes";
|
||||||
|
|
||||||
|
const PROBLEM_STATUSES = new Set(["REJECTED", "EXPIRED", "START_AMBIGUOUS"]);
|
||||||
|
|
||||||
|
export function mapPlans(plans: TguPlan[], platforms: TguPlatform[]): TguPlanUi[] {
|
||||||
|
const platformByNorad = buildPlatformByNorad(platforms);
|
||||||
|
|
||||||
|
return plans
|
||||||
|
.map((plan) => {
|
||||||
|
const startMs = new Date(plan.startTime).getTime();
|
||||||
|
const endMs = new Date(plan.endTime).getTime();
|
||||||
|
const platform = platformByNorad.get(plan.spacecraftId);
|
||||||
|
return {
|
||||||
|
...plan,
|
||||||
|
startMs,
|
||||||
|
endMs,
|
||||||
|
platform: platform ? toPlatformUi(plan.spacecraftId, platform, 0, false) : undefined
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((plan) => Number.isFinite(plan.startMs) && Number.isFinite(plan.endMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPlatformsForInterval(
|
||||||
|
platforms: TguPlatform[],
|
||||||
|
plans: TguPlanUi[],
|
||||||
|
range: TimelineRange
|
||||||
|
): TguPlatformUi[] {
|
||||||
|
const intervalPlans = filterPlansByRange(plans, range);
|
||||||
|
const platformByNorad = buildPlatformByNorad(platforms);
|
||||||
|
const planStats = new Map<string, { count: number; hasProblemStatus: boolean }>();
|
||||||
|
|
||||||
|
for (const plan of intervalPlans) {
|
||||||
|
const stats = planStats.get(plan.spacecraftId) ?? { count: 0, hasProblemStatus: false };
|
||||||
|
stats.count += 1;
|
||||||
|
stats.hasProblemStatus = stats.hasProblemStatus || PROBLEM_STATUSES.has(plan.status);
|
||||||
|
planStats.set(plan.spacecraftId, stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
const platformItems = Array.from(platformByNorad.entries()).map(([spacecraftId, platform]) => {
|
||||||
|
const stats = planStats.get(spacecraftId) ?? { count: 0, hasProblemStatus: false };
|
||||||
|
return toPlatformUi(spacecraftId, platform, stats.count, stats.hasProblemStatus);
|
||||||
|
});
|
||||||
|
|
||||||
|
const knownSpacecraft = new Set(platformItems.map((platform) => platform.spacecraftId));
|
||||||
|
for (const [spacecraftId, stats] of planStats.entries()) {
|
||||||
|
if (!knownSpacecraft.has(spacecraftId)) {
|
||||||
|
platformItems.push({
|
||||||
|
spacecraftId,
|
||||||
|
noradId: spacecraftId,
|
||||||
|
planCount: stats.count,
|
||||||
|
hasProblemStatus: stats.hasProblemStatus
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return platformItems.sort((a, b) => platformLabel(a).localeCompare(platformLabel(b), "ru"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTimelineRows(
|
||||||
|
plans: TguPlanUi[],
|
||||||
|
platforms: TguPlatformUi[],
|
||||||
|
range: TimelineRange,
|
||||||
|
selectedSpacecraftId?: string
|
||||||
|
): TimelineRow[] {
|
||||||
|
const platformBySpacecraft = new Map(platforms.map((platform) => [platform.spacecraftId, platform]));
|
||||||
|
const rowsBySpacecraft = new Map<string, TimelineRow>();
|
||||||
|
|
||||||
|
for (const plan of filterPlansByRange(plans, range)) {
|
||||||
|
if (selectedSpacecraftId && plan.spacecraftId !== selectedSpacecraftId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const row =
|
||||||
|
rowsBySpacecraft.get(plan.spacecraftId) ??
|
||||||
|
{
|
||||||
|
spacecraftId: plan.spacecraftId,
|
||||||
|
platform: platformBySpacecraft.get(plan.spacecraftId),
|
||||||
|
plans: []
|
||||||
|
};
|
||||||
|
|
||||||
|
row.plans.push(plan);
|
||||||
|
rowsBySpacecraft.set(plan.spacecraftId, row);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const platform of platforms) {
|
||||||
|
if (selectedSpacecraftId && platform.spacecraftId !== selectedSpacecraftId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!rowsBySpacecraft.has(platform.spacecraftId) && platform.planCount > 0) {
|
||||||
|
rowsBySpacecraft.set(platform.spacecraftId, {
|
||||||
|
spacecraftId: platform.spacecraftId,
|
||||||
|
platform,
|
||||||
|
plans: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(rowsBySpacecraft.values())
|
||||||
|
.map((row) => ({
|
||||||
|
...row,
|
||||||
|
platform: row.platform ?? platformBySpacecraft.get(row.spacecraftId),
|
||||||
|
plans: [...row.plans].sort((a, b) => a.startMs - b.startMs)
|
||||||
|
}))
|
||||||
|
.sort((a, b) => platformLabel(a.platform, a.spacecraftId).localeCompare(platformLabel(b.platform, b.spacecraftId), "ru"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterPlatforms(platforms: TguPlatformUi[], query: string): TguPlatformUi[] {
|
||||||
|
const normalized = query.trim().toLowerCase();
|
||||||
|
if (!normalized) return platforms;
|
||||||
|
|
||||||
|
return platforms.filter((platform) => {
|
||||||
|
const searchText = [
|
||||||
|
platform.name,
|
||||||
|
platform.noradId,
|
||||||
|
platform.spacecraftId,
|
||||||
|
platform.mission,
|
||||||
|
platform.status
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
return searchText.includes(normalized);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterPlansByRange(plans: TguPlanUi[], range: TimelineRange): TguPlanUi[] {
|
||||||
|
return plans.filter((plan) => plan.endMs >= range.fromMs && plan.startMs <= range.toMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function platformLabel(platform?: TguPlatformUi, fallback?: string): string {
|
||||||
|
return platform?.name || platform?.noradId || fallback || platform?.spacecraftId || "КА";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPlatformByNorad(platforms: TguPlatform[]): Map<string, TguPlatform> {
|
||||||
|
const result = new Map<string, TguPlatform>();
|
||||||
|
for (const platform of platforms) {
|
||||||
|
if (platform.noradId !== null && platform.noradId !== undefined) {
|
||||||
|
result.set(String(platform.noradId), platform);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPlatformUi(
|
||||||
|
spacecraftId: string,
|
||||||
|
platform: TguPlatform,
|
||||||
|
planCount: number,
|
||||||
|
hasProblemStatus: boolean
|
||||||
|
): TguPlatformUi {
|
||||||
|
return {
|
||||||
|
spacecraftId,
|
||||||
|
name: platform.name ?? undefined,
|
||||||
|
noradId: platform.noradId === null || platform.noradId === undefined ? undefined : String(platform.noradId),
|
||||||
|
status: platform.status ?? undefined,
|
||||||
|
mission: platform.mission ?? undefined,
|
||||||
|
planCount,
|
||||||
|
hasProblemStatus,
|
||||||
|
platform
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import App from "./App";
|
||||||
|
import "./styles/theme.css";
|
||||||
|
import "./styles/tgu-planning.css";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
export type TguPlanStatus =
|
||||||
|
| "ACCEPTED"
|
||||||
|
| "REJECTED"
|
||||||
|
| "WAITING_DECISION"
|
||||||
|
| "PLANNED"
|
||||||
|
| "ISSUING"
|
||||||
|
| "EXPIRED"
|
||||||
|
| "SUPERSEDED"
|
||||||
|
| "START_AMBIGUOUS"
|
||||||
|
| string;
|
||||||
|
|
||||||
|
export type TguPlanDecision = "ACCEPTED" | "REJECTED";
|
||||||
|
|
||||||
|
export type TguPlan = {
|
||||||
|
planId: string;
|
||||||
|
spacecraftId: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
kppId: string;
|
||||||
|
status: TguPlanStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TguPlatform = {
|
||||||
|
id?: string | null;
|
||||||
|
businessKey?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
status?: string | null;
|
||||||
|
noradId?: number | string | null;
|
||||||
|
mission?: string | null;
|
||||||
|
validFrom?: string | null;
|
||||||
|
validTo?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TguPlanDecisionMessage = {
|
||||||
|
eventId: string;
|
||||||
|
spacecraftId: string;
|
||||||
|
planId: string;
|
||||||
|
attemptId: string;
|
||||||
|
decision: TguPlanDecision;
|
||||||
|
decisionTime: string;
|
||||||
|
reason?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TguApiErrorKind = "network" | "http";
|
||||||
|
|
||||||
|
export class TguApiError extends Error {
|
||||||
|
readonly kind: TguApiErrorKind;
|
||||||
|
readonly status?: number;
|
||||||
|
|
||||||
|
constructor(message: string, kind: TguApiErrorKind, status?: number) {
|
||||||
|
super(message);
|
||||||
|
this.name = "TguApiError";
|
||||||
|
this.kind = kind;
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { TguPlan, TguPlanStatus, TguPlatform } from "./tguTypes";
|
||||||
|
|
||||||
|
export type TguPlatformUi = {
|
||||||
|
spacecraftId: string;
|
||||||
|
name?: string;
|
||||||
|
noradId?: string;
|
||||||
|
status?: string;
|
||||||
|
mission?: string;
|
||||||
|
planCount: number;
|
||||||
|
hasProblemStatus: boolean;
|
||||||
|
platform?: TguPlatform;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TguPlanUi = TguPlan & {
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
platform?: TguPlatformUi;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TimelineRange = {
|
||||||
|
fromMs: number;
|
||||||
|
toMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TimelineRow = {
|
||||||
|
spacecraftId: string;
|
||||||
|
platform?: TguPlatformUi;
|
||||||
|
plans: TguPlanUi[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TimelinePlanSegment = {
|
||||||
|
plan: TguPlanUi;
|
||||||
|
rowIndex: number;
|
||||||
|
x: number;
|
||||||
|
x2: number;
|
||||||
|
width: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TimelineLink = {
|
||||||
|
spacecraftId: string;
|
||||||
|
fromPlanId: string;
|
||||||
|
toPlanId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TimelineStatusStyle = {
|
||||||
|
status: TguPlanStatus;
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
className: string;
|
||||||
|
};
|
||||||
@@ -0,0 +1,569 @@
|
|||||||
|
.tgu-app-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
height: 100%;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(104, 195, 189, 0.06), transparent 22rem),
|
||||||
|
var(--bg-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-toolbar {
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: rgba(23, 26, 28, 0.96);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-toolbar__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
min-height: 4.4rem;
|
||||||
|
padding: 0.75rem 1rem 0.65rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-toolbar__brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
min-width: 13.5rem;
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-toolbar__mark {
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
border: 2px solid var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 18px rgba(104, 195, 189, 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-toolbar__title {
|
||||||
|
font-size: 0.96rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-toolbar__subtitle {
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-family: "SFMono-Regular", Consolas, monospace;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-field input,
|
||||||
|
.tgu-search input {
|
||||||
|
min-height: 2rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #101214;
|
||||||
|
color: var(--text);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-field input {
|
||||||
|
width: 12.8rem;
|
||||||
|
padding: 0.38rem 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-field input:focus,
|
||||||
|
.tgu-search input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 2px rgba(104, 195, 189, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-button {
|
||||||
|
min-height: 2rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.38rem 0.72rem;
|
||||||
|
background: var(--bg-panel-2);
|
||||||
|
color: var(--text);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-button:hover:not(:disabled) {
|
||||||
|
border-color: #4e5a5d;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-button--primary {
|
||||||
|
border-color: rgba(104, 195, 189, 0.58);
|
||||||
|
background: #214341;
|
||||||
|
color: #eafffd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-button--accept {
|
||||||
|
border-color: rgba(47, 158, 68, 0.65);
|
||||||
|
background: rgba(47, 158, 68, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-button--reject {
|
||||||
|
border-color: rgba(214, 69, 69, 0.7);
|
||||||
|
background: rgba(214, 69, 69, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-legend {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.48rem 0.82rem;
|
||||||
|
padding: 0 1rem 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-legend__item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-legend__swatch {
|
||||||
|
width: 1.05rem;
|
||||||
|
height: 0.46rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #79828d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-legend__note {
|
||||||
|
color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-accepted {
|
||||||
|
background-color: #2f9e44;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-rejected {
|
||||||
|
background-color: #d64545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-waiting {
|
||||||
|
background-color: #f0ad2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-planned {
|
||||||
|
background-color: #8d99a6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-issuing {
|
||||||
|
background-color: #2f80ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-expired {
|
||||||
|
background-color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-superseded {
|
||||||
|
background-color: #c7ced6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-ambiguous {
|
||||||
|
background-color: #b42318;
|
||||||
|
outline: 1px dashed #ffd1d1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-unknown {
|
||||||
|
background-color: #79828d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-workspace {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 18.5rem minmax(0, 1fr) 22rem;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-sidebar,
|
||||||
|
.tgu-details {
|
||||||
|
min-height: 0;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-panel-heading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 3rem;
|
||||||
|
padding: 0.75rem 0.9rem;
|
||||||
|
border-bottom: 1px solid var(--line-soft);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-link-button {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-search {
|
||||||
|
padding: 0.75rem 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-search input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.42rem 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-spacecraft-list {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 0.35rem;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0 0.55rem 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-spacecraft {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 0.58rem minmax(0, 1fr) auto;
|
||||||
|
gap: 0.55rem;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 4.35rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 7px;
|
||||||
|
padding: 0.55rem 0.62rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-spacecraft:hover,
|
||||||
|
.tgu-spacecraft.is-selected {
|
||||||
|
border-color: var(--line);
|
||||||
|
background: var(--bg-panel-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-spacecraft.is-selected {
|
||||||
|
border-color: rgba(104, 195, 189, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-spacecraft__problem {
|
||||||
|
width: 0.48rem;
|
||||||
|
height: 0.48rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #465154;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-spacecraft__problem.is-problem {
|
||||||
|
background: var(--danger);
|
||||||
|
box-shadow: 0 0 12px rgba(214, 69, 69, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-spacecraft__body {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 0.18rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-spacecraft__name {
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
font-weight: 650;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-spacecraft__meta,
|
||||||
|
.tgu-spacecraft__status {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-spacecraft__count {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-width: 1.75rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #101214;
|
||||||
|
color: var(--accent-strong);
|
||||||
|
font-family: "SFMono-Regular", Consolas, monospace;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-center {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
background: #121516;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-timeline {
|
||||||
|
min-width: 48rem;
|
||||||
|
min-height: 100%;
|
||||||
|
padding: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-timeline__svg {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 26rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #151819;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-timeline__background {
|
||||||
|
fill: #151819;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-timeline__labels-background {
|
||||||
|
fill: #181c1e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-timeline__axis,
|
||||||
|
.tgu-timeline__grid {
|
||||||
|
stroke: #343c3f;
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-timeline__grid {
|
||||||
|
stroke-dasharray: 3 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-timeline__tick-date {
|
||||||
|
fill: #d2dddd;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-timeline__tick-time,
|
||||||
|
.tgu-timeline__row-meta {
|
||||||
|
fill: #79878a;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-timeline__now {
|
||||||
|
stroke: #f46e4f;
|
||||||
|
stroke-width: 1.7;
|
||||||
|
stroke-dasharray: 5 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-timeline__now-label {
|
||||||
|
fill: #f46e4f;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-timeline__row {
|
||||||
|
fill: #151819;
|
||||||
|
stroke: #22282a;
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-timeline__row--alt {
|
||||||
|
fill: #171b1d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-timeline__row-title {
|
||||||
|
fill: #e2e9e9;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-timeline__arrow {
|
||||||
|
fill: none;
|
||||||
|
stroke: #8aa2a6;
|
||||||
|
stroke-width: 1.5;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-timeline-plan {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-timeline-plan__bar {
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-width: 13;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-timeline-plan__selection {
|
||||||
|
stroke: #efffff;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-width: 19;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-timeline-plan__label {
|
||||||
|
fill: #101214;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.9rem;
|
||||||
|
overflow: auto;
|
||||||
|
border-left: 1px solid var(--line);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-details--empty {
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-details__placeholder {
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-details__placeholder-icon {
|
||||||
|
width: 2.2rem;
|
||||||
|
height: 1rem;
|
||||||
|
border: 2px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-details__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-details__eyebrow {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-details h2 {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-icon-button {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 1.8rem;
|
||||||
|
height: 1.8rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-panel-2);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-status-pill {
|
||||||
|
align-self: flex-start;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.22rem 0.65rem;
|
||||||
|
color: #101214;
|
||||||
|
font-family: "SFMono-Regular", Consolas, monospace;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-details-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.72rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-details-grid dt {
|
||||||
|
margin-bottom: 0.18rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-details-grid dd {
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-details__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding-top: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-alert {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 7px;
|
||||||
|
padding: 0.62rem 0.72rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-toolbar > .tgu-alert {
|
||||||
|
margin: 0 1rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-alert--danger {
|
||||||
|
border-color: rgba(214, 69, 69, 0.48);
|
||||||
|
background: rgba(214, 69, 69, 0.12);
|
||||||
|
color: #ffd9d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-alert--warning {
|
||||||
|
border-color: rgba(240, 173, 46, 0.48);
|
||||||
|
background: rgba(240, 173, 46, 0.12);
|
||||||
|
color: #ffe7b5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-alert--success {
|
||||||
|
border-color: rgba(47, 158, 68, 0.48);
|
||||||
|
background: rgba(47, 158, 68, 0.12);
|
||||||
|
color: #c9f7d2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-empty {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 18rem;
|
||||||
|
padding: 2rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-empty--compact {
|
||||||
|
min-height: 5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.tgu-workspace {
|
||||||
|
grid-template-columns: 16rem minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-details {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
max-height: 18rem;
|
||||||
|
border-left: 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
:root {
|
||||||
|
font-family:
|
||||||
|
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
color: #e7ecec;
|
||||||
|
background: #111315;
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
|
||||||
|
--bg-base: #111315;
|
||||||
|
--bg-panel: #171a1c;
|
||||||
|
--bg-panel-2: #1f2325;
|
||||||
|
--bg-elevated: #262b2e;
|
||||||
|
--line: #30373a;
|
||||||
|
--line-soft: #252b2d;
|
||||||
|
--text: #e7ecec;
|
||||||
|
--text-muted: #a4b0b1;
|
||||||
|
--text-dim: #748183;
|
||||||
|
--accent: #68c3bd;
|
||||||
|
--accent-strong: #82ded8;
|
||||||
|
--danger: #d64545;
|
||||||
|
--warning: #f0ad2e;
|
||||||
|
--success: #2f9e44;
|
||||||
|
--shadow: 0 16px 42px rgba(0, 0, 0, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.62;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5174,
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: "http://localhost:7008",
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
globals: true
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user