feat(группировка): реальная съёмка/сброс/связь и заполнение борта

Дашборд «Группировка» наполняется из плана: режимы SURVEY/DROP → интервалы
съёмки и сброса с контуром и станцией, окна ЗРВ выделены в отдельную «связь»
(раньше показывались как сброс). Заполнение бортового накопителя считается
из объёмов маршрутов (volumeMb) с модельной ёмкостью 500 ГБ
(ONBOARD_CAPACITY_MB) — memAt по таймлайну, подсветка контура/линии сброса
на карте.
This commit is contained in:
Дмитрий Соловьев
2026-06-08 14:00:03 +03:00
parent 2c8b317c42
commit 5fa3ceaac9
5 changed files with 238 additions and 33 deletions
@@ -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 } = {}) {
<div className="tl-legend">
<LegItem c="#d99a4e" t="СЪЁМКА" />
<LegItem c="#5b9bd5" t="СБРОС" />
<LegItem c="rgba(111,143,174,0.6)" t="СВЯЗЬ С КА" />
<LegItem c="#d9685f" t="ЗАПРЕТ РАБОТЫ" hatch />
<LegItem c="rgba(120,140,180,0.5)" t="ТЕНЬ ЗЕМЛИ" />
</div>
@@ -206,6 +214,7 @@ function MapLegend() {
<div className="ml-row"><span className="ml-sw" style={{ background: COL.radar }} />ЛОКАЦИЯ</div>
<div className="ml-row"><span className="ml-sw amber" />МАРШРУТ СЪЁМКИ</div>
<div className="ml-row"><span className="ml-sw blue dash" />СБРОС НА СТАНЦИЮ</div>
<div className="ml-row"><span className="ml-sw dash" style={{ background: "#6f8fae" }} />СВЯЗЬ С КА</div>
<div className="ml-row"><span className="ml-sw night" />ТЕНЬ ЗЕМЛИ</div>
</div>
);
@@ -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();
@@ -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
<line x1={TL_GUTTER} y1={cy} x2={w} y2={cy} stroke="rgba(255,255,255,0.04)" />
{/* 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))}
@@ -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<string, Interval[]>;
/** Подспутниковая точка по реальной трассе; null — нет данных на это время. */
subSat: (sat: ConstellationSat, tMs: number) => { lon: number; lat: number } | null;
/** Готовое расписание (съёмка/сброс/связь/тень) — из него statusAt берёт состояние. */
sched?: Record<string, SchedTracks>;
/** Заполнение бортового накопителя на момент tMs, % от ёмкости. Из объёмов маршрутов плана. */
memAt?: (sat: ConstellationSat, tMs: number) => number;
}
export interface ConstellationConfig {
env: ConstellationEnv;
domain: { start: number; end: number };
sched: Record<string, { capture: Interval[]; downlink: Interval[]; prohibit: Interval[]; shadow: Interval[] }>;
sched: Record<string, SchedTracks>;
}
// --- классификация полезной нагрузки по имени КА (из НСИ) -------------------
@@ -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<number, StationGeo>
): 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<string>,
domainStart: number,
domainEnd: number
): Promise<Map<string, PlanMode[]>> {
const byId = new Map<string, PlanMode[]>();
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<ConstellationConfig> {
lon: s.position.long,
lat: s.position.lat
}));
const stationNameByNumber = new Map<number, string>(
(stationDtos as StationDto[]).map((s) => [s.number, s.name])
const stationByNumber = new Map<number, StationGeo>(
(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<ConstellationConfig> {
sats.sort(compareSats);
// Запреты работы (ВНЗ/питание) — источника в проекте нет: оставляем пустыми (заготовка).
const prohibits: Record<string, Interval[]> = Object.fromEntries(sats.map((s) => [s.id, []]));
const env: ConstellationEnv = {
@@ -248,23 +383,42 @@ export async function loadConstellationEnv(): Promise<ConstellationConfig> {
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<string, (t: number) => 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 };
}
@@ -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,
};