Files
Flight-Ops/lib/mapData.ts
T
2026-09-20 09:16:06 +00:00

167 lines
6.0 KiB
TypeScript

import type { 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;
status: 'available' | 'flying';
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 };
}