import type { PlaneState, StateSnapshot } from './game/state'; import { formatFuel } from './format'; import { haversineKm } from './geo'; import { legPhaseAt, positionAt, type LegPhase } 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 // v1.12.0: 'taking off' | 'cruising' | 'landing' while flying (derived from the sim clock), '' when grounded. phaseLabel: string; flying: boolean; } // v1.12.0: display text for each timed leg segment (see legPhaseAt in lib/sim/movement.ts). const PHASE_LABELS: Record = { takeoff: 'taking off', cruise: 'cruising', landing: 'landing' }; 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: [] }; // ISS-124 (v0.58.0): positionAt wraps each sample's longitude into [−180, 180], so a marker on a // leg crossing the antimeridian jumps +179.x → −179.x between polls — MapLibre draws that as a 360° // teleport while the route line (greatCircleArc) stays continuous. Unwrap the plane's lon against // its departure airport — the same anchor greatCircleArc keeps for its first sample — so marker and // line agree frame to frame. Only when BOTH endpoints exist, mirroring routesGeoJson's guard: a // flying plane with a missing endpoint draws no line either, so it stays wrapped like before. export function planesGeoJson( state: StateSnapshot | null, airportsByIcao: ReadonlyMap, ): 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 speedByModel = new Map(state.catalog.map((e) => [e.model, e.speedKmh])); const clockMs = state.simState.clockMs; const features: MapPointFeature[] = state.planes.map((p) => { const flying = p.status === 'flying'; let lon = p.lon; // v1.12.0: the marker's phase label — same legPhases composition the engine uses, derived from // departureSimTime + clockMs (both in the snapshot) so no extra state is needed. let phaseLabel = ''; if (flying && p.destIcao && p.departureSimTime != null) { const from = airportsByIcao.get(p.locationIcao); const to = airportsByIcao.get(p.destIcao); const speedKmh = speedByModel.get(p.model); if (from && to) lon = unwrapLon(p.lon, from.lon); if (from && to && speedKmh !== undefined) { phaseLabel = PHASE_LABELS[legPhaseAt( p.departureSimTime, haversineKm(from.lat, from.lon, to.lat, to.lon), speedKmh, p.locationIcao, p.destIcao, clockMs, )]; } } return { type: 'Feature', geometry: { type: 'Point', coordinates: [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}` : '', phaseLabel, 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; // ISS-120 (v0.53.0): positionAt wraps each sample's longitude into [−180, 180], so an arc that // crosses the antimeridian jumps +179.x → −179.x between consecutive samples — and MapLibre draws // LineStrings as-is (no ±180 splitting), rendering a chord across the whole map. Unwrap the sample // sequence into continuous longitudes: keep the first sample's raw lon, then shift each subsequent // one by ±360 relative to the previous UNWRAPPED value until |Δlon| ≤ 180. One line stays one line; // out-of-range values render on MapLibre's adjacent world copies (renderWorldCopies is default-on). function unwrapLon(lon: number, prev: number): number { while (lon - prev > 180) lon -= 360; while (prev - lon > 180) lon += 360; return lon; } function unwrapLons(coords: [number, number][]): [number, number][] { let prev = coords[0]![0]; for (let i = 1; i < coords.length; i++) { const lon = unwrapLon(coords[i]![0], prev); coords[i]![0] = lon; prev = lon; } return coords; } // Great-circle arc between two airports as [lon, lat] coordinates: positionAt at // t = i/SEGMENTS for i in 0..SEGMENTS. Pure — the first sample is exactly `from` and the last is // ≡ `to` (mod 360) (positionAt returns them verbatim at t=0 and t=1, unwrapped relative to from). 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 unwrapLons(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, ): FeatureCollection { 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. ISS-077 (v0.41.0): // also empty when the start point IS the destination airport itself (a flying carrier's trace now // starts at its landing airport, which can equal the package's dest) — nothing to draw there; the // solid active-leg layer covers that case. export function hoverTraceGeoJson( from: AirportCoord, toIcao: string, airportsByIcao: ReadonlyMap, ): FeatureCollection { const to = airportsByIcao.get(toIcao); if (!to) return EMPTY_LINES; if (from.lat === to.lat && from.lon === to.lon) return EMPTY_LINES; // start == dest — zero-length trace 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, ): 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 }; } // ISS-043 (v0.26.0): the dotted queue line — one LineString per plane with a non-empty queue, // chaining the not-yet-started legs: a FLYING plane starts at its active leg's destination // (dest → queue[0] → …; the active leg locationIcao→dest stays in routesGeoJson's solid layer), // and ISS-077 (v0.41.0) extends it to GROUNDED ('available') planes, which start at their current // airport (locationIcao → queue[0] → …). 'repairing' planes draw nothing — they can't fly. Arcs are // concatenated with the shared joint point dropped once. A plane is skipped — never guessed — when // its first point or any queued ICAO is missing from the airport index; empty collection when // nothing qualifies. export function queuedRoutesGeoJson( planes: PlaneState[], airportsByIcao: ReadonlyMap, ): FeatureCollection { const features: MapLineFeature[] = []; for (const p of planes) { if (p.queue.length === 0) continue; // First point of the chain: a flying plane's landing airport, a grounded one's current airport. const firstIcao = p.status === 'flying' ? p.destIcao : p.status === 'available' ? p.locationIcao : null; if (!firstIcao) continue; const pts = [firstIcao, ...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 } // ISS-120: each leg's arc is unwrapped relative to its OWN start airport, so at a joint the // sequence can jump ±360 (leg 1 ends at dest+360k, leg 2 restarts at the raw dest) — re-unify. unwrapLons(coords); features.push({ type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: { planeId: p.id }, }); } return { type: 'FeatureCollection', features }; }