From c06e038130388d569ef562dbddb346ddefc571f3 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Tue, 22 Sep 2026 21:35:48 +0000 Subject: [PATCH] =?UTF-8?q?v0.58.0=20=E2=80=94=20plane=20markers=20no=20lo?= =?UTF-8?q?nger=20teleport=20across=20the=20antimeridian=20(ISS-124)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- CHANGELOG.md | 4 + components/Map.tsx | 7 +- e2e/map-interactions.spec.ts | 140 +++++++++++++++++++++++++++++++++++ lib/mapData.test.ts | 95 +++++++++++++++++++++++- lib/mapData.ts | 29 ++++++-- package.json | 2 +- 6 files changed, 268 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9857b5..38f90d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## v0.58.0 — 2026-09-22 21:27 +- **Plane markers no longer teleport across the antimeridian (ISS-124).** The follow-up to ISS-120's known limitation: a plane mid-crossing still used the snapshot's wrapped lat/lon (`positionAt` wraps every longitude into [−180, 180]), so its marker jumped +179.x → −179.x between polls — a visible 360° teleport on the map while its route line stayed continuous. `planesGeoJson` in `lib/mapData.ts` now takes the airport index (`airportsByIcao`, same parameter shape as `routesGeoJson`) and re-expresses each FLYING plane's longitude against its departure airport — the same anchor `greatCircleArc` keeps for its first sample — via a new module-private `unwrapLon(lon, prev)` (the ±360 shift until |Δlon| ≤ 180 that `unwrapLons`'s loop body already used; `unwrapLons` now calls it). The unwrap applies only when BOTH leg endpoints exist in the index, mirroring `routesGeoJson`'s guard exactly: a marker is unwrapped if and only if its line is drawn. Grounded planes, repairing planes and flying planes with a missing endpoint keep their raw wrapped position (unchanged behavior); non-crossing legs are byte-identical (no |Δlon| > 180 → the shift never fires). `components/Map.tsx` passes `airportsIndexRef.current ?? new Map()` at both call sites (`applyDynamic` and the source bootstrap in `ensureLayers`) — planes draw immediately at their wrapped position until the airport index loads, then unwrap. +- **Tests.** Unit +6 in `lib/mapData.test.ts` (new "antimeridian marker unwrap" block on the real NZAA/NFTF pair): eastward crossing unwraps a mid-leg −179.5 to +180.5 against the origin's +174.8, westward (reversed pair) unwraps below −180, and — the key invariant — every sample of `greatCircleArc(NZAA→NFTF)` fed back as a WRAPPED snapshot position comes out exactly on that arc's own sample, so marker and line agree frame to frame; grounded planes keep their raw coordinate, a flying plane with a missing endpoint stays wrapped (no line drawn either), and non-crossing legs are untouched. E2E +1 in `e2e/map-interactions.spec.ts`: the starter is relocated to Auckland via a fewest-legs chain over `/api/airports` (each hop ≤ 3400 km — within the ATR72's guaranteed range even in worst-case headwind; the tank is topped for free with the `set-fuel` hook before every dispatch so auto-refuel costs nothing), then a hook-created NZAA → NFTF package is loaded and flown at scale 1000 while the marker's longitude is sampled from the planes source every ~500 ms (real time) until the server reports landing — no two consecutive samples may differ by ≥ 180° (the teleport), some sample must sit on the east world copy (lon > 180, proving the crossing was actually traversed unwrapped rather than skipped), and the sampled span must exceed 4°. The plane chains back home afterwards so the other specs start from the canonical state. + ## v0.57.0 — 2026-09-22 19:19 - **Push updated state to clients over SSE (ISS-129).** Clients no longer rely on pure polling for live updates: new `GET /api/events` streams full game-state snapshots as Server-Sent Events — the current snapshot immediately on connect, then one after every sim tick (~1 s), so the UI updates push-driven even while idle. New module `lib/sse.ts` is a tiny in-process fan-out registry (`subscribe(fn)` → unsubscribe, `broadcast(text)` with per-subscriber try/catch) — the three process-wide singletons (this subscriber set, the sql.js DB promise (`getGameDb`), and the tick lock (`withLock`)) live on `globalThis`: Next.js can load a module more than once per process (instrumentation.ts and route handlers get separate module instances), so plain module-level state would give each layer its own registry/DB/lock, and background-tick pushes would never reach route-handler subscribers. The broadcast hook lives in `tickNow()` in `lib/sim/engine.ts`: after each locked tick on the singleton path it serializes via the SAME `getStateSnapshot()` that `GET /api/state` returns (no duplicated serialization) and pushes to all subscribers; failures are logged, never fatal. Action mutations reach other clients within one background tick (~1 s); the acting client still gets its immediate post-action snapshot from the POST response as before. The SSE route (`app/api/events/route.ts`) sends `Content-Type: text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive` and `X-Accel-Buffering: no` (prod sits behind Traefik), plus a `: ping` heartbeat comment every 15 s so idle proxies don't drop the connection; snapshot-first-then-subscribe ordering guarantees events are strictly ordered by sim clock, and stream cancel unsubscribes + clears the heartbeat; broadcast pushes are wrapped in `data: \n\n` SSE frames at the route (matching the initial snapshot). Client (`lib/useGameState.ts`): one `EventSource('/api/events')` per page — onmessage parses the full snapshot into the shared state (same shape as today); polling stays as an automatic fallback: if no event arrives within 5 s of connect, or on a stream error, the old poll loop starts (immediate catch-up fetch + interval), and it stops again when fresh events flow. EventSource's native reconnect is relied on — no custom reconnect framework. `/api/state` behavior is unchanged (still ticks-then-snapshots; polling remains fully functional). - **API.** OpenAPI spec gains `GET /api/events` (200 → `text/event-stream`) in `lib/openapi.ts`, so the machine-readable spec at `/api/openapi` stays accurate. diff --git a/components/Map.tsx b/components/Map.tsx index 024d927..50ef8ac 100644 --- a/components/Map.tsx +++ b/components/Map.tsx @@ -96,7 +96,9 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) { const st = stateRef.current; if (!map || !st) return; const planesSource = map.getSource(PLANES_SOURCE_ID) as maplibregl.GeoJSONSource | undefined; - if (planesSource) void planesSource.setData(planesGeoJson(st)); // v6 API (setGeoData was removed) + // ISS-124 (v0.58.0): the airport index unwraps crossing markers — empty until it loads, so + // planes draw at their wrapped position in the meantime (same as pre-v0.58.0). + if (planesSource) void planesSource.setData(planesGeoJson(st, airportsIndexRef.current ?? new Map())); // v6 API (setGeoData was removed) const routesSource = map.getSource(ROUTES_SOURCE_ID) as maplibregl.GeoJSONSource | undefined; if (routesSource && airportsIndexRef.current) { void routesSource.setData(routesGeoJson(st, airportsIndexRef.current)); @@ -405,7 +407,8 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) { }); } if (!map.getSource(PLANES_SOURCE_ID)) { - map.addSource(PLANES_SOURCE_ID, { type: 'geojson', data: planesGeoJson(stateRef.current) }); + // ISS-124 (v0.58.0): same airport-index unwrap as applyDynamic above. + map.addSource(PLANES_SOURCE_ID, { type: 'geojson', data: planesGeoJson(stateRef.current, airportsIndexRef.current ?? new Map()) }); } if (!map.getLayer(PLANES_GROUNDED_LAYER_ID)) { map.addLayer({ diff --git a/e2e/map-interactions.spec.ts b/e2e/map-interactions.spec.ts index 3dfd525..a028f47 100644 --- a/e2e/map-interactions.spec.ts +++ b/e2e/map-interactions.spec.ts @@ -337,3 +337,143 @@ test('plane markers move while flying and the plane popup shows status + route', await loadAndFly(page, DEST, HUB); }); + +// ISS-124 (v0.58.0): a marker on a leg crossing the antimeridian must not teleport — its longitude is +// unwrapped against the departure airport in planesGeoJson (lib/mapData.ts), so consecutive polls stay +// continuous exactly like the route line. The starter can't fly EDDF→NZAA in one leg (~19,000 km vs the +// ATR72's ~3,700 km guaranteed range), so relocation is a fewest-legs chain over /api/airports with each +// hop ≤ MAX_LEG_KM (within that range even in worst-case headwind); set-fuel tops the tank for free before +// every dispatch so auto-refuel costs nothing. +const MAX_LEG_KM = 3400; + +// Fewest-legs chain from → to over /api/airports, each hop ≤ maxLegKm — BFS with adjacency sorted by +// (distance, ICAO) so the chain is deterministic for a given dataset. Excludes `from`, includes `to`. +async function chainTo(page: Page, fromIcao: string, toIcao: string): Promise { + const fc = (await (await page.request.get('/api/airports')).json()) as { + features: Array<{ properties: { icao: string; lat: number; lon: number } }>; + }; + const aps = fc.features.map((f) => f.properties); + const toRad = (d: number) => (d * Math.PI) / 180; + const havKm = (a: { lat: number; lon: number }, b: { lat: number; lon: number }) => { + const dLat = toRad(b.lat - a.lat); + const dLon = toRad(b.lon - a.lon); + const s = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLon / 2) ** 2; + return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(s))); + }; + const idx = new Map(aps.map((a, i) => [a.icao, i])); + const adj: Array> = aps.map(() => []); + for (let i = 0; i < aps.length; i++) { + for (let j = i + 1; j < aps.length; j++) { + const d = havKm(aps[i]!, aps[j]!); + if (d <= MAX_LEG_KM) { + adj[i]!.push([j, d]); + adj[j]!.push([i, d]); + } + } + } + for (const a of adj) a.sort((x, y) => x[1] - y[1] || aps[x[0]]!.icao.localeCompare(aps[y[0]]!.icao)); + const s = idx.get(fromIcao)!; + const t = idx.get(toIcao)!; + const prev = new Array(aps.length).fill(-1); + const queue: number[] = [s]; + for (let qi = 0; qi < queue.length && prev[t] === -1; qi++) { + const u = queue[qi]!; + for (const [v] of adj[u]!) { + if (prev[v] === -1) { + prev[v] = u; + queue.push(v); + } + } + } + expect(prev[t], `no ≤${MAX_LEG_KM} km chain from ${fromIcao} to ${toIcao}`).not.toBe(-1); + const path: string[] = []; + for (let cur = t; cur !== s; cur = prev[cur]!) path.unshift(aps[cur]!.icao); + return path; +} + +// plane-starter's marker from the planes source: its longitude plus whether that frame is a FLYING one. +// Extracted IN-PAGE because MapLibre feature geometry can be a lazy prototype getter that Playwright's +// evaluate serialization drops (same trap as e2e/hover.spec.ts). The `flying` flag matters for the +// sampling loop: a grounded frame snaps back to the raw airport coordinate (−175.1), which is the same +// screen point but a 360° jump in raw data — only flying frames may be recorded. +function starterMarker(page: Page): Promise<{ lon: number; flying: boolean } | null> { + return page.evaluate(() => { + const m = (window as any).__flightMap; + if (!m || !m.getSource('planes')) return null; + try { + for (const f of m.querySourceFeatures('planes') as Array<{ properties?: Record; geometry?: { coordinates: number[] } }>) { + if (f.properties?.id === 'plane-starter' && f.geometry) { + return { lon: f.geometry.coordinates[0]!, flying: f.properties.flying === true }; + } + } + } catch { + // map/source not ready yet — null keeps the caller polling + } + return null; + }); +} + +test('plane marker stays continuous across the antimeridian (no 360° teleport)', async ({ page }) => { + test.setTimeout(900_000); // relocation chain (~3 min at 1000×) + crossing leg + return chain + margin + + await page.goto('/'); + await waitForAirportCount(page); + + const ORIGIN = 'NZAA'; // Auckland — east of +170 + const CROSS_DEST = 'NFTF'; // Fua'amotu (Tonga) — west of −175; the short arc crosses ±180 EASTWARD + + // Self-heal: full health, let any in-flight leg finish, then relocate to ORIGIN via the chain. + expect((await page.request.post('/api/test/set-health', { data: { planeId: 'plane-starter', health: 100, repairUntilMs: 0 } })).status()).toBe(200); + if ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status === 'flying') { + await setScale(page, SCALE); + await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status ?? '', { timeout: 240_000 }).toBe('available'); + } + let loc = (await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao; + if (loc !== ORIGIN) { + for (const next of await chainTo(page, loc, ORIGIN)) { + await setFuelFull(page, 'plane-starter'); // full tank → auto-refuel tops up 0 L and costs nothing + expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: next } })).status()).toBe(200); + await setScale(page, SCALE); + await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? '', { timeout: 240_000 }).toBe(next); + } + loc = ORIGIN; + } + + // The crossing package + dispatch. + await setFuelFull(page, 'plane-starter'); + const pkg = await createPackage(page, ORIGIN, CROSS_DEST); + expect((await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } })).status()).toBe(200); + expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: CROSS_DEST } })).status()).toBe(200); + await setScale(page, SCALE); + + // Wait until the map's planes source shows it flying (the source updates from ~1 s state polls). + await expect.poll(async () => (await starterMarker(page))?.flying ?? false, { timeout: 15_000, message: 'waiting for the flying plane feature in the planes source' }).toBe(true); + + // Sample the marker's longitude while it crosses. Stop once the SERVER says landed; record only + // FLYING frames — a grounded frame snaps back to its raw airport coordinate (−175.1), the same screen + // point but a 360° jump in raw data, and could land between this loop's server read and source read. + const lons: number[] = []; + for (;;) { + const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter')!; + if (p.status !== 'flying') break; + const marker = await starterMarker(page); + if (marker?.flying) lons.push(marker.lon); + await page.waitForTimeout(500); + } + + expect(lons.length).toBeGreaterThan(3); // a real flight window, not a degenerate one + for (let i = 1; i < lons.length; i++) { + expect(Math.abs(lons[i]! - lons[i - 1]!), `marker jumped between samples ${i - 1} and ${i}`).toBeLessThan(180); // the ISS-124 bug + } + expect(lons.some((lon) => lon > 180)).toBe(true); // crossed onto the east world copy (NZAA→NFTF is eastward) + expect(Math.max(...lons) - Math.min(...lons)).toBeGreaterThan(4); // traversed the crossing region, not a static window + + // Cleanup: chain back home so the other specs start from the canonical state. The package was delivered + // on arrival (generous deadline). + for (const next of await chainTo(page, CROSS_DEST, HUB)) { + await setFuelFull(page, 'plane-starter'); + expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: next } })).status()).toBe(200); + await setScale(page, SCALE); + await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? '', { timeout: 240_000 }).toBe(next); + } +}); diff --git a/lib/mapData.test.ts b/lib/mapData.test.ts index 48eeb62..fade72c 100644 --- a/lib/mapData.test.ts +++ b/lib/mapData.test.ts @@ -45,7 +45,7 @@ function snapshot(overrides: Partial = {}): 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: [] }); diff --git a/lib/mapData.ts b/lib/mapData.ts index d3d5a9c..5423cef 100644 --- a/lib/mapData.ts +++ b/lib/mapData.ts @@ -41,15 +41,30 @@ export interface FeatureCollection { const EMPTY_POINTS: FeatureCollection = { type: 'FeatureCollection', features: [] }; const EMPTY_LINES: FeatureCollection = { type: 'FeatureCollection', features: [] }; -export function planesGeoJson(state: StateSnapshot | null): FeatureCollection { +// 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 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; } diff --git a/package.json b/package.json index 3523fa6..811144c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flight-ops", - "version": "0.57.0", + "version": "0.58.0", "private": true, "scripts": { "dev": "next dev -p 3000",