feat(tgu-ops-ui): display real ground track and swath from ballistics service
Replace synthetic orbit calculation (Kepler + spacecraft ID hash) with real
data from pcp-ballistics-service via a new proxy endpoint in pcp-ui-service.
Backend:
- TguPlanningController: add GET /api/tgu-planning/spacecraft/{noradId}/flight-line
that proxies FlightLineDTO[] from BallisticsService for a given time interval
Frontend:
- Fetch FlightLineDTO[] (revolution, time, lat/long, swath boundaries) using
the spacecraft's noradId for the current 24h map window
- Group points by real revolution number to build OrbitPassInfo[] with actual
orbit numbers and UTC times instead of synthetic Kepler-based estimates
- Build ground track MapLines and swath MapPolygons from real coordinates;
swath polygon = outer-left edge forward + outer-right edge reversed
- Enable swath layer in the editor map; draw with teal fill/stroke matching
the track colour
- MapPassSelector now shows real revolution numbers in header and handle labels
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
import { useEffect, useMemo, useRef, useState, useCallback, type MutableRefObject } from "react";
|
||||
import { fetchRequestsForMap } from "../../api/requestApi";
|
||||
import { fetchFlightLine, type FlightLinePoint } from "../../api/tguApi";
|
||||
import type { RequestMapItem } from "../../model/requestTypes";
|
||||
import { groundTrack, stationPasses, targetPasses } from "../tgu-map-2d/geometry/passes";
|
||||
import { stationPasses, targetPasses, type OrbitPassInfo, type PassRange } from "../tgu-map-2d/geometry/passes";
|
||||
import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
|
||||
import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser";
|
||||
import type { Tgu2DMapLayersState } from "../tgu-map-2d/model/mapLayerTypes";
|
||||
import type { MapPolygon, Tgu2DMapScene } from "../tgu-map-2d/model/mapTypes";
|
||||
import type { MapLine, MapPolygon, Tgu2DMapScene } from "../tgu-map-2d/model/mapTypes";
|
||||
import { fetchPlanModes, saveEditedPlan, type PlanMode } from "./editorApi";
|
||||
import {
|
||||
addDownlink,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
import { checkVisibility, conflictCount, mergeConflictMaps, overlaps, type EditorConflictMap } from "./editorConflicts";
|
||||
import { DEFAULT_EDITOR_MODES, formatDuration } from "./editorPresentation";
|
||||
import { DownlinkPanel, type DownlinkSelection, type DownlinkStationOption, ShootPanel, WorkInspector } from "./EditorPanels";
|
||||
import { MapPassSelector } from "./MapPassSelector";
|
||||
import { EditorRail } from "./EditorRail";
|
||||
import { EditorTimeline } from "./EditorTimeline";
|
||||
import { EditorToolbar } from "./EditorToolbar";
|
||||
@@ -42,7 +44,7 @@ type EditorRightMode = "none" | "inspect" | "shoot" | "downlink";
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
const EDITOR_MAP_LAYERS: Tgu2DMapLayersState = {
|
||||
tracks: true,
|
||||
swath: false,
|
||||
swath: true,
|
||||
planWorks: true,
|
||||
stations: true,
|
||||
spacecraftMarkers: false,
|
||||
@@ -74,8 +76,37 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
||||
const [contextSpacecraftIds, setContextSpacecraftIds] = useState<Set<string>>(new Set());
|
||||
const [requestItems, setRequestItems] = useState<RequestMapItem[]>([]);
|
||||
const [planModes, setPlanModes] = useState<PlanMode[]>([]);
|
||||
const [passFrom, setPassFrom] = useState(1);
|
||||
const [passTo, setPassTo] = useState(999);
|
||||
const [flightLineData, setFlightLineData] = useState<FlightLinePoint[]>([]);
|
||||
const draft = history.draft;
|
||||
const stations = useMemo(() => buildStations(selectedPlan), [selectedPlan]);
|
||||
const mapRange = useMemo<PassRange>(() => {
|
||||
const center = (window.fromMs + window.toMs) / 2;
|
||||
return { fromMs: center - MAP_TRACK_WINDOW_MS / 2, toMs: center + MAP_TRACK_WINDOW_MS / 2 };
|
||||
}, [window.fromMs, window.toMs]);
|
||||
const noradId = useMemo(() => {
|
||||
if (!selectedSpacecraftId) return undefined;
|
||||
const platform = platforms.find((p) => p.spacecraftId === selectedSpacecraftId);
|
||||
return platform?.noradId ?? undefined;
|
||||
}, [selectedSpacecraftId, platforms]);
|
||||
const orbitPasses = useMemo<OrbitPassInfo[]>(() => {
|
||||
if (flightLineData.length === 0) return [];
|
||||
const groups = new Map<number, FlightLinePoint[]>();
|
||||
for (const pt of flightLineData) {
|
||||
const arr = groups.get(pt.revolution) ?? [];
|
||||
arr.push(pt);
|
||||
groups.set(pt.revolution, arr);
|
||||
}
|
||||
return Array.from(groups.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([revolution, pts], idx) => ({
|
||||
index: idx + 1,
|
||||
revolution,
|
||||
startMs: Date.parse(pts[0].time),
|
||||
endMs: Date.parse(pts[pts.length - 1].time),
|
||||
}));
|
||||
}, [flightLineData]);
|
||||
const modes: EditorMode[] = DEFAULT_EDITOR_MODES;
|
||||
const liveConflicts = useMemo(() => overlaps(draft.works), [draft.works]);
|
||||
const conflicts = useMemo(() => mergeConflictMaps(liveConflicts, serviceConflicts), [liveConflicts, serviceConflicts]);
|
||||
@@ -93,9 +124,11 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
||||
() => buildVisibilityTracks(target, shootPasses, selectedWork, stations, selectedSpacecraftId, window),
|
||||
[selectedSpacecraftId, selectedWork, shootPasses, stations, target, window]
|
||||
);
|
||||
const effectiveFrom = Math.max(1, Math.min(passFrom, orbitPasses.length || 1));
|
||||
const effectiveTo = Math.max(effectiveFrom, Math.min(passTo, orbitPasses.length || 1));
|
||||
const mapScene = useMemo(
|
||||
() => buildEditorMapScene(selectedSpacecraftId, window, stations, requestItems, planModes),
|
||||
[selectedSpacecraftId, stations, window, requestItems, planModes]
|
||||
() => buildEditorMapScene(selectedSpacecraftId, flightLineData, orbitPasses, stations, requestItems, planModes, effectiveFrom, effectiveTo),
|
||||
[selectedSpacecraftId, flightLineData, orbitPasses, stations, requestItems, planModes, effectiveFrom, effectiveTo]
|
||||
);
|
||||
const shootTargets = useMemo(
|
||||
() =>
|
||||
@@ -128,8 +161,20 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
||||
setNotice(undefined);
|
||||
setApplyError(undefined);
|
||||
setPlanModes([]);
|
||||
setPassFrom(1);
|
||||
setPassTo(999);
|
||||
setFlightLineData([]);
|
||||
}, [appliedRange, selectedPlan, selectedSpacecraftId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!noradId) return;
|
||||
let cancelled = false;
|
||||
void fetchFlightLine(noradId, mapRange.fromMs, mapRange.toMs)
|
||||
.then((data) => { if (!cancelled) setFlightLineData(data); })
|
||||
.catch(() => { if (!cancelled) setFlightLineData([]); });
|
||||
return () => { cancelled = true; };
|
||||
}, [noradId, mapRange]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void fetchRequestsForMap({
|
||||
@@ -347,6 +392,15 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
||||
>
|
||||
<div className="tgu-editor-map-stage">
|
||||
{rightMode === "shoot" && <div className="tgu-map-overlay">Кликните точку съёмки на карте.</div>}
|
||||
{orbitPasses.length > 0 && (
|
||||
<MapPassSelector
|
||||
passes={orbitPasses}
|
||||
fromIndex={effectiveFrom}
|
||||
toIndex={effectiveTo}
|
||||
onFromChange={setPassFrom}
|
||||
onToChange={setPassTo}
|
||||
/>
|
||||
)}
|
||||
<Tgu2DMapView
|
||||
scene={mapScene}
|
||||
layers={EDITOR_MAP_LAYERS}
|
||||
@@ -550,44 +604,57 @@ function buildVisibilityTracks(
|
||||
}
|
||||
|
||||
const MAP_TRACK_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
const MAP_TRACK_POINTS = 720; // 2-minute step over 24h → ~45 points per LEO orbit
|
||||
|
||||
function buildEditorMapScene(
|
||||
spacecraftId: string | undefined,
|
||||
window: EditorTimelineWindow,
|
||||
flightLineData: FlightLinePoint[],
|
||||
orbitPasses: OrbitPassInfo[],
|
||||
stations: EditorGroundStation[],
|
||||
requestItems: RequestMapItem[],
|
||||
planModes: PlanMode[]
|
||||
planModes: PlanMode[],
|
||||
fromIndex: number,
|
||||
toIndex: number
|
||||
): Tgu2DMapScene {
|
||||
const center = (window.fromMs + window.toMs) / 2;
|
||||
const mapRange = { fromMs: center - MAP_TRACK_WINDOW_MS / 2, toMs: center + MAP_TRACK_WINDOW_MS / 2 };
|
||||
const visibleRevolutions = new Set(
|
||||
orbitPasses.filter((p) => p.index >= fromIndex && p.index <= toIndex).map((p) => p.revolution!)
|
||||
);
|
||||
|
||||
const lines = spacecraftId
|
||||
? groundTrack({ spacecraftId }, mapRange, MAP_TRACK_POINTS).map((points, index) => ({
|
||||
id: `${spacecraftId}:track:${index}`,
|
||||
layer: "tracks" as const,
|
||||
points,
|
||||
spacecraftId,
|
||||
zIndex: index
|
||||
}))
|
||||
: [];
|
||||
|
||||
const markers = stations.flatMap((station) => {
|
||||
if (station.lat === undefined || station.lon === undefined) {
|
||||
return [];
|
||||
}
|
||||
return [{
|
||||
id: station.id,
|
||||
layer: "stations" as const,
|
||||
point: { lat: station.lat, lon: station.lon },
|
||||
label: station.name,
|
||||
zIndex: 10
|
||||
}];
|
||||
});
|
||||
const byRevolution = new Map<number, FlightLinePoint[]>();
|
||||
for (const pt of flightLineData) {
|
||||
if (!visibleRevolutions.has(pt.revolution)) continue;
|
||||
const arr = byRevolution.get(pt.revolution) ?? [];
|
||||
arr.push(pt);
|
||||
byRevolution.set(pt.revolution, arr);
|
||||
}
|
||||
|
||||
const lines: MapLine[] = [];
|
||||
const polygons: MapPolygon[] = [];
|
||||
let zIndex = 0;
|
||||
|
||||
for (const [revolution, pts] of byRevolution) {
|
||||
const passIdx = orbitPasses.find((p) => p.revolution === revolution)?.index ?? 0;
|
||||
|
||||
lines.push({
|
||||
id: `${spacecraftId}:track:${revolution}`,
|
||||
layer: "tracks",
|
||||
points: pts.map((p) => ({ lat: p.lat, lon: p.long })),
|
||||
spacecraftId,
|
||||
passIndex: passIdx,
|
||||
zIndex: zIndex++
|
||||
});
|
||||
|
||||
const leftEdge = pts.map((p) => ({ lat: p.latLeft, lon: p.longLeft }));
|
||||
const rightEdge = [...pts].reverse().map((p) => ({ lat: p.latRight, lon: p.longRight }));
|
||||
polygons.push({
|
||||
id: `${spacecraftId}:swath:${revolution}`,
|
||||
name: `Полоса обзора витка ${revolution}`,
|
||||
kind: "swath",
|
||||
layer: "swath",
|
||||
points: [...leftEdge, ...rightEdge],
|
||||
zIndex: zIndex++
|
||||
});
|
||||
}
|
||||
|
||||
for (const mode of planModes) {
|
||||
if (mode.type !== "SURVEY" || !mode.contourWkt) continue;
|
||||
const rings = parseWktPolygon(mode.contourWkt);
|
||||
@@ -622,6 +689,17 @@ function buildEditorMapScene(
|
||||
}
|
||||
}
|
||||
|
||||
const markers = stations.flatMap((station) => {
|
||||
if (station.lat === undefined || station.lon === undefined) return [];
|
||||
return [{
|
||||
id: station.id,
|
||||
layer: "stations" as const,
|
||||
point: { lat: station.lat, lon: station.lon },
|
||||
label: station.name,
|
||||
zIndex: 10
|
||||
}];
|
||||
});
|
||||
|
||||
return { polygons, lines, markers };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user