From 9fca4d40511ee8e4a17d9608786f43af7d042a64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Fri, 29 May 2026 16:09:36 +0300 Subject: [PATCH] =?UTF-8?q?pcp-tgu=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D1=84?= =?UTF-8?q?=D0=B5=D0=B9=D1=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + .../pcp-tgu-mission-ops/.thumbnail | Bin 0 -> 5758 bytes .../ui-prototypes/pcp-tgu-mission-ops/app.jsx | 221 +++++++ .../pcp-tgu-mission-ops/data.jsx | 195 ++++++ .../pcp-tgu-mission-ops/details.jsx | 152 +++++ .../pcp-tgu-mission-ops/sidebar.jsx | 122 ++++ .../pcp-tgu-mission-ops/timeline.jsx | 273 +++++++++ .../pcp-tgu-mission-ops/util.jsx | 123 ++++ .../План КА - Mission Ops.html | 93 +++ services/pcp-tgu-ops-ui/Dockerfile | 14 + services/pcp-tgu-ops-ui/index.html | 12 + services/pcp-tgu-ops-ui/nginx.conf | 20 + services/pcp-tgu-ops-ui/src/App.tsx | 5 + services/pcp-tgu-ops-ui/src/api/tguApi.ts | 93 +++ .../src/features/tgu-planning/TguLegend.tsx | 15 + .../features/tgu-planning/TguPlanDetails.tsx | 102 ++++ .../tgu-planning/TguPlanningLayout.tsx | 21 + .../features/tgu-planning/TguPlanningPage.tsx | 217 +++++++ .../src/features/tgu-planning/TguSidebar.tsx | 74 +++ .../src/features/tgu-planning/TguTimeline.tsx | 201 +++++++ .../src/features/tgu-planning/TguToolbar.tsx | 66 ++ .../src/features/tgu-planning/tguStatus.ts | 26 + .../tgu-planning/tguTimelineLayout.test.ts | 58 ++ .../tgu-planning/tguTimelineLayout.ts | 93 +++ .../tgu-planning/tguTimelineMapper.ts | 162 +++++ services/pcp-tgu-ops-ui/src/main.tsx | 11 + services/pcp-tgu-ops-ui/src/model/tguTypes.ts | 56 ++ .../pcp-tgu-ops-ui/src/model/timelineTypes.ts | 50 ++ .../src/styles/tgu-planning.css | 569 ++++++++++++++++++ services/pcp-tgu-ops-ui/src/styles/theme.css | 57 ++ services/pcp-tgu-ops-ui/tsconfig.app.json | 21 + services/pcp-tgu-ops-ui/tsconfig.json | 7 + services/pcp-tgu-ops-ui/tsconfig.node.json | 14 + services/pcp-tgu-ops-ui/vite.config.ts | 19 + 34 files changed, 3163 insertions(+) create mode 100644 docs/ui-prototypes/pcp-tgu-mission-ops/.thumbnail create mode 100644 docs/ui-prototypes/pcp-tgu-mission-ops/app.jsx create mode 100644 docs/ui-prototypes/pcp-tgu-mission-ops/data.jsx create mode 100644 docs/ui-prototypes/pcp-tgu-mission-ops/details.jsx create mode 100644 docs/ui-prototypes/pcp-tgu-mission-ops/sidebar.jsx create mode 100644 docs/ui-prototypes/pcp-tgu-mission-ops/timeline.jsx create mode 100644 docs/ui-prototypes/pcp-tgu-mission-ops/util.jsx create mode 100644 docs/ui-prototypes/pcp-tgu-mission-ops/План КА - Mission Ops.html create mode 100644 services/pcp-tgu-ops-ui/Dockerfile create mode 100644 services/pcp-tgu-ops-ui/index.html create mode 100644 services/pcp-tgu-ops-ui/nginx.conf create mode 100644 services/pcp-tgu-ops-ui/src/App.tsx create mode 100644 services/pcp-tgu-ops-ui/src/api/tguApi.ts create mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-planning/TguLegend.tsx create mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanDetails.tsx create mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningLayout.tsx create mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx create mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-planning/TguSidebar.tsx create mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-planning/TguTimeline.tsx create mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-planning/TguToolbar.tsx create mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-planning/tguStatus.ts create mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineLayout.test.ts create mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineLayout.ts create mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineMapper.ts create mode 100644 services/pcp-tgu-ops-ui/src/main.tsx create mode 100644 services/pcp-tgu-ops-ui/src/model/tguTypes.ts create mode 100644 services/pcp-tgu-ops-ui/src/model/timelineTypes.ts create mode 100644 services/pcp-tgu-ops-ui/src/styles/tgu-planning.css create mode 100644 services/pcp-tgu-ops-ui/src/styles/theme.css create mode 100644 services/pcp-tgu-ops-ui/tsconfig.app.json create mode 100644 services/pcp-tgu-ops-ui/tsconfig.json create mode 100644 services/pcp-tgu-ops-ui/tsconfig.node.json create mode 100644 services/pcp-tgu-ops-ui/vite.config.ts diff --git a/.gitignore b/.gitignore index 8b1f57c..0b4af92 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,4 @@ out/ /pcp-chatgpt-context*.zip /deploy/local-bundle/build/ /deploy/local-bundle/pcp-local-bundle-*.tar.gz +/services/pcp-tgu-ops-ui/node_modules/ diff --git a/docs/ui-prototypes/pcp-tgu-mission-ops/.thumbnail b/docs/ui-prototypes/pcp-tgu-mission-ops/.thumbnail new file mode 100644 index 0000000000000000000000000000000000000000..8e07e2289940d17cf0e14c478a92113ec0a22843 GIT binary patch literal 5758 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 literal 0 HcmV?d00001 diff --git a/docs/ui-prototypes/pcp-tgu-mission-ops/app.jsx b/docs/ui-prototypes/pcp-tgu-mission-ops/app.jsx new file mode 100644 index 0000000..b8d272d --- /dev/null +++ b/docs/ui-prototypes/pcp-tgu-mission-ops/app.jsx @@ -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 ( +
+ + +
+ {/* ТУЛБАР */} +
+ {/* масштаб */} +
+ {SCALES.map((s) => ( + + ))} +
+ + + +
+ + +
+ + {/* статистика */} +
+ + + 0 ? "var(--now)" : null} /> +
+ + {!allOn && ( + + )} +
+ + {rows.length === 0 ? ( +
+ Нет аппаратов по текущим фильтрам. +
+ ) : ( +
{}}> + +
+ )} +
+ +
setSelectedIv(null)} /> +
+ ); +} + +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 ( +
+ РАБОТЫ: + {items.map((it) => ( +
+ + {it.label} +
+ ))} +
+ ); +} + +function Stat({ label, v, accent }) { return ( +
+ {v} + {label} +
+ ); +} + +ReactDOM.createRoot(document.getElementById("root")).render(); diff --git a/docs/ui-prototypes/pcp-tgu-mission-ops/data.jsx b/docs/ui-prototypes/pcp-tgu-mission-ops/data.jsx new file mode 100644 index 0000000..3b6771e --- /dev/null +++ b/docs/ui-prototypes/pcp-tgu-mission-ops/data.jsx @@ -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"], + }; +})(); diff --git a/docs/ui-prototypes/pcp-tgu-mission-ops/details.jsx b/docs/ui-prototypes/pcp-tgu-mission-ops/details.jsx new file mode 100644 index 0000000..df27bf4 --- /dev/null +++ b/docs/ui-prototypes/pcp-tgu-mission-ops/details.jsx @@ -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 {s.t}; +} + +function Field({ label, children, mono }) { + return ( +
+ {label} + {children} +
+ ); +} + +function Details({ iv, sc, group, byId, onSelectIv, onClose }) { + if (!iv) { + return ( +
+ +
Выберите план на таймлайне, чтобы увидеть его работы и цепочку ревизий.
+
+ ); + } + 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 ( + + ); + }; + + return ( +
+
+
+
+ + {iv.id} +
+ +
+
{iv.title}
+
+ + {sc.short} · {group.name} +
+
+ +
+ {/* Время */} +
+ {fmtDateTime(iv.start)} + {fmtDateTime(iv.end)} + {fmtDur(dur)} + {iv.works.length} +
+ +
+ + {/* Аппарат */} +
+ АППАРАТ +
+ +
+
{sc.name}
+
{D.TYPES[sc.type].label} · {sc.orbit}
+
+
+ {iv.author} +
+ +
+ + {/* Работы внутри плана */} +
+
+ РАБОТЫ В ПЛАНЕ + {iv.works.length} +
+
+ {iv.works.length === 0 &&
Работы не заданы.
} + {iv.works.map((wk) => { + const fill = workFill(wk.kind, sc.type); + const isService = wk.kind === "service"; + return ( +
+ + {wk.label} + {fmtTime(wk.start)}–{fmtTime(wk.end)} +
+ ); + })} +
+
+ +
+ + {/* Родословная */} +
+
+ РОДОСЛОВНАЯ ПЛАНА + {lin.chain.length} ревизий +
+ + {lin.ancestors.length > 0 && ( +
+ ◀ Предки + {lin.ancestors.map(chainRow)} +
+ )} + +
+ ● Текущий + {chainRow(iv.id)} +
+ + {lin.descendants.length > 0 && ( +
+ ▶ Потомки + {lin.descendants.map(chainRow)} +
+ )} + + {lin.ancestors.length === 0 && lin.descendants.length === 0 && ( +
Это единственная ревизия плана — без предков и потомков.
+ )} +
+
+
+ ); +} + +Object.assign(window, { Details }); diff --git a/docs/ui-prototypes/pcp-tgu-mission-ops/sidebar.jsx b/docs/ui-prototypes/pcp-tgu-mission-ops/sidebar.jsx new file mode 100644 index 0000000..8bdf60c --- /dev/null +++ b/docs/ui-prototypes/pcp-tgu-mission-ops/sidebar.jsx @@ -0,0 +1,122 @@ +// Левая панель: поиск, фильтр по типам (легенда), дерево групп → КА с чекбоксами. +const { useState: useStateSB } = React; + +function TypeDot({ type, size = 9 }) { + return ( + + ); +} + +function Check({ on, onClick, dim }) { + return ( + + ); +} + +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 ( +
+ {/* Заголовок */} +
+
+
+ +
+
+
ПЛАН КА
+
MISSION OPS · ДЗЗ
+
+
+
+ + {/* Поиск */} +
+
+ + 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 && } +
+
+ + {/* Легенда / фильтр по типам */} +
+
АППАРАТУРА
+
+ {D.typeOrder.map((t) => { + const on = typeFilter[t]; + return ( + + ); + })} +
+
+ + {/* Дерево групп → КА */} +
+
ГРУППЫ / КА
+ {groups.map((g) => { + const list = scByGroup[g.id] || []; + const isOpen = open[g.id]; + const shownCount = list.filter((sc) => !scHidden.has(sc.id)).length; + return ( +
+
+ + toggleGroup(g.id)} /> + {g.code} + {g.name} + {shownCount}/{list.length} +
+ {isOpen && ( +
+ {list.map((sc) => { + const vis = !scHidden.has(sc.id); + const sel = selectedSc === sc.id; + return ( +
onPickSc(sc.id)} + style={{ display: "flex", alignItems: "center", gap: 7, padding: "4px 7px", borderRadius: 6, cursor: "pointer", background: sel ? "var(--bg-3)" : "transparent" }}> + { e.stopPropagation(); toggleSc(sc.id); }} /> + + {sc.short} +
+ ); + })} +
+ )} +
+ ); + })} +
+
+ ); +} + +Object.assign(window, { Sidebar, TypeDot }); diff --git a/docs/ui-prototypes/pcp-tgu-mission-ops/timeline.jsx b/docs/ui-prototypes/pcp-tgu-mission-ops/timeline.jsx new file mode 100644 index 0000000..584a1ee --- /dev/null +++ b/docs/ui-prototypes/pcp-tgu-mission-ops/timeline.jsx @@ -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 ( +
+ {/* ОСЬ ВРЕМЕНИ */} +
+
+ КОСМИЧЕСКИЙ АППАРАТ +
+
+
+ {/* верхний ярус — дни */} + {days.map((d, i) => { + const w = 86400000 * pxPerMs; + return ( +
+ {pad(new Date(d.t).getUTCDate())} + {MONTHS[new Date(d.t).getUTCMonth()]} + {WDAYS[d.dow]} +
+ ); + })} + {/* нижний ярус — засечки */} + {sub.ticks.map((tk, i) => ( +
+ {showHourTicks && {fmtTime(tk.t)}} +
+ ))} + {/* метка «сейчас» */} +
+ СЕЙЧАС {fmtTime(now)} +
+
+
+
+ + {/* ТЕЛО */} +
+ {/* Левая колонка подписей */} +
+
+ {layout.placed.map((r) => r.kind === "group" ? ( +
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" }}> + + {r.group.code} + {r.group.name} + {r.count} +
+ ) : ( +
+ +
+
{r.sc.short}
+
{r.sc.orbit}
+
+ {r.ivs.length} +
+ ))} +
+
+ + {/* Правый холст */} +
+
+ {/* фон выходных + сетка дней */} + {days.map((d, i) => ( +
+ ))} + {/* линия «сейчас» */} +
+ + {/* строки */} + {layout.placed.map((r) => r.kind === "group" ? ( +
+ ) : ( + + ))} + + {/* стрелки родословной */} + {arrows.length > 0 && ( + + + + + + + {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 ; + })} + + )} +
+
+
+
+ ); +} + +// Одна строка КА: ПЛАНЫ (объемлющие интервалы) по лейнам, внутри — РАБОТЫ. +function Row({ r, x, contentW, selectedIv, onSelectIv, lineageSet, anySelected }) { + return ( +
+ {/* пересечения планов — штриховка */} + {r.overlaps.map((ov, i) => ( +
+ ))} + {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 ( +
{ 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", + }}> + {/* заголовок плана */} +
+ {hasParent && } + {wide && {plan.title}} + {wide && plan.status === "active" && } + {wide && plan.rev > 1 && r{plan.rev}} + {!wide && } + {hasChild && } +
+ {/* трек работ внутри плана */} + {showWorks && ( +
+ {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 ( +
+ ); + })} +
+ )} +
+ ); + })} +
+ ); +} + +Object.assign(window, { Timeline }); diff --git a/docs/ui-prototypes/pcp-tgu-mission-ops/util.jsx b/docs/ui-prototypes/pcp-tgu-mission-ops/util.jsx new file mode 100644 index 0000000..39a3ca3 --- /dev/null +++ b/docs/ui-prototypes/pcp-tgu-mission-ops/util.jsx @@ -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, +}); diff --git a/docs/ui-prototypes/pcp-tgu-mission-ops/План КА - Mission Ops.html b/docs/ui-prototypes/pcp-tgu-mission-ops/План КА - Mission Ops.html new file mode 100644 index 0000000..ba8d077 --- /dev/null +++ b/docs/ui-prototypes/pcp-tgu-mission-ops/План КА - Mission Ops.html @@ -0,0 +1,93 @@ + + + + + +План КА — Mission Ops + + + + + + +
+ + + + + + + + + + + + + diff --git a/services/pcp-tgu-ops-ui/Dockerfile b/services/pcp-tgu-ops-ui/Dockerfile new file mode 100644 index 0000000..7b7cac9 --- /dev/null +++ b/services/pcp-tgu-ops-ui/Dockerfile @@ -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 diff --git a/services/pcp-tgu-ops-ui/index.html b/services/pcp-tgu-ops-ui/index.html new file mode 100644 index 0000000..223f74a --- /dev/null +++ b/services/pcp-tgu-ops-ui/index.html @@ -0,0 +1,12 @@ + + + + + + Планирование ТГУ Ops UI + + +
+ + + diff --git a/services/pcp-tgu-ops-ui/nginx.conf b/services/pcp-tgu-ops-ui/nginx.conf new file mode 100644 index 0000000..e1ffd09 --- /dev/null +++ b/services/pcp-tgu-ops-ui/nginx.conf @@ -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; + } +} diff --git a/services/pcp-tgu-ops-ui/src/App.tsx b/services/pcp-tgu-ops-ui/src/App.tsx new file mode 100644 index 0000000..8e53210 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/App.tsx @@ -0,0 +1,5 @@ +import { TguPlanningPage } from "./features/tgu-planning/TguPlanningPage"; + +export default function App() { + return ; +} diff --git a/services/pcp-tgu-ops-ui/src/api/tguApi.ts b/services/pcp-tgu-ops-ui/src/api/tguApi.ts new file mode 100644 index 0000000..10575e2 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/api/tguApi.ts @@ -0,0 +1,93 @@ +import { + TguApiError, + type TguPlan, + type TguPlanDecision, + type TguPlanDecisionMessage, + type TguPlatform +} from "../model/tguTypes"; + +export async function fetchPlatforms(): Promise { + return fetchJson("/api/tgu-planning/platforms"); +} + +export async function fetchPlans(historyDays: number, spacecraftId?: string): Promise { + 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(path); +} + +export async function sendPlanDecision( + planId: string, + decision: TguPlanDecision, + reason?: string +): Promise { + const query = new URLSearchParams({ decision }); + if (reason?.trim()) { + query.set("reason", reason.trim()); + } + + return fetchJson( + `/api/tgu-planning/plans/${encodeURIComponent(planId)}/decision?${query}`, + { method: "POST" }, + true + ); +} + +async function fetchJson(url: string, init?: RequestInit, decisionRequest = false): Promise { + 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; + 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; +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguLegend.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguLegend.tsx new file mode 100644 index 0000000..c4b6fac --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguLegend.tsx @@ -0,0 +1,15 @@ +import { STATUS_STYLES } from "./tguStatus"; + +export function TguLegend() { + return ( +
+ {STATUS_STYLES.map((item) => ( + + + {item.label} + + ))} + Стрелки показывают следующий план того же КА по времени. +
+ ); +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanDetails.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanDetails.tsx new file mode 100644 index 0000000..7712ec7 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanDetails.tsx @@ -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 ( + + ); + } + + 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 ( + + ); +} + +function Detail({ label, value, mono }: { label: string; value?: string; mono?: boolean }) { + return ( +
+
{label}
+
{value || "-"}
+
+ ); +} + +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} мин`; +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningLayout.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningLayout.tsx new file mode 100644 index 0000000..6afb54a --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningLayout.tsx @@ -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 ( +
+ {toolbar} +
+ {sidebar} +
{timeline}
+ {details} +
+
+ ); +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx new file mode 100644 index 0000000..cba487c --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx @@ -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(initialRange); + const [rawPlatforms, setRawPlatforms] = useState([]); + const [plans, setPlans] = useState([]); + const [loading, setLoading] = useState(false); + const [pageError, setPageError] = useState(); + const [platformLoadFailed, setPlatformLoadFailed] = useState(false); + const [search, setSearch] = useState(""); + const [selectedSpacecraftId, setSelectedSpacecraftId] = useState(); + const [selectedPlanId, setSelectedPlanId] = useState(); + const [decisionInFlight, setDecisionInFlight] = useState(false); + const [decisionNotice, setDecisionNotice] = useState(); + const [decisionError, setDecisionError] = useState(); + + 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 ( + + } + sidebar={ + { + setSelectedSpacecraftId(spacecraftId); + setSelectedPlanId(undefined); + setDecisionNotice(undefined); + }} + /> + } + timeline={ + + } + details={ + 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); +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguSidebar.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguSidebar.tsx new file mode 100644 index 0000000..68c0fb0 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguSidebar.tsx @@ -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 ( + + ); +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguTimeline.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguTimeline.tsx new file mode 100644 index 0000000..8e04697 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguTimeline.tsx @@ -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(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
Некорректный интервал: from должен быть меньше to.
; + } + + if (rows.length === 0 || segments.length === 0) { + return
Нет планов в выбранном интервале.
; + } + + return ( +
+ + + + + + + + + + + + + {ticks.map((tick) => { + const x = TIMELINE_LABEL_WIDTH + timeToX(tick, range, timelineWidth); + return ( + + + + {formatTickDate(tick)} + + + {formatTickTime(tick)} + + + ); + })} + + {nowX !== undefined && ( + + + + сейчас + + + )} + + {rows.map((row, rowIndex) => { + const y = TIMELINE_AXIS_HEIGHT + rowIndex * TIMELINE_ROW_HEIGHT; + return ( + + + + {platformLabel(row.platform, row.spacecraftId)} + + + NORAD {row.platform?.noradId || row.spacecraftId} + + + ); + })} + + {links.map((link) => { + const from = segmentByPlanId.get(link.fromPlanId); + const to = segmentByPlanId.get(link.toPlanId); + if (!from || !to) return null; + return ; + })} + + {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 ( + onSelectPlan(segment.plan.planId)} + > + + {segment.plan.planId} · {segment.plan.status} · {formatDateTime(segment.plan.startTime)} -{" "} + {formatDateTime(segment.plan.endTime)} + + {selected && } + + {labelVisible && ( + + {segment.plan.kppId} · {segment.plan.status} + + )} + + ); + })} + +
+ ); +} + +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 ; +} + +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"); +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguToolbar.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguToolbar.tsx new file mode 100644 index 0000000..03bb114 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguToolbar.tsx @@ -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 ( +
+
+
+
+ + + + + + + + + +
+ + {error &&
{error}
} +
+ ); +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguStatus.ts b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguStatus.ts new file mode 100644 index 0000000..f288b6e --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguStatus.ts @@ -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" + } + ); +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineLayout.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineLayout.test.ts new file mode 100644 index 0000000..512bbd8 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineLayout.test.ts @@ -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" } + ]); + }); +}); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineLayout.ts b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineLayout.ts new file mode 100644 index 0000000..0ade590 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineLayout.ts @@ -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; +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineMapper.ts b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineMapper.ts new file mode 100644 index 0000000..f5d5b1c --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineMapper.ts @@ -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(); + + 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(); + + 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 { + const result = new Map(); + 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 + }; +} diff --git a/services/pcp-tgu-ops-ui/src/main.tsx b/services/pcp-tgu-ops-ui/src/main.tsx new file mode 100644 index 0000000..f3df47a --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/main.tsx @@ -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( + + + +); diff --git a/services/pcp-tgu-ops-ui/src/model/tguTypes.ts b/services/pcp-tgu-ops-ui/src/model/tguTypes.ts new file mode 100644 index 0000000..e62484d --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/model/tguTypes.ts @@ -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; + } +} diff --git a/services/pcp-tgu-ops-ui/src/model/timelineTypes.ts b/services/pcp-tgu-ops-ui/src/model/timelineTypes.ts new file mode 100644 index 0000000..8653c44 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/model/timelineTypes.ts @@ -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; +}; diff --git a/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css b/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css new file mode 100644 index 0000000..f79f239 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css @@ -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); + } +} diff --git a/services/pcp-tgu-ops-ui/src/styles/theme.css b/services/pcp-tgu-ops-ui/src/styles/theme.css new file mode 100644 index 0000000..6b08e61 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/styles/theme.css @@ -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; +} diff --git a/services/pcp-tgu-ops-ui/tsconfig.app.json b/services/pcp-tgu-ops-ui/tsconfig.app.json new file mode 100644 index 0000000..7a7a882 --- /dev/null +++ b/services/pcp-tgu-ops-ui/tsconfig.app.json @@ -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"] +} diff --git a/services/pcp-tgu-ops-ui/tsconfig.json b/services/pcp-tgu-ops-ui/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/services/pcp-tgu-ops-ui/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/services/pcp-tgu-ops-ui/tsconfig.node.json b/services/pcp-tgu-ops-ui/tsconfig.node.json new file mode 100644 index 0000000..c077346 --- /dev/null +++ b/services/pcp-tgu-ops-ui/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"] +} diff --git a/services/pcp-tgu-ops-ui/vite.config.ts b/services/pcp-tgu-ops-ui/vite.config.ts new file mode 100644 index 0000000..1749eb9 --- /dev/null +++ b/services/pcp-tgu-ops-ui/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 + } +});