v0.58.0 — plane markers no longer teleport across the antimeridian (ISS-124)
CI / build + unit + e2e (pull_request) Failing after 8m36s
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:
+94
-1
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user