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