From 4ab765b0ee1539aa19f2623cba14063abedf6981 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: Sun, 31 May 2026 16:05:41 +0300 Subject: [PATCH] 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 --- services/pcp-tgu-ops-ui/src/api/tguApi.ts | 31 ++++ .../features/tgu-editor/MapPassSelector.tsx | 141 +++++++++++++++++ .../src/features/tgu-editor/TguEditorTab.tsx | 142 ++++++++++++++---- .../features/tgu-map-2d/canvas/drawSwath.ts | 23 ++- .../features/tgu-map-2d/geometry/passes.ts | 25 +++ .../src/features/tgu-map-2d/model/mapTypes.ts | 1 + .../controller/TguPlanningController.kt | 12 +- 7 files changed, 339 insertions(+), 36 deletions(-) create mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-editor/MapPassSelector.tsx diff --git a/services/pcp-tgu-ops-ui/src/api/tguApi.ts b/services/pcp-tgu-ops-ui/src/api/tguApi.ts index 10575e2..149ad5e 100644 --- a/services/pcp-tgu-ops-ui/src/api/tguApi.ts +++ b/services/pcp-tgu-ops-ui/src/api/tguApi.ts @@ -6,6 +6,37 @@ import { type TguPlatform } from "../model/tguTypes"; +export type FlightLinePoint = { + time: string; + revolution: number; + lat: number; + long: number; + latLeft: number; + longLeft: number; + latInnerLeft: number; + longInnerLeft: number; + latInnerRight: number; + longInnerRight: number; + latRight: number; + longRight: number; + revSign: "ASC" | "DESC"; +}; + +export async function fetchFlightLine( + noradId: string, + fromMs: number, + toMs: number +): Promise { + const toLocalDT = (ms: number) => new Date(ms).toISOString().slice(0, 19); + const query = new URLSearchParams({ + time_start: toLocalDT(fromMs), + time_stop: toLocalDT(toMs) + }); + return fetchJson( + `/api/tgu-planning/spacecraft/${encodeURIComponent(noradId)}/flight-line?${query}` + ); +} + export async function fetchPlatforms(): Promise { return fetchJson("/api/tgu-planning/platforms"); } diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/MapPassSelector.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/MapPassSelector.tsx new file mode 100644 index 0000000..eeb061f --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/MapPassSelector.tsx @@ -0,0 +1,141 @@ +import { useRef } from "react"; +import type { OrbitPassInfo } from "../tgu-map-2d/geometry/passes"; + +type MapPassSelectorProps = { + passes: OrbitPassInfo[]; + fromIndex: number; + toIndex: number; + onFromChange: (idx: number) => void; + onToChange: (idx: number) => void; +}; + +function utcHM(ms: number): string { + const d = new Date(ms); + return `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}`; +} + +function utcDM(ms: number): string { + const d = new Date(ms); + return `${String(d.getUTCDate()).padStart(2, "0")}.${String(d.getUTCMonth() + 1).padStart(2, "0")}`; +} + +export function MapPassSelector({ passes, fromIndex, toIndex, onFromChange, onToChange }: MapPassSelectorProps) { + const trackRef = useRef(null); + const n = passes.length; + if (n === 0) return null; + + const fromPass = passes[Math.max(0, Math.min(fromIndex - 1, n - 1))]; + const toPass = passes[Math.max(0, Math.min(toIndex - 1, n - 1))]; + + const multiDay = utcDM(passes[0].startMs) !== utcDM(passes[n - 1].startMs); + const isFullRange = fromIndex === 1 && toIndex === n; + const fromRev = fromPass.revolution ?? fromIndex; + const toRev = toPass.revolution ?? toIndex; + + const pct = (idx: number): number => (n <= 1 ? 0 : ((idx - 1) / (n - 1)) * 100); + const fromPct = pct(fromIndex); + const toPct = pct(toIndex); + + const indexAt = (clientX: number): number => { + const rect = trackRef.current?.getBoundingClientRect(); + if (!rect || n <= 1) return 1; + return Math.round(Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)) * (n - 1)) + 1; + }; + + const makeHandlers = (isFrom: boolean) => ({ + onPointerDown(e: React.PointerEvent) { + e.stopPropagation(); + (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); + }, + onPointerMove(e: React.PointerEvent) { + if (!(e.currentTarget as HTMLDivElement).hasPointerCapture(e.pointerId)) return; + const idx = indexAt(e.clientX); + if (isFrom) { + onFromChange(Math.max(1, Math.min(idx, toIndex))); + } else { + onToChange(Math.min(n, Math.max(idx, fromIndex))); + } + }, + onPointerUp(e: React.PointerEvent) { + (e.currentTarget as HTMLDivElement).releasePointerCapture(e.pointerId); + }, + }); + + const onTrackPointerDown = (e: React.PointerEvent) => { + const idx = indexAt(e.clientX); + if (Math.abs(idx - fromIndex) <= Math.abs(idx - toIndex)) { + onFromChange(Math.max(1, Math.min(idx, toIndex))); + } else { + onToChange(Math.min(n, Math.max(idx, fromIndex))); + } + }; + + return ( +
+
+ Витки + {fromRev}–{toRev} + + {multiDay ? `${utcDM(fromPass.startMs)} ` : ""}{utcHM(fromPass.startMs)} + {" – "} + {multiDay ? `${utcDM(toPass.startMs)} ` : ""}{utcHM(toPass.startMs)} + {" UTC"} + + {!isFullRange && ( + + )} +
+ +
+ {/* Track line */} +
+
+ {/* Tick marks */} + {passes.map((p) => ( +
= fromIndex && p.index <= toIndex ? " is-active" : ""}`} + style={{ left: `${pct(p.index)}%` }} + /> + ))} + {/* From handle */} +
+ {/* To handle */} +
+
+ + {/* Labels below handles */} +
+
+ {fromRev} + {multiDay && `${utcDM(fromPass.startMs)} `}{utcHM(fromPass.startMs)} +
+ {toIndex !== fromIndex && ( +
+ {toRev} + {multiDay && `${utcDM(toPass.startMs)} `}{utcHM(toPass.startMs)} +
+ )} +
+
+
+ ); +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx index 0b6cd55..c518e0b 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx @@ -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>(new Set()); const [requestItems, setRequestItems] = useState([]); const [planModes, setPlanModes] = useState([]); + const [passFrom, setPassFrom] = useState(1); + const [passTo, setPassTo] = useState(999); + const [flightLineData, setFlightLineData] = useState([]); const draft = history.draft; const stations = useMemo(() => buildStations(selectedPlan), [selectedPlan]); + const mapRange = useMemo(() => { + 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(() => { + if (flightLineData.length === 0) return []; + const groups = new Map(); + 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, >
{rightMode === "shoot" &&
Кликните точку съёмки на карте.
} + {orbitPasses.length > 0 && ( + + )} 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(); + 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 }; } diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawSwath.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawSwath.ts index 90df02e..2a5125a 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawSwath.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawSwath.ts @@ -1,6 +1,5 @@ import type { MapProjection } from "../geometry/mapProjection"; import type { MapPolygon } from "../model/mapTypes"; -import { drawPlanWorks } from "./drawPlanWorks"; export function drawSwath( ctx: CanvasRenderingContext2D, @@ -8,6 +7,24 @@ export function drawSwath( projection: MapProjection, viewport: { width: number; height: number } ) { - drawPlanWorks(ctx, polygons, projection, viewport); + ctx.save(); + ctx.fillStyle = "rgba(104, 195, 189, 0.10)"; + ctx.strokeStyle = "rgba(104, 195, 189, 0.40)"; + ctx.lineWidth = 1; + for (const polygon of polygons) { + if (polygon.points.length < 3) continue; + const screenPoints = polygon.points.map((point) => projection.project(point)); + if (!screenPoints.some((p) => p.x >= -32 && p.x <= viewport.width + 32 && p.y >= -32 && p.y <= viewport.height + 32)) { + continue; + } + ctx.beginPath(); + screenPoints.forEach((p, i) => { + if (i === 0) ctx.moveTo(p.x, p.y); + else ctx.lineTo(p.x, p.y); + }); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + } + ctx.restore(); } - diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/passes.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/passes.ts index 17deb6e..733bc17 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/passes.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/passes.ts @@ -71,6 +71,31 @@ export function nearestStation(point: GroundPoint, stations: GroundStationPoint[ return bestStation; } +export type OrbitPassInfo = { + index: number; + startMs: number; + endMs: number; + revolution?: number; +}; + +export function orbitPeriodMs(orbit: PassOrbit): number { + return orbitParams(orbit).periodSeconds * 1000; +} + +export function orbitPassList(orbit: PassOrbit, range: PassRange): OrbitPassInfo[] { + if (range.toMs <= range.fromMs) return []; + const periodMs = orbitPeriodMs(orbit); + const passes: OrbitPassInfo[] = []; + let t = range.fromMs; + let index = 1; + while (t < range.toMs) { + passes.push({ index, startMs: t, endMs: Math.min(t + periodMs, range.toMs) }); + t += periodMs; + index++; + } + return passes; +} + export function groundTrack(orbit: PassOrbit, range: PassRange, maxPoints = 240): GroundPoint[][] { if (range.toMs <= range.fromMs) { return []; diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapTypes.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapTypes.ts index a97dc05..a4c6c85 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapTypes.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapTypes.ts @@ -40,6 +40,7 @@ export type MapLine = { layer: "tracks"; points: GeoPoint[]; spacecraftId?: string; + passIndex?: number; zIndex: number; }; diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/TguPlanningController.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/TguPlanningController.kt index ee7c0fb..491903f 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/TguPlanningController.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/TguPlanningController.kt @@ -8,7 +8,9 @@ import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController import space.nstart.pcp.slots_service.dto.tgu.TguPlanDecision +import space.nstart.pcp.slots_service.service.BallisticsService import space.nstart.pcp.slots_service.service.TguPlanningService +import java.time.LocalDateTime import java.util.UUID @Controller @@ -21,7 +23,8 @@ class TguPlanningPageController { @RestController @RequestMapping("/api/tgu-planning") class TguPlanningRestController( - private val tguPlanningService: TguPlanningService + private val tguPlanningService: TguPlanningService, + private val ballisticsService: BallisticsService ) { @GetMapping("/platforms") @@ -43,4 +46,11 @@ class TguPlanningRestController( @RequestParam decision: TguPlanDecision, @RequestParam(required = false) reason: String? ) = tguPlanningService.sendDecision(planId, decision, reason) + + @GetMapping("/spacecraft/{noradId}/flight-line") + fun spacecraftFlightLine( + @PathVariable noradId: Long, + @RequestParam("time_start", required = false) timeStart: LocalDateTime?, + @RequestParam("time_stop", required = false) timeStop: LocalDateTime? + ) = ballisticsService.flightLine(noradId, timeStart, timeStop).collectList().block() ?: emptyList() }