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 { type: 'FeatureCollection'; features: T[]; } const EMPTY_POINTS: FeatureCollection = { type: 'FeatureCollection', features: [] }; const EMPTY_LINES: FeatureCollection = { type: 'FeatureCollection', features: [] }; export function planesGeoJson(state: StateSnapshot | null): FeatureCollection { 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; } // 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, ): FeatureCollection { 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 }; }