CI / build + unit + e2e (pull_request) Failing after 8m36s
planesGeoJson now takes the airport index and unwraps each flying plane's longitude against its departure airport (unwrapLon, shared with unwrapLons), mirroring routesGeoJson's both-endpoints guard so a marker is unwrapped iff its line is drawn. Map.tsx passes airportsIndexRef at both call sites. Unit +6 in lib/mapData.test.ts; e2e +1 in e2e/map-interactions.spec.ts (chain-relocate to NZAA, fly the NZAA→NFTF crossing, sample marker lons).
246 lines
10 KiB
TypeScript
246 lines
10 KiB
TypeScript
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: [] };
|
||
|
||
// 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 features: MapPointFeature[] = state.planes.map((p) => {
|
||
const flying = p.status === 'flying';
|
||
let lon = p.lon;
|
||
if (flying && p.destIcao) {
|
||
const from = airportsByIcao.get(p.locationIcao);
|
||
const to = airportsByIcao.get(p.destIcao);
|
||
if (from && to) lon = unwrapLon(p.lon, from.lon);
|
||
}
|
||
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}` : '',
|
||
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 };
|
||
}
|