Files
Flight-Ops/lib/mapData.ts
T
2026-09-21 12:06:45 +00:00

196 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { PlaneState, StateSnapshot } from './game/state';
import { formatFuel } from './format';
import { positionAt } from './sim/movement';
// Pure GeoJSON builders for the map's dynamic layers (v0.11.0, package model
// v0.12.0): one Point feature per plane, one LineString per FLYING plane's
// current leg (origin → dest_icao). Kept out of components/Map.tsx so the
// classification logic is unit-testable without a DOM.
export interface PlaneFeatureProps {
id: string;
modelLabel: string;
// v0.39.0 (ISS-069): 'repairing' planes render on the grounded layer (flying === false).
status: 'available' | 'flying' | 'repairing';
fuelText: string; // "1234 / 6300 L"
routeText: string; // "Origin → Destination" while flying, '' when grounded
flying: boolean;
}
export interface RouteFeatureProps {
planeId: string;
}
export interface MapPointFeature {
type: 'Feature';
geometry: { type: 'Point'; coordinates: [number, number] };
properties: PlaneFeatureProps;
}
export interface MapLineFeature {
type: 'Feature';
geometry: { type: 'LineString'; coordinates: [number, number][] };
properties: RouteFeatureProps;
}
export interface FeatureCollection<T> {
type: 'FeatureCollection';
features: T[];
}
const EMPTY_POINTS: FeatureCollection<MapPointFeature> = { type: 'FeatureCollection', features: [] };
const EMPTY_LINES: FeatureCollection<MapLineFeature> = { type: 'FeatureCollection', features: [] };
export function planesGeoJson(state: StateSnapshot | null): FeatureCollection<MapPointFeature> {
if (!state) return EMPTY_POINTS;
// Catalog display name ("ATR 72-600") for the popup — raw model id as fallback.
const labelByModel = new Map(state.catalog.map((e) => [e.model, e.label]));
const features: MapPointFeature[] = state.planes.map((p) => {
const flying = p.status === 'flying';
return {
type: 'Feature',
geometry: { type: 'Point', coordinates: [p.lon, p.lat] },
properties: {
id: p.id,
modelLabel: labelByModel.get(p.model) ?? p.model,
status: p.status,
fuelText: formatFuel(p.fuel, p.maxFuel),
routeText: flying && p.destName ? `${p.locationName} → ${p.destName}` : '',
flying,
},
};
});
return { type: 'FeatureCollection', features };
}
// Airport coordinate lookup for route endpoints (from the /api/airports GeoJSON).
export interface AirportCoord {
lat: number;
lon: number;
}
// Segments used to sample a route leg's great-circle arc (v0.12.2): 25 coordinates
// total — endpoints exact, 23 intermediate points so the drawn line matches the
// curved path the plane actually flies (same slerp as lib/sim/movement.ts).
export const ROUTE_ARC_SEGMENTS = 24;
// Great-circle arc between two airports as [lon, lat] coordinates: positionAt at
// t = i/SEGMENTS for i in 0..SEGMENTS. Pure — endpoints are exactly the airport
// coords (positionAt returns them verbatim at t=0 and t=1).
export function greatCircleArc(from: AirportCoord, to: AirportCoord): [number, number][] {
const coords: [number, number][] = [];
for (let i = 0; i <= ROUTE_ARC_SEGMENTS; i++) {
const pos = positionAt(from.lat, from.lon, to.lat, to.lon, i / ROUTE_ARC_SEGMENTS);
coords.push([pos.lon, pos.lat]);
}
return coords;
}
// ISS-022/025/027 (v0.22.0): the hover route trace — one great-circle LineString between two
// airports, drawn top-most while a panel row hovers (job-list rows, on-board cargo rows, the
// Relocate section). Empty collection when either endpoint is missing from the dataset — never
// guessed; the '' sentinel (no hovered target) resolves to nothing and yields the same.
export function hoverRouteGeoJson(
fromIcao: string,
toIcao: string,
airportsByIcao: ReadonlyMap<string, AirportCoord>,
): FeatureCollection<MapLineFeature> {
const from = airportsByIcao.get(fromIcao);
const to = airportsByIcao.get(toIcao);
if (!from || !to) return EMPTY_LINES;
return {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: greatCircleArc(from, to),
},
properties: { planeId: '' }, // no owning plane — a pure hover overlay
},
],
};
}
// ISS-040 (v0.23.2): the on-board package hover trace — same shape as hoverRouteGeoJson but the
// start is a live COORDINATE (the carrier plane's current position), not an airport ICAO: once the
// plane has departed with cargo still loaded, the preview must begin where it actually is. Empty
// collection when the destination is missing from the dataset — never guessed.
export function hoverTraceGeoJson(
from: AirportCoord,
toIcao: string,
airportsByIcao: ReadonlyMap<string, AirportCoord>,
): FeatureCollection<MapLineFeature> {
const to = airportsByIcao.get(toIcao);
if (!to) return EMPTY_LINES;
return {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: greatCircleArc(from, to),
},
properties: { planeId: '' }, // no owning plane — a pure hover overlay
},
],
};
}
// One LineString per FLYING plane — its current leg from locationIcao to dest_icao.
// Grounded planes draw nothing; a flying plane missing either endpoint from the
// dataset is skipped, never guessed. The line is sampled along the great circle
// (greatCircleArc) so it matches the curved flight path instead of a straight chord.
export function routesGeoJson(
state: StateSnapshot | null,
airportsByIcao: ReadonlyMap<string, AirportCoord>,
): FeatureCollection<MapLineFeature> {
if (!state) return EMPTY_LINES;
const features: MapLineFeature[] = [];
for (const p of state.planes) {
if (p.status !== 'flying' || !p.destIcao) continue;
const from = airportsByIcao.get(p.locationIcao);
const to = airportsByIcao.get(p.destIcao);
if (!from || !to) continue; // endpoint missing from the dataset — skip, don't guess
features.push({
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: greatCircleArc(from, to),
},
properties: { planeId: p.id },
});
}
return { type: 'FeatureCollection', features };
}
// ISS-043 (v0.26.0): the dotted queue line — one LineString per FLYING plane with a non-empty
// queue, chaining dest → queue[0] → … → queue[n−1]: ONLY the not-yet-started legs (the active
// leg locationIcao→dest stays in routesGeoJson's solid layer). Arcs are concatenated with the
// shared joint point dropped once. A plane is skipped — never guessed — when its dest or any
// queued ICAO is missing from the airport index; empty collection when nothing qualifies.
export function queuedRoutesGeoJson(
planes: PlaneState[],
airportsByIcao: ReadonlyMap<string, AirportCoord>,
): FeatureCollection<MapLineFeature> {
const features: MapLineFeature[] = [];
for (const p of planes) {
if (p.status !== 'flying' || !p.destIcao || p.queue.length === 0) continue;
const pts = [p.destIcao, ...p.queue].map((icao) => airportsByIcao.get(icao));
if (pts.some((c) => c === undefined)) continue; // an endpoint is missing from the dataset — skip
const coords: [number, number][] = [];
for (let i = 0; i < pts.length - 1; i++) {
const arc = greatCircleArc(pts[i]!, pts[i + 1]!);
coords.push(...(i === 0 ? arc : arc.slice(1))); // drop the duplicated joint point
}
features.push({
type: 'Feature',
geometry: { type: 'LineString', coordinates: coords },
properties: { planeId: p.id },
});
}
return { type: 'FeatureCollection', features };
}