feat(tgu-ops-ui): add spacecraft work editor tab
Port the TGU editor prototype into pcp-tgu-ops-ui as a new editor tab, add draft/conflict/pass logic with tests, extend the 2D map picking API, and include editor styles/theme aliases and task/prototype docs. Validation: npm run test; npm run build in services/pcp-tgu-ops-ui.
This commit is contained in:
@@ -15,6 +15,16 @@ type Tgu2DMapViewProps = {
|
||||
layers: Tgu2DMapLayersState;
|
||||
redrawToken: number;
|
||||
onObjectSelect: (selection?: Tgu2DMapSelection) => void;
|
||||
pickMode?: boolean;
|
||||
onPickPoint?: (point: { lat: number; lon: number }) => void;
|
||||
targets?: Tgu2DMapTarget[];
|
||||
};
|
||||
|
||||
export type Tgu2DMapTarget = {
|
||||
id: string;
|
||||
name: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
};
|
||||
|
||||
type DragState = {
|
||||
@@ -39,7 +49,15 @@ const INITIAL_VIEW: MapViewState = {
|
||||
zoom: 2
|
||||
};
|
||||
|
||||
export function Tgu2DMapView({ scene, layers, redrawToken, onObjectSelect }: Tgu2DMapViewProps) {
|
||||
export function Tgu2DMapView({
|
||||
scene,
|
||||
layers,
|
||||
redrawToken,
|
||||
onObjectSelect,
|
||||
pickMode = false,
|
||||
onPickPoint,
|
||||
targets = []
|
||||
}: Tgu2DMapViewProps) {
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const dragRef = useRef<DragState | undefined>(undefined);
|
||||
@@ -199,6 +217,12 @@ export function Tgu2DMapView({ scene, layers, redrawToken, onObjectSelect }: Tgu
|
||||
|
||||
const onClick = (event: React.MouseEvent) => {
|
||||
const screenPoint = screenPointFromEvent(event);
|
||||
if (pickMode) {
|
||||
onPickPoint?.(projection.unproject(screenPoint));
|
||||
onObjectSelect(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const polygon = hitTestPolygons(polygonIndex, projection.unproject(screenPoint));
|
||||
selectedPolygonRef.current = polygon;
|
||||
|
||||
@@ -239,7 +263,7 @@ export function Tgu2DMapView({ scene, layers, redrawToken, onObjectSelect }: Tgu
|
||||
|
||||
return (
|
||||
<div
|
||||
className="tgu-2d-map"
|
||||
className={`tgu-2d-map ${pickMode ? "is-picking" : ""}`}
|
||||
ref={wrapperRef}
|
||||
onClick={onClick}
|
||||
onPointerDown={onPointerDown}
|
||||
@@ -265,6 +289,17 @@ export function Tgu2DMapView({ scene, layers, redrawToken, onObjectSelect }: Tgu
|
||||
className={polygon.id === selectedPolygon?.id ? "is-selected" : "is-hovered"}
|
||||
/>
|
||||
))}
|
||||
{targets.map((target) => {
|
||||
const screen = projection.project(target);
|
||||
return (
|
||||
<g className="tgu-2d-map__target" key={target.id} transform={`translate(${screen.x} ${screen.y})`}>
|
||||
<circle r="6" />
|
||||
<line x1="-10" x2="10" y1="0" y2="0" />
|
||||
<line x1="0" x2="0" y1="-10" y2="10" />
|
||||
<text x="10" y="-8">{target.name}</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
{tooltip && (
|
||||
<div className="tgu-2d-map__tooltip" style={{ left: tooltip.x, top: tooltip.y }}>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { groundTrack, nearestStation, stationPasses, subPoint, targetPasses } from "./passes";
|
||||
|
||||
const orbit = { spacecraftId: "56756" };
|
||||
const centerMs = Date.UTC(2026, 4, 29, 12, 0, 0);
|
||||
|
||||
describe("passes", () => {
|
||||
it("finds target and station visibility around the sub-satellite point", () => {
|
||||
const point = subPoint(orbit, centerMs);
|
||||
const range = { fromMs: centerMs - 15 * 60 * 1000, toMs: centerMs + 15 * 60 * 1000 };
|
||||
|
||||
expect(targetPasses(orbit, point, range).length).toBeGreaterThan(0);
|
||||
expect(stationPasses(orbit, point, range).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("selects the nearest station by great-circle distance", () => {
|
||||
const point = { lat: 55.75, lon: 37.62 };
|
||||
|
||||
expect(nearestStation(point, [
|
||||
{ id: "far", lat: -30, lon: -120 },
|
||||
{ id: "near", lat: 55.8, lon: 37.7 }
|
||||
])?.id).toBe("near");
|
||||
});
|
||||
|
||||
it("builds ground track segments in the requested range", () => {
|
||||
const segments = groundTrack(orbit, {
|
||||
fromMs: centerMs,
|
||||
toMs: centerMs + 90 * 60 * 1000
|
||||
});
|
||||
|
||||
expect(segments.length).toBeGreaterThan(0);
|
||||
expect(segments.every((segment) => segment.length > 1)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
import { normalizeLon } from "./mapProjection";
|
||||
|
||||
const DEG = Math.PI / 180;
|
||||
const RAD = 180 / Math.PI;
|
||||
const EARTH_RADIUS_KM = 6371;
|
||||
const EARTH_MU_KM3_S2 = 398600.4418;
|
||||
const SIDEREAL_DAY_SECONDS = 86164;
|
||||
const DEFAULT_ALTITUDE_KM = 550;
|
||||
const DEFAULT_TARGET_THRESHOLD_DEG = 6.5;
|
||||
const TARGET_SCAN_STEP_MS = 20 * 1000;
|
||||
const STATION_SCAN_STEP_MS = 30 * 1000;
|
||||
const MIN_WINDOW_MS = 40 * 1000;
|
||||
|
||||
export type GroundPoint = {
|
||||
lat: number;
|
||||
lon: number;
|
||||
};
|
||||
|
||||
export type GroundStationPoint = GroundPoint & {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type PassOrbit = {
|
||||
spacecraftId?: string;
|
||||
altitudeKm?: number;
|
||||
};
|
||||
|
||||
export type PassRange = {
|
||||
fromMs: number;
|
||||
toMs: number;
|
||||
};
|
||||
|
||||
export type PassWindow = {
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
quality: number;
|
||||
};
|
||||
|
||||
export function subPoint(orbit: PassOrbit, timeMs: number): GroundPoint {
|
||||
const params = orbitParams(orbit);
|
||||
const dtSeconds = timeMs / 1000;
|
||||
const argument = params.phaseRad + 2 * Math.PI * (dtSeconds / params.periodSeconds);
|
||||
const lat = Math.asin(Math.sin(params.inclinationRad) * Math.sin(argument)) * RAD;
|
||||
const relativeLon = Math.atan2(Math.cos(params.inclinationRad) * Math.sin(argument), Math.cos(argument));
|
||||
const lon = normalizeLon((params.nodeRad + relativeLon) * RAD - (360 / SIDEREAL_DAY_SECONDS) * dtSeconds);
|
||||
|
||||
return { lat, lon };
|
||||
}
|
||||
|
||||
export function targetPasses(orbit: PassOrbit, target: GroundPoint, range: PassRange): PassWindow[] {
|
||||
return scanWindows(orbit, target, range, DEFAULT_TARGET_THRESHOLD_DEG, TARGET_SCAN_STEP_MS);
|
||||
}
|
||||
|
||||
export function stationPasses(orbit: PassOrbit, station: GroundPoint, range: PassRange, minElevDeg = 5): PassWindow[] {
|
||||
const thresholdDeg = accessAngleDeg(orbit.altitudeKm ?? DEFAULT_ALTITUDE_KM, minElevDeg);
|
||||
return scanWindows(orbit, station, range, thresholdDeg, STATION_SCAN_STEP_MS);
|
||||
}
|
||||
|
||||
export function nearestStation(point: GroundPoint, stations: GroundStationPoint[]): GroundStationPoint | undefined {
|
||||
let bestStation: GroundStationPoint | undefined;
|
||||
let bestDistance = Number.POSITIVE_INFINITY;
|
||||
|
||||
for (const station of stations) {
|
||||
const distance = greatCircleDistanceDeg(point, station);
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
bestStation = station;
|
||||
}
|
||||
}
|
||||
|
||||
return bestStation;
|
||||
}
|
||||
|
||||
export function groundTrack(orbit: PassOrbit, range: PassRange, maxPoints = 240): GroundPoint[][] {
|
||||
if (range.toMs <= range.fromMs) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const spanMs = range.toMs - range.fromMs;
|
||||
const stepMs = Math.max(60 * 1000, Math.ceil(spanMs / Math.max(2, maxPoints)));
|
||||
const segments: GroundPoint[][] = [];
|
||||
let current: GroundPoint[] = [];
|
||||
let previousLon: number | undefined;
|
||||
|
||||
for (let timeMs = range.fromMs; timeMs <= range.toMs; timeMs += stepMs) {
|
||||
const point = subPoint(orbit, timeMs);
|
||||
if (previousLon !== undefined && Math.abs(point.lon - previousLon) > 180) {
|
||||
if (current.length > 1) {
|
||||
segments.push(current);
|
||||
}
|
||||
current = [];
|
||||
}
|
||||
current.push(point);
|
||||
previousLon = point.lon;
|
||||
}
|
||||
|
||||
const endPoint = subPoint(orbit, range.toMs);
|
||||
if (current.length === 0 || current[current.length - 1].lat !== endPoint.lat || current[current.length - 1].lon !== endPoint.lon) {
|
||||
current.push(endPoint);
|
||||
}
|
||||
if (current.length > 1) {
|
||||
segments.push(current);
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
export function greatCircleDistanceDeg(first: GroundPoint, second: GroundPoint): number {
|
||||
const firstLat = first.lat * DEG;
|
||||
const secondLat = second.lat * DEG;
|
||||
const deltaLon = (second.lon - first.lon) * DEG;
|
||||
const cosine =
|
||||
Math.sin(firstLat) * Math.sin(secondLat) +
|
||||
Math.cos(firstLat) * Math.cos(secondLat) * Math.cos(deltaLon);
|
||||
|
||||
return Math.acos(Math.max(-1, Math.min(1, cosine))) * RAD;
|
||||
}
|
||||
|
||||
export function accessAngleDeg(altitudeKm: number, minElevDeg: number): number {
|
||||
const minElevRad = minElevDeg * DEG;
|
||||
const ratio = EARTH_RADIUS_KM / (EARTH_RADIUS_KM + altitudeKm);
|
||||
return (Math.acos(ratio * Math.cos(minElevRad)) - minElevRad) * RAD;
|
||||
}
|
||||
|
||||
function scanWindows(
|
||||
orbit: PassOrbit,
|
||||
target: GroundPoint,
|
||||
range: PassRange,
|
||||
thresholdDeg: number,
|
||||
stepMs: number
|
||||
): PassWindow[] {
|
||||
if (range.toMs <= range.fromMs) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const windows: PassWindow[] = [];
|
||||
let inside = false;
|
||||
let startMs = range.fromMs;
|
||||
let bestMargin = 0;
|
||||
|
||||
for (let timeMs = range.fromMs; timeMs <= range.toMs; timeMs += stepMs) {
|
||||
const distance = greatCircleDistanceDeg(subPoint(orbit, timeMs), target);
|
||||
const visible = distance <= thresholdDeg;
|
||||
|
||||
if (visible && !inside) {
|
||||
inside = true;
|
||||
startMs = timeMs;
|
||||
bestMargin = thresholdDeg - distance;
|
||||
} else if (visible) {
|
||||
bestMargin = Math.max(bestMargin, thresholdDeg - distance);
|
||||
} else if (inside) {
|
||||
windows.push(toWindow(startMs, timeMs, bestMargin, thresholdDeg));
|
||||
inside = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (inside) {
|
||||
windows.push(toWindow(startMs, range.toMs, bestMargin, thresholdDeg));
|
||||
}
|
||||
|
||||
return windows.filter((window) => window.endMs - window.startMs >= MIN_WINDOW_MS);
|
||||
}
|
||||
|
||||
function toWindow(startMs: number, endMs: number, bestMargin: number, thresholdDeg: number): PassWindow {
|
||||
return {
|
||||
startMs,
|
||||
endMs,
|
||||
quality: Math.max(0, Math.min(1, bestMargin / thresholdDeg))
|
||||
};
|
||||
}
|
||||
|
||||
function orbitParams(orbit: PassOrbit): {
|
||||
altitudeKm: number;
|
||||
periodSeconds: number;
|
||||
inclinationRad: number;
|
||||
nodeRad: number;
|
||||
phaseRad: number;
|
||||
} {
|
||||
const altitudeKm = orbit.altitudeKm ?? DEFAULT_ALTITUDE_KM;
|
||||
const semiMajorAxis = EARTH_RADIUS_KM + altitudeKm;
|
||||
const periodSeconds = 2 * Math.PI * Math.sqrt((semiMajorAxis * semiMajorAxis * semiMajorAxis) / EARTH_MU_KM3_S2);
|
||||
const key = orbit.spacecraftId ?? "default-spacecraft";
|
||||
|
||||
return {
|
||||
altitudeKm,
|
||||
periodSeconds,
|
||||
inclinationRad: (97.4 + hash01(key, 7) * 1.6) * DEG,
|
||||
nodeRad: hash01(key, 3) * 360 * DEG,
|
||||
phaseRad: hash01(key, 11) * 2 * Math.PI
|
||||
};
|
||||
}
|
||||
|
||||
function hash01(value: string, salt: number): number {
|
||||
let hash = 2166136261 ^ salt;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash ^= value.charCodeAt(index);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return ((hash >>> 0) % 100000) / 100000;
|
||||
}
|
||||
Reference in New Issue
Block a user