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:
@@ -6,6 +6,37 @@ import {
|
|||||||
type TguPlatform
|
type TguPlatform
|
||||||
} from "../model/tguTypes";
|
} 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<FlightLinePoint[]> {
|
||||||
|
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<FlightLinePoint[]>(
|
||||||
|
`/api/tgu-planning/spacecraft/${encodeURIComponent(noradId)}/flight-line?${query}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchPlatforms(): Promise<TguPlatform[]> {
|
export async function fetchPlatforms(): Promise<TguPlatform[]> {
|
||||||
return fetchJson<TguPlatform[]>("/api/tgu-planning/platforms");
|
return fetchJson<TguPlatform[]>("/api/tgu-planning/platforms");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<HTMLDivElement>(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<HTMLDivElement>) {
|
||||||
|
e.stopPropagation();
|
||||||
|
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
||||||
|
},
|
||||||
|
onPointerMove(e: React.PointerEvent<HTMLDivElement>) {
|
||||||
|
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<HTMLDivElement>) {
|
||||||
|
(e.currentTarget as HTMLDivElement).releasePointerCapture(e.pointerId);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onTrackPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className="map-pass-selector">
|
||||||
|
<div className="map-pass-selector__header">
|
||||||
|
<span className="map-pass-selector__title">Витки</span>
|
||||||
|
<span className="map-pass-selector__nums">{fromRev}–{toRev}</span>
|
||||||
|
<span className="map-pass-selector__times">
|
||||||
|
{multiDay ? `${utcDM(fromPass.startMs)} ` : ""}{utcHM(fromPass.startMs)}
|
||||||
|
{" – "}
|
||||||
|
{multiDay ? `${utcDM(toPass.startMs)} ` : ""}{utcHM(toPass.startMs)}
|
||||||
|
{" UTC"}
|
||||||
|
</span>
|
||||||
|
{!isFullRange && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="map-pass-selector__reset"
|
||||||
|
title="Все витки"
|
||||||
|
onClick={() => { onFromChange(1); onToChange(n); }}
|
||||||
|
>
|
||||||
|
Все
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="map-pass-selector__body" onPointerDown={onTrackPointerDown}>
|
||||||
|
{/* Track line */}
|
||||||
|
<div className="map-pass-selector__track" ref={trackRef}>
|
||||||
|
<div
|
||||||
|
className="map-pass-selector__fill"
|
||||||
|
style={{ left: `${fromPct}%`, width: `${toPct - fromPct}%` }}
|
||||||
|
/>
|
||||||
|
{/* Tick marks */}
|
||||||
|
{passes.map((p) => (
|
||||||
|
<div
|
||||||
|
key={p.index}
|
||||||
|
className={`map-pass-selector__tick${p.index >= fromIndex && p.index <= toIndex ? " is-active" : ""}`}
|
||||||
|
style={{ left: `${pct(p.index)}%` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{/* From handle */}
|
||||||
|
<div
|
||||||
|
className="map-pass-selector__thumb"
|
||||||
|
style={{ left: `${fromPct}%` }}
|
||||||
|
{...makeHandlers(true)}
|
||||||
|
/>
|
||||||
|
{/* To handle */}
|
||||||
|
<div
|
||||||
|
className="map-pass-selector__thumb"
|
||||||
|
style={{ left: `${toPct}%` }}
|
||||||
|
{...makeHandlers(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Labels below handles */}
|
||||||
|
<div className="map-pass-selector__labels">
|
||||||
|
<div className="map-pass-selector__label" style={{ left: `${fromPct}%` }}>
|
||||||
|
<span className="map-pass-selector__label-num">{fromRev}</span>
|
||||||
|
<span className="map-pass-selector__label-time">{multiDay && `${utcDM(fromPass.startMs)} `}{utcHM(fromPass.startMs)}</span>
|
||||||
|
</div>
|
||||||
|
{toIndex !== fromIndex && (
|
||||||
|
<div className="map-pass-selector__label map-pass-selector__label--right" style={{ left: `${toPct}%` }}>
|
||||||
|
<span className="map-pass-selector__label-num">{toRev}</span>
|
||||||
|
<span className="map-pass-selector__label-time">{multiDay && `${utcDM(toPass.startMs)} `}{utcHM(toPass.startMs)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, useCallback, type MutableRefObject } from "react";
|
import { useEffect, useMemo, useRef, useState, useCallback, type MutableRefObject } from "react";
|
||||||
import { fetchRequestsForMap } from "../../api/requestApi";
|
import { fetchRequestsForMap } from "../../api/requestApi";
|
||||||
|
import { fetchFlightLine, type FlightLinePoint } from "../../api/tguApi";
|
||||||
import type { RequestMapItem } from "../../model/requestTypes";
|
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 { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
|
||||||
import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser";
|
import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser";
|
||||||
import type { Tgu2DMapLayersState } from "../tgu-map-2d/model/mapLayerTypes";
|
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 { fetchPlanModes, saveEditedPlan, type PlanMode } from "./editorApi";
|
||||||
import {
|
import {
|
||||||
addDownlink,
|
addDownlink,
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
import { checkVisibility, conflictCount, mergeConflictMaps, overlaps, type EditorConflictMap } from "./editorConflicts";
|
import { checkVisibility, conflictCount, mergeConflictMaps, overlaps, type EditorConflictMap } from "./editorConflicts";
|
||||||
import { DEFAULT_EDITOR_MODES, formatDuration } from "./editorPresentation";
|
import { DEFAULT_EDITOR_MODES, formatDuration } from "./editorPresentation";
|
||||||
import { DownlinkPanel, type DownlinkSelection, type DownlinkStationOption, ShootPanel, WorkInspector } from "./EditorPanels";
|
import { DownlinkPanel, type DownlinkSelection, type DownlinkStationOption, ShootPanel, WorkInspector } from "./EditorPanels";
|
||||||
|
import { MapPassSelector } from "./MapPassSelector";
|
||||||
import { EditorRail } from "./EditorRail";
|
import { EditorRail } from "./EditorRail";
|
||||||
import { EditorTimeline } from "./EditorTimeline";
|
import { EditorTimeline } from "./EditorTimeline";
|
||||||
import { EditorToolbar } from "./EditorToolbar";
|
import { EditorToolbar } from "./EditorToolbar";
|
||||||
@@ -42,7 +44,7 @@ type EditorRightMode = "none" | "inspect" | "shoot" | "downlink";
|
|||||||
const HOUR_MS = 60 * 60 * 1000;
|
const HOUR_MS = 60 * 60 * 1000;
|
||||||
const EDITOR_MAP_LAYERS: Tgu2DMapLayersState = {
|
const EDITOR_MAP_LAYERS: Tgu2DMapLayersState = {
|
||||||
tracks: true,
|
tracks: true,
|
||||||
swath: false,
|
swath: true,
|
||||||
planWorks: true,
|
planWorks: true,
|
||||||
stations: true,
|
stations: true,
|
||||||
spacecraftMarkers: false,
|
spacecraftMarkers: false,
|
||||||
@@ -74,8 +76,37 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
const [contextSpacecraftIds, setContextSpacecraftIds] = useState<Set<string>>(new Set());
|
const [contextSpacecraftIds, setContextSpacecraftIds] = useState<Set<string>>(new Set());
|
||||||
const [requestItems, setRequestItems] = useState<RequestMapItem[]>([]);
|
const [requestItems, setRequestItems] = useState<RequestMapItem[]>([]);
|
||||||
const [planModes, setPlanModes] = useState<PlanMode[]>([]);
|
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 draft = history.draft;
|
||||||
const stations = useMemo(() => buildStations(selectedPlan), [selectedPlan]);
|
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 modes: EditorMode[] = DEFAULT_EDITOR_MODES;
|
||||||
const liveConflicts = useMemo(() => overlaps(draft.works), [draft.works]);
|
const liveConflicts = useMemo(() => overlaps(draft.works), [draft.works]);
|
||||||
const conflicts = useMemo(() => mergeConflictMaps(liveConflicts, serviceConflicts), [liveConflicts, serviceConflicts]);
|
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),
|
() => buildVisibilityTracks(target, shootPasses, selectedWork, stations, selectedSpacecraftId, window),
|
||||||
[selectedSpacecraftId, selectedWork, shootPasses, stations, target, 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(
|
const mapScene = useMemo(
|
||||||
() => buildEditorMapScene(selectedSpacecraftId, window, stations, requestItems, planModes),
|
() => buildEditorMapScene(selectedSpacecraftId, flightLineData, orbitPasses, stations, requestItems, planModes, effectiveFrom, effectiveTo),
|
||||||
[selectedSpacecraftId, stations, window, requestItems, planModes]
|
[selectedSpacecraftId, flightLineData, orbitPasses, stations, requestItems, planModes, effectiveFrom, effectiveTo]
|
||||||
);
|
);
|
||||||
const shootTargets = useMemo(
|
const shootTargets = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -128,8 +161,20 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
setNotice(undefined);
|
setNotice(undefined);
|
||||||
setApplyError(undefined);
|
setApplyError(undefined);
|
||||||
setPlanModes([]);
|
setPlanModes([]);
|
||||||
|
setPassFrom(1);
|
||||||
|
setPassTo(999);
|
||||||
|
setFlightLineData([]);
|
||||||
}, [appliedRange, selectedPlan, selectedSpacecraftId]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
void fetchRequestsForMap({
|
void fetchRequestsForMap({
|
||||||
@@ -347,6 +392,15 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
>
|
>
|
||||||
<div className="tgu-editor-map-stage">
|
<div className="tgu-editor-map-stage">
|
||||||
{rightMode === "shoot" && <div className="tgu-map-overlay">Кликните точку съёмки на карте.</div>}
|
{rightMode === "shoot" && <div className="tgu-map-overlay">Кликните точку съёмки на карте.</div>}
|
||||||
|
{orbitPasses.length > 0 && (
|
||||||
|
<MapPassSelector
|
||||||
|
passes={orbitPasses}
|
||||||
|
fromIndex={effectiveFrom}
|
||||||
|
toIndex={effectiveTo}
|
||||||
|
onFromChange={setPassFrom}
|
||||||
|
onToChange={setPassTo}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Tgu2DMapView
|
<Tgu2DMapView
|
||||||
scene={mapScene}
|
scene={mapScene}
|
||||||
layers={EDITOR_MAP_LAYERS}
|
layers={EDITOR_MAP_LAYERS}
|
||||||
@@ -550,44 +604,57 @@ function buildVisibilityTracks(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MAP_TRACK_WINDOW_MS = 24 * 60 * 60 * 1000;
|
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(
|
function buildEditorMapScene(
|
||||||
spacecraftId: string | undefined,
|
spacecraftId: string | undefined,
|
||||||
window: EditorTimelineWindow,
|
flightLineData: FlightLinePoint[],
|
||||||
|
orbitPasses: OrbitPassInfo[],
|
||||||
stations: EditorGroundStation[],
|
stations: EditorGroundStation[],
|
||||||
requestItems: RequestMapItem[],
|
requestItems: RequestMapItem[],
|
||||||
planModes: PlanMode[]
|
planModes: PlanMode[],
|
||||||
|
fromIndex: number,
|
||||||
|
toIndex: number
|
||||||
): Tgu2DMapScene {
|
): Tgu2DMapScene {
|
||||||
const center = (window.fromMs + window.toMs) / 2;
|
const visibleRevolutions = new Set(
|
||||||
const mapRange = { fromMs: center - MAP_TRACK_WINDOW_MS / 2, toMs: center + MAP_TRACK_WINDOW_MS / 2 };
|
orbitPasses.filter((p) => p.index >= fromIndex && p.index <= toIndex).map((p) => p.revolution!)
|
||||||
|
);
|
||||||
|
|
||||||
const lines = spacecraftId
|
const byRevolution = new Map<number, FlightLinePoint[]>();
|
||||||
? groundTrack({ spacecraftId }, mapRange, MAP_TRACK_POINTS).map((points, index) => ({
|
for (const pt of flightLineData) {
|
||||||
id: `${spacecraftId}:track:${index}`,
|
if (!visibleRevolutions.has(pt.revolution)) continue;
|
||||||
layer: "tracks" as const,
|
const arr = byRevolution.get(pt.revolution) ?? [];
|
||||||
points,
|
arr.push(pt);
|
||||||
spacecraftId,
|
byRevolution.set(pt.revolution, arr);
|
||||||
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 lines: MapLine[] = [];
|
||||||
const polygons: MapPolygon[] = [];
|
const polygons: MapPolygon[] = [];
|
||||||
let zIndex = 0;
|
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) {
|
for (const mode of planModes) {
|
||||||
if (mode.type !== "SURVEY" || !mode.contourWkt) continue;
|
if (mode.type !== "SURVEY" || !mode.contourWkt) continue;
|
||||||
const rings = parseWktPolygon(mode.contourWkt);
|
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 };
|
return { polygons, lines, markers };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { MapProjection } from "../geometry/mapProjection";
|
import type { MapProjection } from "../geometry/mapProjection";
|
||||||
import type { MapPolygon } from "../model/mapTypes";
|
import type { MapPolygon } from "../model/mapTypes";
|
||||||
import { drawPlanWorks } from "./drawPlanWorks";
|
|
||||||
|
|
||||||
export function drawSwath(
|
export function drawSwath(
|
||||||
ctx: CanvasRenderingContext2D,
|
ctx: CanvasRenderingContext2D,
|
||||||
@@ -8,6 +7,24 @@ export function drawSwath(
|
|||||||
projection: MapProjection,
|
projection: MapProjection,
|
||||||
viewport: { width: number; height: number }
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,31 @@ export function nearestStation(point: GroundPoint, stations: GroundStationPoint[
|
|||||||
return bestStation;
|
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[][] {
|
export function groundTrack(orbit: PassOrbit, range: PassRange, maxPoints = 240): GroundPoint[][] {
|
||||||
if (range.toMs <= range.fromMs) {
|
if (range.toMs <= range.fromMs) {
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export type MapLine = {
|
|||||||
layer: "tracks";
|
layer: "tracks";
|
||||||
points: GeoPoint[];
|
points: GeoPoint[];
|
||||||
spacecraftId?: string;
|
spacecraftId?: string;
|
||||||
|
passIndex?: number;
|
||||||
zIndex: number;
|
zIndex: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+11
-1
@@ -8,7 +8,9 @@ import org.springframework.web.bind.annotation.RequestMapping
|
|||||||
import org.springframework.web.bind.annotation.RequestParam
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
import space.nstart.pcp.slots_service.dto.tgu.TguPlanDecision
|
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 space.nstart.pcp.slots_service.service.TguPlanningService
|
||||||
|
import java.time.LocalDateTime
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@Controller
|
@Controller
|
||||||
@@ -21,7 +23,8 @@ class TguPlanningPageController {
|
|||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/tgu-planning")
|
@RequestMapping("/api/tgu-planning")
|
||||||
class TguPlanningRestController(
|
class TguPlanningRestController(
|
||||||
private val tguPlanningService: TguPlanningService
|
private val tguPlanningService: TguPlanningService,
|
||||||
|
private val ballisticsService: BallisticsService
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@GetMapping("/platforms")
|
@GetMapping("/platforms")
|
||||||
@@ -43,4 +46,11 @@ class TguPlanningRestController(
|
|||||||
@RequestParam decision: TguPlanDecision,
|
@RequestParam decision: TguPlanDecision,
|
||||||
@RequestParam(required = false) reason: String?
|
@RequestParam(required = false) reason: String?
|
||||||
) = tguPlanningService.sendDecision(planId, decision, reason)
|
) = 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<Any>()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user