Files
Flight-Ops/lib/mapData.ts
T
kamilkrzywda 82e4c64206
CI / build + unit + e2e (pull_request) Failing after 7m29s
v1.12.0 — takeoff & landing phases on the marker
Every leg gains a 10-min takeoff phase at the origin and a 10-min
landing phase at the destination, each weather-scaled up to 2x via a
monotone condition rule (Clear < Cloudy < Rain < Fog). Single
composition in lib/sim/movement.ts: BASE_PHASE_MS, phaseFactor,
MAX_PHASE_FACTOR, legPhases; arrivalTimeFor and progressAt route
through it (progress is now piecewise — hold at origin / linear cruise
/ hold at destination), so engine arrival gate, client position/fuel
recompute and the job expiry floor all agree. Phases burn no fuel.

Marker shows the current phase next to the status in the popup
(mapData derives it from departureSimTime + clockMs; new
data-testid=plane-phase sibling). plane-status keeps the raw status.

Tests: movement.test.ts (phaseFactor bounds/monotonicity, origin-vs-dest
composition, piecewise progressAt), engine.test.ts (deleted local travelMs
mirror — all ~18 fixture clocks use the real composition), seed.test.ts
(phase-inclusive expiry floor mirrors). New e2e/phases.spec.ts asserts
'taking off' at the origin gate and 'landing' at the destination gate on
EDDF→LEMD using the same legPhases boundaries as the engine.
2026-09-28 07:09:19 +00:00

269 lines
12 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 { 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<LegPhase, string> = { 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<T> {
type: 'FeatureCollection';
features: T[];
}
const EMPTY_POINTS: FeatureCollection<MapPointFeature> = { type: 'FeatureCollection', features: [] };
const EMPTY_LINES: FeatureCollection<MapLineFeature> = { 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<string, AirportCoord>,
): 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 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<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. 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<string, AirportCoord>,
): FeatureCollection<MapLineFeature> {
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<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 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<string, AirportCoord>,
): FeatureCollection<MapLineFeature> {
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 };
}