diff --git a/services/pcp-tgu-ops-ui/src/features/constellation/ConstellationDashboard.jsx b/services/pcp-tgu-ops-ui/src/features/constellation/ConstellationDashboard.jsx index 887010c..97701f8 100644 --- a/services/pcp-tgu-ops-ui/src/features/constellation/ConstellationDashboard.jsx +++ b/services/pcp-tgu-ops-ui/src/features/constellation/ConstellationDashboard.jsx @@ -44,7 +44,7 @@ function App({ env: extEnv, domain: extDomain, sched: extSched } = {}) { const liveRef = useRef(false); const focusRef = useRef(null); const hoverRef = useRef(null); - const memRef = useRef(Object.fromEntries(env.sats.map((s) => [s.id, s.mem0]))); + const memRef = useRef(Object.fromEntries(env.sats.map((s) => [s.id, env.memAt ? env.memAt(s, env.epochMs) : s.mem0]))); const lastT = useRef(performance.now()); const [, setTick] = useState(0); @@ -80,17 +80,24 @@ function App({ env: extEnv, domain: extDomain, sched: extSched } = {}) { else simRef.current += dt * speedRef.current; if (simRef.current > domain.end) simRef.current = domain.end; if (simRef.current < domain.start) simRef.current = domain.start; - // memory dynamics - const secEq = (dt * speedRef.current) / 1000; - for (const s of env.sats) { - const stt = SIM.statusAt(s, simRef.current, env); - let m = memRef.current[s.id]; - if (stt.state === "capture") m += 0.7 * secEq; - else if (stt.state === "downlink") m -= 1.5 * secEq; - else m += 0.02 * secEq; // idle housekeeping drift - memRef.current[s.id] = Math.max(0, Math.min(100, m)); + // memory dynamics — только когда нет авторитетного источника заполнения борта + if (!env.memAt) { + const secEq = (dt * speedRef.current) / 1000; + for (const s of env.sats) { + const stt = SIM.statusAt(s, simRef.current, env); + let m = memRef.current[s.id]; + if (stt.state === "capture") m += 0.7 * secEq; + else if (stt.state === "downlink") m -= 1.5 * secEq; + else m += 0.02 * secEq; // idle housekeeping drift + memRef.current[s.id] = Math.max(0, Math.min(100, m)); + } } } + // Реальное заполнение борта (объёмы маршрутов плана) — сэмплируем каждый кадр, + // чтобы значение следовало и за скрабом на паузе. + if (env.memAt) { + for (const s of env.sats) memRef.current[s.id] = env.memAt(s, simRef.current); + } if (apiRef.current.draw) apiRef.current.draw(simRef.current, { focusId: focusRef.current }); acc += dt; if (acc >= 80) { acc = 0; setTick((t) => (t + 1) % 1e6); } @@ -164,6 +171,7 @@ function App({ env: extEnv, domain: extDomain, sched: extSched } = {}) {
+
@@ -206,6 +214,7 @@ function MapLegend() {
ЛОКАЦИЯ
МАРШРУТ СЪЁМКИ
СБРОС НА СТАНЦИЮ
+
СВЯЗЬ С КА
ТЕНЬ ЗЕМЛИ
); diff --git a/services/pcp-tgu-ops-ui/src/features/constellation/MapView.jsx b/services/pcp-tgu-ops-ui/src/features/constellation/MapView.jsx index ec8512d..e8952ef 100644 --- a/services/pcp-tgu-ops-ui/src/features/constellation/MapView.jsx +++ b/services/pcp-tgu-ops-ui/src/features/constellation/MapView.jsx @@ -31,6 +31,7 @@ const MAP_C = { gratText: "rgba(150,180,178,0.25)", amber: "#d99a4e", blue: "#5b9bd5", + link: "#6f8fae", red: "#d9685f", teal: "#5fb3a3", night: "rgba(20,28,46,0.55)", @@ -217,13 +218,32 @@ function MapView({ env, apiRef }) { } strokeTrack(ctx, seg, MAP_C.amber, dim ? 3 : 6, dim ? 0.3 : 0.85); ctx.setLineDash([]); - tracePoly(ctx, s.proj, stt.roi.poly); - ctx.fillStyle = "rgba(217,154,78,0.16)"; ctx.fill(); - ctx.strokeStyle = MAP_C.amber; ctx.lineWidth = 1.4; ctx.stroke(); + // контур съёмки есть не у всех режимов плана — рисуем только когда задан + if (stt.roi && stt.roi.poly && stt.roi.poly.length) { + tracePoly(ctx, s.proj, stt.roi.poly); + ctx.fillStyle = "rgba(217,154,78,0.16)"; ctx.fill(); + ctx.strokeStyle = MAP_C.amber; ctx.lineWidth = 1.4; ctx.stroke(); + } + } + + // link (связь с КА) — пунктир к станции на всём окне радиовидимости (ЗРВ), + // даже когда сброс не идёт. Тоньше/бледнее сброса; реальный сброс ляжет поверх. + if (env.sched) { + const lk = SIM.intervalAt((env.sched[sat.id] || {}).link, tMs); + if (lk && lk.lon != null && lk.lat != null) { + const lp = s.proj([lk.lon, lk.lat]); + if (lp) { + ctx.save(); + ctx.setLineDash([3, 6]); ctx.lineDashOffset = -dash; + ctx.strokeStyle = hexA(MAP_C.link, dim ? 0.22 : 0.55); ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(pt[0], pt[1]); ctx.lineTo(lp[0], lp[1]); ctx.stroke(); + ctx.restore(); + } + } } // downlink (сброс) — animated dashed line to station - if (stt.state === "downlink") { + if (stt.state === "downlink" && stt.station && stt.station.lon != null && stt.station.lat != null) { const sp = s.proj([stt.station.lon, stt.station.lat]); if (sp) { ctx.save(); diff --git a/services/pcp-tgu-ops-ui/src/features/constellation/Timeline.jsx b/services/pcp-tgu-ops-ui/src/features/constellation/Timeline.jsx index c83e318..cbb6ba6 100644 --- a/services/pcp-tgu-ops-ui/src/features/constellation/Timeline.jsx +++ b/services/pcp-tgu-ops-ui/src/features/constellation/Timeline.jsx @@ -14,6 +14,7 @@ const TL_AXIS = 26; const TL_COL = { capture: "#d99a4e", downlink: "#5b9bd5", + link: "rgba(111,143,174,0.38)", // связь с КА (ЗРВ) — фоновое окно радиовидимости prohibit: "#d9685f", shadow: "rgba(120,140,180,0.30)", }; @@ -125,7 +126,9 @@ function Timeline({ env, sched, simTime, domain, focusId, onScrub, onFocus, live {/* shadow (under) */} {(tr.shadow || []).map((iv, k) => band(iv, "sh" + k, TL_COL.shadow, TL_ROW - 8))} - {/* downlink */} + {/* link (связь с КА — окна радиовидимости, фоновая полоса) */} + {(tr.link || []).map((iv, k) => band(iv, "lk" + k, TL_COL.link, TL_ROW - 10))} + {/* downlink (реальный сброс из плана) */} {(tr.downlink || []).map((iv, k) => band(iv, "dl" + k, TL_COL.downlink, 8))} {/* capture */} {(tr.capture || []).map((iv, k) => band(iv, "cp" + k, TL_COL.capture, 10))} diff --git a/services/pcp-tgu-ops-ui/src/features/constellation/realData.ts b/services/pcp-tgu-ops-ui/src/features/constellation/realData.ts index fde7a1b..3558a0e 100644 --- a/services/pcp-tgu-ops-ui/src/features/constellation/realData.ts +++ b/services/pcp-tgu-ops-ui/src/features/constellation/realData.ts @@ -10,19 +10,23 @@ Возвращает { env, domain, sched } в том же виде, что buildEnv() прототипа, так что ConstellationDashboard рендерит реальные данные без изменений разметки. ============================================================ */ -import { fetchPlatforms, fetchFlightLine, type FlightLinePoint } from "../../api/tguApi"; +import { fetchPlatforms, fetchFlightLine, fetchPlans, type FlightLinePoint } from "../../api/tguApi"; import { fetchStations, type StationDto } from "../../api/stationsApi"; import { fetchRequestsForMap } from "../../api/requestApi"; import { fetchSatelliteRva } from "../../api/ballisticsApi"; -import type { TguPlatform } from "../../model/tguTypes"; +import { fetchPlanModes, type PlanMode } from "../tgu-editor/editorApi"; +import type { TguPlatform, TguPlan } from "../../model/tguTypes"; import type { RequestMapItem } from "../../model/requestTypes"; import { isOperationalPlatform } from "../tgu-planning/tguTimelineMapper"; import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser"; import { parseLocal } from "../../utils/localTime"; // sim.js/data.js — JS-модули прототипа (allowJs); типы any, нам достаточно. -import { buildSchedule, wrapLon } from "./sim.js"; +import { buildSchedule, wrapLon, pointInPoly } from "./sim.js"; import { COL } from "./data.js"; +/** Полный объём бортового накопителя, Мбайт (500 ГБ). Согласовано как модельная ёмкость. */ +const ONBOARD_CAPACITY_MB = 500 * 1024; + type Payload = "optic" | "radar"; export interface ConstellationSat { @@ -54,6 +58,20 @@ export interface Interval { start: number; end: number; label?: string; + /** Контур съёмки (для подсветки на карте), [lon,lat][]. Только у интервалов capture. */ + poly?: [number, number][]; + /** Координаты станции приёма (для линии сброса на карте). Только у интервалов downlink. */ + lon?: number; + lat?: number; +} + +export interface SchedTracks { + capture: Interval[]; + downlink: Interval[]; + /** Связь с КА — окна радиовидимости (ЗРВ). Раньше показывались как «сброс». */ + link: Interval[]; + prohibit: Interval[]; + shadow: Interval[]; } export interface ConstellationEnv { @@ -64,12 +82,16 @@ export interface ConstellationEnv { prohibits: Record; /** Подспутниковая точка по реальной трассе; null — нет данных на это время. */ subSat: (sat: ConstellationSat, tMs: number) => { lon: number; lat: number } | null; + /** Готовое расписание (съёмка/сброс/связь/тень) — из него statusAt берёт состояние. */ + sched?: Record; + /** Заполнение бортового накопителя на момент tMs, % от ёмкости. Из объёмов маршрутов плана. */ + memAt?: (sat: ConstellationSat, tMs: number) => number; } export interface ConstellationConfig { env: ConstellationEnv; domain: { start: number; end: number }; - sched: Record; + sched: Record; } // --- классификация полезной нагрузки по имени КА (из НСИ) ------------------- @@ -164,6 +186,115 @@ function compareSats(a: ConstellationSat, b: ConstellationSat): number { return a.name.localeCompare(b.name, "ru"); } +// --- съёмка/сброс из реального плана ----------------------------------------- +type StationGeo = { name: string; lon: number; lat: number }; + +interface PlanTracks { + capture: Interval[]; + downlink: Interval[]; + /** Точки изменения заполнения борта: +volumeMb на конце съёмки, −volumeMb на конце сброса. */ + memEvents: { t: number; delta: number }[]; +} + +// Историю тянем так, чтобы покрыть начало горизонта (now-6ч): дни назад + запас. +function planHistoryDays(domainStart: number): number { + const back = Math.ceil(Math.max(0, Date.now() - domainStart) / 86400e3); + return Math.max(1, back + 1); +} + +// Имя заявки, над которой идёт съёмка (по центру контура) — для подписи на таймлайне/карте. +function captureLabel(lon: number, lat: number, rois: ConstellationRoi[]): string { + for (const r of rois) if (pointInPoly(lon, lat, r.poly)) return r.name; + return "Съёмка"; +} + +// Режимы плана (SURVEY/DROP) → интервалы съёмки/сброса + дельты заполнения борта. +function buildPlanTracks( + modes: PlanMode[], + rois: ConstellationRoi[], + stationByNumber: Map +): PlanTracks { + const capture: Interval[] = []; + const downlink: Interval[] = []; + const memEvents: { t: number; delta: number }[] = []; + + for (const m of modes) { + const start = m.timeStart ? parseLocal(m.timeStart) : NaN; + if (!Number.isFinite(start)) continue; + const end = start + (m.duration ?? 0) * 1000; + const vol = m.volumeMb ?? 0; + if (m.type === "SURVEY") { + const rings = m.contourWkt ? parseWktPolygon(m.contourWkt) : []; + const poly = rings[0]?.map((pt) => [wrapLon(pt.lon), pt.lat] as [number, number]); + capture.push({ start, end, label: captureLabel(m.longitude, m.lat, rois), poly }); + memEvents.push({ t: end, delta: vol }); + } else if (m.type === "DROP") { + const st = m.station != null ? stationByNumber.get(Number(m.station)) : undefined; + downlink.push({ + start, + end, + label: st?.name ?? (m.station != null ? `ст. ${m.station}` : "Сброс"), + lon: st?.lon, + lat: st?.lat + }); + memEvents.push({ t: end, delta: -vol }); + } + } + + capture.sort((a, b) => a.start - b.start); + downlink.sort((a, b) => a.start - b.start); + memEvents.sort((a, b) => a.t - b.t); + return { capture, downlink, memEvents }; +} + +// Планы из pcp-tgu, пересекающие горизонт, → их режимы по КА (spacecraftId == NORAD). +async function loadPlanModesBySat( + satIds: Set, + domainStart: number, + domainEnd: number +): Promise> { + const byId = new Map(); + let plans: TguPlan[]; + try { + plans = await fetchPlans(planHistoryDays(domainStart)); + } catch { + return byId; // нет планов — съёмка/сброс просто не покажутся + } + const relevant = plans.filter((p) => { + if (!satIds.has(p.spacecraftId)) return false; + const s = parseLocal(p.startTime); + const e = parseLocal(p.endTime); + return Number.isFinite(s) && Number.isFinite(e) && e >= domainStart && s <= domainEnd; + }); + const loaded = await Promise.all( + relevant.map((p) => + fetchPlanModes(p.planId) + .then((m) => ({ sid: p.spacecraftId, m })) + .catch(() => ({ sid: p.spacecraftId, m: [] as PlanMode[] })) + ) + ); + for (const { sid, m } of loaded) { + const arr = byId.get(sid) ?? []; + arr.push(...m); + byId.set(sid, arr); + } + return byId; +} + +// Заполнение борта на момент t: накопленный объём маршрутов (старт 0), % от ёмкости. +function memorySampler(memEvents: { t: number; delta: number }[]): (t: number) => number { + if (memEvents.length === 0) return () => 0; + return (t: number) => { + let mb = 0; + for (const e of memEvents) { + if (e.t > t) break; + mb += e.delta; + } + const pct = (Math.max(0, Math.min(ONBOARD_CAPACITY_MB, mb)) / ONBOARD_CAPACITY_MB) * 100; + return pct; + }; +} + /** * Грузит реальный состав и геометрию группировки и собирает конфиг дашборда. * Горизонт по умолчанию now-6ч … now+18ч (как в прототипе). КА без трассы @@ -187,8 +318,11 @@ export async function loadConstellationEnv(): Promise { lon: s.position.long, lat: s.position.lat })); - const stationNameByNumber = new Map( - (stationDtos as StationDto[]).map((s) => [s.number, s.name]) + const stationByNumber = new Map( + (stationDtos as StationDto[]).map((s) => [ + s.number, + { name: s.name, lon: s.position.long, lat: s.position.lat } + ]) ); const rois: ConstellationRoi[] = requestItems @@ -237,6 +371,7 @@ export async function loadConstellationEnv(): Promise { sats.sort(compareSats); + // Запреты работы (ВНЗ/питание) — источника в проекте нет: оставляем пустыми (заготовка). const prohibits: Record = Object.fromEntries(sats.map((s) => [s.id, []])); const env: ConstellationEnv = { @@ -248,23 +383,42 @@ export async function loadConstellationEnv(): Promise { subSat: (sat, t) => (samplers[sat.id] ? samplers[sat.id](t) : null) }; - // Съёмка (по пролёту над ROI), тень и геометрический сброс — из реальных позиций. - // Шаг 0.5 мин, чтобы не проскочить мелкие полигоны заявок. + // Съёмку и сброс берём из реального плана; тень считаем геометрически по трассе. + const satIds = new Set(sats.map((s) => s.id)); + const planModesBySat = await loadPlanModesBySat(satIds, domain.start, domain.end); + + // buildSchedule даёт нам тень (shadow) по реальным позициям; съёмку/сброс из него игнорируем. const sched = buildSchedule(sats, env, domain.start, domain.end, 0.5) as ConstellationConfig["sched"]; - // Сброс заменяем авторитетными зонами радиовидимости, где они есть. + const memSamplers: Record number> = {}; for (const s of sats) { + const tracks = sched[s.id]; + // Съёмка и сброс — из режимов плана (SURVEY/DROP). + const plan = buildPlanTracks(planModesBySat.get(s.id) ?? [], rois, stationByNumber); + tracks.capture = plan.capture; + tracks.downlink = plan.downlink; + memSamplers[s.id] = memorySampler(plan.memEvents); + + // «Связь с КА» — окна радиовидимости (ЗРВ). Раньше показывались как «сброс». const rva = rvaBySat[s.id]; - if (!rva || rva.length === 0) continue; - sched[s.id].downlink = rva - .map((z) => ({ - start: parseLocal(z.onStart.time), - end: parseLocal(z.onStop.time), - label: stationNameByNumber.get(z.stationId) ?? `ст. ${z.stationId}` - })) + tracks.link = (rva ?? []) + .map((z) => { + const st = stationByNumber.get(z.stationId); + return { + start: parseLocal(z.onStart.time), + end: parseLocal(z.onStop.time), + label: st?.name ?? `ст. ${z.stationId}`, + lon: st?.lon, + lat: st?.lat + }; + }) .filter((iv) => Number.isFinite(iv.start) && Number.isFinite(iv.end) && iv.end > iv.start) .sort((a, b) => a.start - b.start); } + // Расписание и заполнение борта — на env, чтобы statusAt/панель читали авторитетные данные. + env.sched = sched; + env.memAt = (sat, t) => (memSamplers[sat.id] ? memSamplers[sat.id](t) : 0); + return { env, domain, sched }; } diff --git a/services/pcp-tgu-ops-ui/src/features/constellation/sim.js b/services/pcp-tgu-ops-ui/src/features/constellation/sim.js index f35a7d5..c247507 100644 --- a/services/pcp-tgu-ops-ui/src/features/constellation/sim.js +++ b/services/pcp-tgu-ops-ui/src/features/constellation/sim.js @@ -109,12 +109,31 @@ const D2R = Math.PI / 180, R2D = 180 / Math.PI; return env.subSat ? env.subSat(sat, tMs) : subSat(sat, tMs, env.epochMs); } + // interval containing t in a sorted/unsorted list, else null + function intervalAt(list, tMs) { + if (!list) return null; + for (const iv of list) if (tMs >= iv.start && tMs < iv.end) return iv; + return null; + } + // live status of a sat at time t (priority order) // returns {state, roi, station, eclipse, prohibit} + // С реальным расписанием (env.sched) состояние берём из готовых интервалов плана + // (съёмка/сброс/запрет); иначе — встроенная геометрическая симуляция прототипа. function statusAt(sat, tMs, env) { const p = subSatOf(sat, tMs, env); - const prohibit = inProhibit(env.prohibits[sat.id], tMs); const eclipse = inShadow(p.lon, p.lat, tMs); + if (env.sched) { + const tr = env.sched[sat.id] || {}; + const prohibit = intervalAt(tr.prohibit, tMs); + if (prohibit) return { state: "prohibit", p, prohibit: { reason: prohibit.label }, eclipse }; + const cap = intervalAt(tr.capture, tMs); + if (cap) return { state: "capture", p, roi: { name: cap.label, poly: cap.poly }, eclipse }; + const dl = intervalAt(tr.downlink, tMs); + if (dl) return { state: "downlink", p, station: { name: dl.label, lon: dl.lon, lat: dl.lat }, eclipse }; + return { state: "operational", p, eclipse }; + } + const prohibit = inProhibit(env.prohibits[sat.id], tMs); if (prohibit) return { state: "prohibit", p, prohibit, eclipse }; const roi = overROI(sat, p, env.rois); if (roi) return { state: "capture", p, roi, eclipse }; @@ -160,5 +179,5 @@ const D2R = Math.PI / 180, R2D = 180 / Math.PI; export { D2R, R2D, wrapLon, subSat, subSolar, angDist, inShadow, pointInPoly, overROI, nearStation, buildProhibits, inProhibit, - statusAt, buildSchedule, STATION_RANGE, + statusAt, intervalAt, buildSchedule, STATION_RANGE, };