From cd2c76689587f59cd8d1132feb2f594eee1c38c3 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Tue, 22 Sep 2026 14:48:53 +0000 Subject: [PATCH] Unwrap great-circle arc longitudes across the antimeridian so routes don't chord across the map (ISS-120) --- CHANGELOG.md | 5 +++ e2e/hover.spec.ts | 64 ++++++++++++++++++++++++++++++++++++-- lib/mapData.test.ts | 76 +++++++++++++++++++++++++++++++++++++++++++++ lib/mapData.ts | 27 ++++++++++++++-- package.json | 2 +- 5 files changed, 168 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26edd3c..26a1be4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## v0.53.0 — 2026-09-22 14:47 +- **Antimeridian routes no longer chord across the map (ISS-120).** Routes whose great circle crosses ±180° (e.g. Auckland → Fua'amotu, Tonga) rendered as a line spanning the whole map: `greatCircleArc` sampled `positionAt`, which wraps every sample's longitude into [−180, 180], so consecutive samples jumped +179.x → −179.x and MapLibre — which draws LineStrings as-is with no ±180 splitting — connected them across the globe. New module-private `unwrapLons` in `lib/mapData.ts` re-expresses each arc's sample sequence in continuous longitudes (first sample keeps its raw lon; every later one is shifted by ±360 relative to the previous UNWRAPPED value until |Δlon| ≤ 180), applied inside `greatCircleArc` so all four line builders (`routesGeoJson`, `queuedRoutesGeoJson`, `hoverRouteGeoJson`, `hoverTraceGeoJson`) are fixed at once. One line stays one LineString — no splitting; out-of-range values render on MapLibre's adjacent world copies (`renderWorldCopies` is default-on). `queuedRoutesGeoJson` gets a second global pass after concatenation: each leg's arc unwraps 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) and the re-unwrap keeps multi-leg chains continuous. Non-crossing routes are byte-identical (no |Δlon| > 180 anywhere → the pass is a no-op). +- **Tests.** Unit +4 in `lib/mapData.test.ts` (new "antimeridian crossing" block, real dataset pairs NZAA/NFTF/NZCH): eastward and westward crossings stay continuous (every consecutive |Δlon| < 180), endpoints exact mod 360 with the first sample still the raw origin, sampled chord length ≈ haversine within 1%, a non-crossing route's lons stay in [−180, 180], and a grounded plane's two-leg queue chain crossing ±180 stays continuous across the joint (the regression `queuedRoutesGeoJson`'s global pass guards). E2E +1 in `e2e/hover.spec.ts`: hovering an antimeridian-crossing package (hook-created NZAA → NFTF) draws one continuous trace — no rendered segment may contain a ±180 longitude jump; `expectHoverRoute`'s endpoint check now compares longitudes on the circle (mod 360, identical for any pair within ±180 so existing routes are unaffected) and `fitToAirports` unwraps crossing pairs so their bounds follow the short arc across the date line instead of spanning the wrong side of the globe. +- **Known limitation (follow-up).** Plane MARKERS still use the snapshot's wrapped lat/lon (`lib/game/state.ts` → `positionAt`), so a plane mid-crossing teleports 360° on its marker at the instant it passes ±180 while its route line stays continuous. Fixing needs airport coordinates inside `planesGeoJson` (signature change + `Map.tsx` call site) — deliberately out of scope for this fix. + ## v0.52.0 — 2026-09-22 10:45 - **Fixed world clock, no player time controls (ISS-117).** The pause button and the 1×–1000× scale slider are gone from the left pane (`components/LeftPane.tsx`) — the world now always runs at a fixed `WORLD_TIME_SCALE = 24` (new client-safe `lib/worldClock.ts`, re-exported by the engine). The server advances time in a background tick: new `instrumentation.ts` starts `startBackgroundTick()` (1 s interval, singleton-guarded) under `NEXT_RUNTIME === 'nodejs'`, so the sim moves even with no open browser. **Idle-pause:** while no plane is flying or in repair the clock is frozen exactly — pure `shouldAdvanceTime(planes)` in `lib/sim/engine.ts` gates every tick (no catch-up jump on wake; `lastRealTickMs` still updates). Fresh worlds start at year 2000 (`STARTING_CLOCK_MS = Date.UTC(2000, 0, 1)` in `lib/game/seed.ts`; existing saves untouched — no migration). The client clock (`lib/useSimClock.ts`) extrapolates between polls at the fixed rate and freezes while idle, mirroring the server. - **API.** `POST /api/sim` deleted (route + OpenAPI entry; spec now lists twelve endpoints) and its controls removed from the engine (`applyTimeControls`, `clampTimeScale`, MIN/MAX_TIME_SCALE). The `/api/state` snapshot's `simState` is now `{ clockMs }` only — no `timeScale`/`paused`. New test-only hook `POST /api/test/set-timescale` behind the existing `FLIGHT_TEST_HOOKS=1` gate (sibling of `/api/test/set-health`, same 404-when-disabled convention): body `{ timeScale: finite number in [1, 100_000] }` else 400; ticks first under the sim lock and returns `{ clockMs, timeScale }`. The e2e suite's per-spec `setScale` helpers now call it (and verify from the response); every spec restores the world rate in `afterEach`. diff --git a/e2e/hover.spec.ts b/e2e/hover.spec.ts index 5c7d130..64c8d85 100644 --- a/e2e/hover.spec.ts +++ b/e2e/hover.spec.ts @@ -171,6 +171,16 @@ async function fitToAirports(page: Page, icaos: string[]): Promise { if (!f) throw new Error(`airport ${icao} not found in /api/airports`); return [f.properties.lon, f.properties.lat] as [number, number]; }); + // ISS-120 (v0.53.0): raw min/max bounds for a pair straddling ±180 span the WRONG side of the globe + // (−175…+174 covers the Atlantic, not the Pacific) — unwrap each point relative to its predecessor so + // the bounds follow the short arc across the date line. Non-crossing pairs are unchanged. + for (let i = 1; i < points.length; i++) { + let lon = points[i]![0]; + const prev = points[i - 1]![0]; + while (lon - prev > 180) lon -= 360; + while (prev - lon > 180) lon += 360; + points[i]![0] = lon; + } await page.evaluate((pts: [number, number][]) => { const m = (window as any).__flightMap; if (!m) throw new Error('map not ready'); @@ -217,8 +227,13 @@ const HOVER_ROUTE_EPS_DEG = 0.05; // ~5 km: above worst-case quantization at any async function expectHoverRoute(page: Page, fromIcao: string, toIcao: string): Promise { const [from, to] = await Promise.all([airportLonLat(page, fromIcao), airportLonLat(page, toIcao)]); - const near = (a: [number, number], b: [number, number]) => - Math.abs(a[0] - b[0]) <= HOVER_ROUTE_EPS_DEG && Math.abs(a[1] - b[1]) <= HOVER_ROUTE_EPS_DEG; + // ISS-120 (v0.53.0): antimeridian-crossing traces end UNWRAPPED — the dest-side endpoint is ≡ dest + // (mod 360) but sits at e.g. +184.9 instead of −175.1, so compare longitudes on the circle. For any + // two lons within ±180 of each other this equals the plain |Δlon| — existing routes are unaffected. + const near = (a: [number, number], b: [number, number]) => { + const dLon = Math.abs(a[0] - b[0]); + return Math.min(dLon, 360 - dLon) <= HOVER_ROUTE_EPS_DEG && Math.abs(a[1] - b[1]) <= HOVER_ROUTE_EPS_DEG; + }; await expect.poll(async () => { const feats = (await renderedFeatures(page, ['hover-route'])) as Array<{ geometry?: { type: string; coordinates: [number, number][] } }>; // Features are plain objects with in-page-evaluated geometry (see renderedFeatures). The filter @@ -344,6 +359,51 @@ test('hovering an open package highlights its endpoint airports', async ({ page }).toBe(0); }); +// ISS-120 (v0.53.0): Auckland → Fua'amotu (Tonga) — both in the world dataset, and their short great +// circle crosses ±180 EASTWARD. Before the fix the arc's wrapped samples jumped +179.x → −179.x and +// MapLibre connected them with a chord across the whole map; now the trace is one continuous line. +test('hovering an antimeridian-crossing package draws one continuous trace, no chord across the map', async ({ page }) => { + test.setTimeout(300_000); // self-heal can take minutes after an interrupted run + await flyHomeViaApi(page); + + const pkg = await createPackage(page, 'NZAA', 'NFTF'); + + await page.goto('/'); + await waitForAirportCount(page); + + const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`); + await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 }); + + // fitToAirports unwraps crossing pairs (ISS-120) so both endpoints are in-viewport. + await fitToAirports(page, ['NZAA', 'NFTF']); + + await row.hover(); + await expectHoverRoute(page, 'NZAA', 'NFTF'); // endpoint check is mod-360 aware now (ISS-120) + + // The regression itself: no rendered segment may contain a ±180 longitude jump — that was the chord. + await expect.poll(async () => { + const feats = (await renderedFeatures(page, ['hover-route'])) as Array<{ geometry?: { type: string; coordinates: [number, number][] } }>; + const segs = feats.filter( + (f): f is { geometry: { type: 'LineString'; coordinates: [number, number][] } } => + !!f.geometry && f.geometry.type === 'LineString' && Array.isArray(f.geometry.coordinates) && f.geometry.coordinates.length >= 2, + ); + if (!segs.length) return false; // none yet — keep polling + for (const s of segs) { + const c = s.geometry.coordinates; + for (let i = 1; i < c.length; i++) { + if (Math.abs(c[i]![0] - c[i - 1]![0]) >= 180) return false; // a wrapped jump — the chord bug is back + } + } + return true; + }, { timeout: 15_000, message: 'waiting for the hover-route trace to be continuous across the antimeridian' }).toBe(true); + + await unhover(page); + await expect.poll(async () => (await renderedFeatures(page, ['hover-route'])).length, { + timeout: 15_000, + message: 'waiting for the hover route trace to clear after mouseleave', + }).toBe(0); +}); + test('hovering an on-board cargo row draws its trace and keeps the plane halo', async ({ page }) => { test.setTimeout(300_000); // self-heal can take minutes after an interrupted run await flyHomeViaApi(page); // deterministic: the starter sits grounded at EDDF diff --git a/lib/mapData.test.ts b/lib/mapData.test.ts index 341121a..48eeb62 100644 --- a/lib/mapData.test.ts +++ b/lib/mapData.test.ts @@ -8,8 +8,26 @@ const AIRPORTS = new Map([ ['EDDV', { lat: 52.46, lon: 9.68 }], ['EHAM', { lat: 52.31, lon: 4.76 }], ['LEMD', { lat: 40.47, lon: -3.56 }], // Madrid — ~1400 km from EDDF for the long-route test + // ISS-120 (v0.53.0): real dataset pairs whose great circle crosses the antimeridian (±180). + ['NZAA', { lat: -37.01199, lon: 174.786331 }], // Auckland — east of +170 + ['NFTF', { lat: -21.241438, lon: -175.149164 }], // Fua'amotu (Tonga) — west of −175; NZAA→NFTF crosses ±180 eastward + ['NZCH', { lat: -43.489029, lon: 172.532065 }], // Christchurch — for the queue-chain joint test ]); +// ISS-120: longitude difference on the circle — two lons are "equal" when they differ by a multiple of 360. +const modDiff = (a: number, b: number): number => { + const d = Math.abs(a - b) % 360; + return Math.min(d, 360 - d); +}; + +// ISS-120: every consecutive pair of samples must be < 180° apart in longitude — the property that +// keeps MapLibre from drawing a chord across the map. +function expectContinuousLons(coords: [number, number][]): void { + for (let i = 1; i < coords.length; i++) { + expect(Math.abs(coords[i]![0] - coords[i - 1]![0]), `jump between samples ${i - 1} and ${i}`).toBeLessThan(180); + } +} + function snapshot(overrides: Partial = {}): StateSnapshot { return { account: { cash: 0, reputation: 0 }, @@ -320,3 +338,61 @@ describe('greatCircleArc (v0.12.2 — curved route traces)', () => { expect(haversineKm(mid[1], mid[0], chordMid[1], chordMid[0])).toBeLessThan(5); // stays close to the chord }); }); + +describe('greatCircleArc — antimeridian crossing (ISS-120 v0.53.0)', () => { + const from = AIRPORTS.get('NZAA')!; // Auckland, lon +174.8 + const to = AIRPORTS.get('NFTF')!; // Fua'amotu, lon −175.1 — the short arc crosses ±180 EASTWARD + + it('eastward crossing: continuous lons (|Δlon| < 180), endpoints exact mod 360, length ≈ haversine', () => { + const arc = greatCircleArc(from, to); + expect(arc.length).toBe(ROUTE_ARC_SEGMENTS + 1); + expectContinuousLons(arc); // no wrapped ±180 jump anywhere — the chord bug is gone + expect(arc[0]).toEqual([from.lon, from.lat]); // first sample = raw origin, exact + const last = arc[arc.length - 1]!; + expect(modDiff(last[0], to.lon)).toBeLessThan(1e-9); // last ≡ dest (mod 360) — here unwrapped to +184.8 + expect(Math.abs(last[1] - to.lat)).toBeLessThan(1e-9); + expect(arc.some((c) => c[0] > 180)).toBe(true); // the crossing samples sit on the east world copy + + const dist = haversineKm(from.lat, from.lon, to.lat, to.lon); + let sum = 0; + for (let i = 1; i < arc.length; i++) sum += haversineKm(arc[i - 1]![1], arc[i - 1]![0], arc[i]![1], arc[i]![0]); + expect(Math.abs(sum - dist) / dist).toBeLessThan(0.01); // sampled chord length ≈ true great-circle distance + }); + + it('westward crossing (reversed pair): continuous lons below −180, endpoints exact mod 360', () => { + const arc = greatCircleArc(to, from); + expectContinuousLons(arc); + expect(arc[0]).toEqual([to.lon, to.lat]); // raw origin, exact + const last = arc[arc.length - 1]!; + expect(modDiff(last[0], from.lon)).toBeLessThan(1e-9); // ≡ +174.8 (mod 360) — here unwrapped to −185.2 + expect(Math.abs(last[1] - from.lat)).toBeLessThan(1e-9); + expect(arc.some((c) => c[0] < -180)).toBe(true); // the crossing samples sit on the west world copy + }); + + it('non-crossing routes are untouched: lons stay in [−180, 180]', () => { + const arc = greatCircleArc(AIRPORTS.get('EDDF')!, AIRPORTS.get('LEMD')!); + expectContinuousLons(arc); + for (const c of arc) expect(c[0]).toBeGreaterThanOrEqual(-180); + for (const c of arc) expect(c[0]).toBeLessThanOrEqual(180); + }); + + it('queuedRoutesGeoJson: a chain crossing the antimeridian stays continuous across the joint', () => { + // Grounded at NZAA with queue [NFTF, NZCH]: leg 1 crosses ±180 eastward (ends unwrapped at +184.8), + // leg 2 restarts at the RAW −175.1 — without the global re-unwrap the joint would jump ~360°. + const fc = queuedRoutesGeoJson( + [ + { id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'NZAA', locationName: 'A', destIcao: null, destName: null, departureSimTime: null, lat: -37.01, lon: 174.79, health: 100, repairUntilSimTime: null, queue: ['NFTF', 'NZCH'] }, + ], + AIRPORTS, + ); + expect(fc.features).toHaveLength(1); + const coords = fc.features[0]!.geometry.coordinates; + expect(coords.length).toBe(2 * ROUTE_ARC_SEGMENTS + 1); // two arcs, joint dropped once + expectContinuousLons(coords); // the whole chain — including across the NFTF joint + expect(coords[0]).toEqual([from.lon, from.lat]); // starts at the plane's airport, raw + const last = coords[coords.length - 1]!; + const nzch = AIRPORTS.get('NZCH')!; + expect(modDiff(last[0], nzch.lon)).toBeLessThan(1e-9); // ends ≡ NZCH (mod 360) + expect(Math.abs(last[1] - nzch.lat)).toBeLessThan(1e-9); + }); +}); diff --git a/lib/mapData.ts b/lib/mapData.ts index 7f0e46b..d3d5a9c 100644 --- a/lib/mapData.ts +++ b/lib/mapData.ts @@ -74,16 +74,34 @@ export interface AirportCoord { // 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 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; + 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 — endpoints are exactly the airport -// coords (positionAt returns them verbatim at t=0 and t=1). +// 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 coords; + return unwrapLons(coords); } // ISS-022/025/027 (v0.22.0): the hover route trace — one great-circle LineString between two @@ -195,6 +213,9 @@ export function queuedRoutesGeoJson( 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 }, diff --git a/package.json b/package.json index f8f2c12..f49a7ee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flight-ops", - "version": "0.52.0", + "version": "0.53.0", "private": true, "scripts": { "dev": "next dev -p 3000",