v0.58.0 — plane markers no longer teleport across the antimeridian (ISS-124)
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).
This commit is contained in:
2026-09-22 21:35:48 +00:00
parent 18e5c832e3
commit c06e038130
6 changed files with 268 additions and 9 deletions
+94 -1
View File
@@ -45,7 +45,7 @@ function snapshot(overrides: Partial<StateSnapshot> = {}): StateSnapshot {
describe('planesGeoJson', () => {
it('returns an empty collection for a null snapshot', () => {
expect(planesGeoJson(null)).toEqual({ type: 'FeatureCollection', features: [] });
expect(planesGeoJson(null, AIRPORTS)).toEqual({ type: 'FeatureCollection', features: [] });
});
it('emits one Point per plane at its polled lat/lon with popup properties', () => {
@@ -56,6 +56,7 @@ describe('planesGeoJson', () => {
{ id: 'plane-2', model: 'atr72', status: 'flying', fuel: 4000, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: 'EDDV', destName: 'Hannover Airport', departureSimTime: 1000, lat: 51.2, lon: 9.1, health: 100, repairUntilSimTime: null, queue: [] },
],
}),
AIRPORTS, // non-crossing leg — the unwrap is a no-op, positions stay as polled
);
expect(fc.features).toHaveLength(2);
@@ -82,6 +83,7 @@ describe('planesGeoJson', () => {
{ id: 'p1', model: 'mystery', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: null, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [] },
],
}),
AIRPORTS,
);
expect(fc.features[0].properties.modelLabel).toBe('mystery');
});
@@ -93,11 +95,102 @@ describe('planesGeoJson', () => {
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: 0, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [] },
],
}),
AIRPORTS, // no destIcao — the unwrap guard skips it, position stays as polled
);
expect(fc.features[0].properties.routeText).toBe('');
});
});
describe('planesGeoJson — antimeridian marker unwrap (ISS-124 v0.58.0)', () => {
const nzaa = AIRPORTS.get('NZAA')!; // Auckland, lon +174.8
const nftf = AIRPORTS.get('NFTF')!; // Fua'amotu, lon −175.1 — NZAA→NFTF crosses ±180 eastward
it('eastward crossing: a mid-leg wrapped lon is unwrapped against the origin airport', () => {
// positionAt wraps into [−180, 180]: past ±180 the snapshot reads −179.5 for a point at +180.5.
const fc = planesGeoJson(
snapshot({
planes: [
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'NZAA', locationName: 'Auckland', destIcao: 'NFTF', destName: "Fua'amotu", departureSimTime: 0, lat: -29.5, lon: -179.5, health: 100, repairUntilSimTime: null, queue: [] },
],
}),
AIRPORTS,
);
const [lon] = fc.features[0].geometry.coordinates;
expect(lon).toBeCloseTo(180.5, 9); // −179.5 + 360 — continuous with the origin's +174.8
expect(Math.abs(lon - nzaa.lon)).toBeLessThan(180); // within half a turn of the anchor
});
it('westward crossing (reversed pair): unwrapped below −180', () => {
const fc = planesGeoJson(
snapshot({
planes: [
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'NFTF', locationName: "Fua'amotu", destIcao: 'NZAA', destName: 'Auckland', departureSimTime: 0, lat: -29.5, lon: 179.5, health: 100, repairUntilSimTime: null, queue: [] },
],
}),
AIRPORTS,
);
const [lon] = fc.features[0].geometry.coordinates;
expect(lon).toBeCloseTo(-180.5, 9); // +179.5 − 360 — continuous with the origin's −175.1
});
it('marker lon equals the route line\'s own sample at every progress point (same world copy)', () => {
// The line is greatCircleArc(NZAA→NFTF) unwrapped from +174.8; feed each of its samples back as a
// WRAPPED snapshot position (what positionAt produces server-side) — the marker must land exactly
// on the line's sample, so marker and line agree frame to frame across the crossing.
const arc = greatCircleArc(nzaa, nftf);
for (const [uLon, uLat] of arc) {
let wLon = uLon; // wrap into positionAt's range as the server snapshot would carry it
while (wLon > 180) wLon -= 360;
while (wLon <= -180) wLon += 360;
const fc = planesGeoJson(
snapshot({
planes: [
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'NZAA', locationName: 'A', destIcao: 'NFTF', destName: 'B', departureSimTime: 0, lat: uLat, lon: wLon, health: 100, repairUntilSimTime: null, queue: [] },
],
}),
AIRPORTS,
);
expect(fc.features[0].geometry.coordinates[0]).toBeCloseTo(uLon, 9);
}
});
it('grounded planes keep their raw position (no unwrap without a leg)', () => {
const fc = planesGeoJson(
snapshot({
planes: [
{ id: 'p1', model: 'atr72', status: 'available', fuel: 6300, maxFuel: 6300, locationIcao: 'NFTF', locationName: "Fua'amotu", destIcao: null, destName: null, departureSimTime: null, lat: nftf.lat, lon: nftf.lon, health: 100, repairUntilSimTime: null, queue: [] },
],
}),
AIRPORTS,
);
expect(fc.features[0].geometry.coordinates).toEqual([nftf.lon, nftf.lat]); // raw −175.1, untouched
});
it('a flying plane with a missing endpoint keeps its wrapped lon (no line is drawn either)', () => {
const fc = planesGeoJson(
snapshot({
planes: [
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'NZAA', locationName: 'Auckland', destIcao: 'ZZZZ', destName: '?', departureSimTime: 0, lat: -29.5, lon: -179.5, health: 100, repairUntilSimTime: null, queue: [] },
],
}),
AIRPORTS,
);
expect(fc.features[0].geometry.coordinates).toEqual([-179.5, -29.5]); // wrapped — mirrors routesGeoJson's skip
});
it('non-crossing legs are untouched: lons stay as polled', () => {
const fc = planesGeoJson(
snapshot({
planes: [
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'F', destIcao: 'LEMD', destName: 'M', departureSimTime: 0, lat: 45.2, lon: -1.9, health: 100, repairUntilSimTime: null, queue: [] },
],
}),
AIRPORTS,
);
expect(fc.features[0].geometry.coordinates).toEqual([-1.9, 45.2]);
});
});
describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)', () => {
it('returns an empty collection for a null snapshot', () => {
expect(routesGeoJson(null, AIRPORTS)).toEqual({ type: 'FeatureCollection', features: [] });
+24 -5
View File
@@ -41,15 +41,30 @@ export interface FeatureCollection<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> {
// 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: [p.lon, p.lat] },
geometry: { type: 'Point', coordinates: [lon, p.lat] },
properties: {
id: p.id,
modelLabel: labelByModel.get(p.model) ?? p.model,
@@ -80,12 +95,16 @@ export const ROUTE_ARC_SEGMENTS = 24;
// 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++) {
let lon = coords[i]![0];
while (lon - prev > 180) lon -= 360;
while (prev - lon > 180) lon += 360;
const lon = unwrapLon(coords[i]![0], prev);
coords[i]![0] = lon;
prev = lon;
}