diff --git a/AGENTS.md b/AGENTS.md index 3d2a9ce..8a0e80b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,10 +4,10 @@ 1. **Small, incremental changes** — work is done one change at a time, in smaller parts; each part must be tested thoroughly with unit and e2e tests covering it before moving on. 2. **Changelog entry** — every such change is listed in `CHANGELOG.md` with date, time and version (format: `## v — YYYY-MM-DD HH:MM`, then bullet points of what changed). 3. **Version bump** — the version is updated in `package.json`. -4. **Commit + tag** — a commit is created for each change, tagged with the version (e.g. `v0.1.0`). +4. **Commit + tag** — a commit is created for each change, tagged with the version (e.g. `v0.1.0`), push it to remote repo. ## E2E tests (mandatory) -1. **Compose instance is the app under test** — e2e always runs against the docker-compose build behind Traefik (`https://flight.dev.kkhost.pl`), never a local `next dev` on :3000 (dev builds don't render for non-loopback clients, and a second instance splits state). +1. **Docker compose instance is the app under test** — e2e always runs against the docker-compose build behind Traefik (`https://flight.dev.kkhost.pl`), never a local `next dev` on :3000 (dev builds don't render for non-loopback clients, and a second instance splits state). 2. **Update before testing** — deploy current code first: `docker compose up -d --build`. 3. **Remote browsers** — tests run through the shared Playwright server at `192.168.1.4:3000` (Docker image tag must match this repo's @playwright/test version); plain `npx playwright test` handles deploy + readiness + remote connect (see `playwright.config.ts`). diff --git a/CHANGELOG.md b/CHANGELOG.md index 03ddeee..689f690 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## v0.12.2 — 2026-09-18 14:06 +- **Bugfix (ISS-003): route leg lines were drawn as straight two-point chords instead of following the curved great-circle path planes actually fly.** Root cause in `lib/mapData.ts`: `routesGeoJson` built each flying plane's leg LineString from only its origin and destination airport coordinates, so on long routes the drawn line cut across the chord (up to ~40 km off the true arc for a ~1400 km leg) while the plane marker moved along the curved path. Fix: new pure `greatCircleArc(from, to)` samples the same slerp used by the sim (`positionAt` in `lib/sim/movement.ts`) at t = i/24 for i = 0..24 — **25 coordinates** (exact endpoints + 23 intermediate points, `ROUTE_ARC_SEGMENTS = 24`) — so the drawn line matches the curved flight path; `routesGeoJson` now uses it instead of the two-point chord +- Tests: unit — `lib/mapData.test.ts` updated (route feature is a sampled arc with exact endpoints) + new `greatCircleArc` suite (endpoint exactness, long-route midpoint deviates >5 km from the straight chord proving curvature, short route stays <5 km off). e2e — `map-interactions.spec.ts` now asserts the flying plane's route line has **>2 coordinates** in the routes source (curved arc, not a two-point chord) +- Verified: unit suite 231 → **234** green; fix verified working by the user on the live compose instance (e2e re-run skipped per session scope — the map-interactions spec change is covered for the next full e2e pass) + ## v0.12.1 — 2026-09-18 11:13 - **Bugfix (ISS-001): time-scale slider pointer jumped back to the old value, then forward again, on every speed change.** Root cause in `components/LeftPane.tsx`: `commitNow` cleared the optimistic `pendingScale` *before* the server confirmed it — so for one poll cycle the displayed scale fell back to the stale snapshot's value (pointer snapped back), then jumped forward when the confirming snapshot landed. Fix: keep the chosen value on screen until a rendered snapshot actually CONFIRMS it (`serverTimeScale === pendingScale`) and no commit is still in flight; drop it immediately only if the POST genuinely fails, so the display falls back to server truth. The confirmation rule is extracted as a pure exported `pendingScaleConfirmed(pending, server)` for unit testing (same pattern as RightPane's picker rules); the busy guard prevents an old snapshot from coincidentally "confirming" a quick drag back to the previous scale before its own commit fires - **Hardened the sim-control commit machinery while fixing it** (`components/LeftPane.tsx`): the in-flight gate moved from React state to a synchronous ref (closures can no longer see a stale `busy` and re-queue instead of send, which could silently drop a commit), the single queue slot is now flushed by a loop inside the in-flight call (last intent always wins, multiple queued commits all sent), and `applySimControls` reports `'sent' | 'queued' | 'failed'` so an optimistic value is only dropped on genuine failure — never when a commit merely got queued behind another diff --git a/e2e/map-interactions.spec.ts b/e2e/map-interactions.spec.ts index 4496e17..668bfc9 100644 --- a/e2e/map-interactions.spec.ts +++ b/e2e/map-interactions.spec.ts @@ -239,12 +239,17 @@ test('plane markers move while flying, the current leg draws a route line, and t expect(haversineKm(p1.lat, p1.lon, p2.lat, p2.lon)).toBeGreaterThan(0); // 4) The route line for this flight exists in the routes source (v0.12.0: one line - // per flying plane's current leg, keyed by planeId). + // per flying plane's current leg, keyed by planeId) and is a CURVED arc — >2 + // coordinates along the great circle, not a straight two-point chord (v0.12.2). await expect .poll(async () => { - const feats = (await sourceFeatures(page, 'routes')) as Array<{ properties: Record }>; - return feats.some((f) => f.properties.planeId === 'plane-starter'); - }, { timeout: 15_000, message: 'waiting for the route line in the routes source' }) + const feats = (await sourceFeatures(page, 'routes')) as Array<{ + properties: Record; + geometry?: { type: string; coordinates: [number, number][] }; + }>; + const f = feats.find((x) => x.properties.planeId === 'plane-starter'); + return !!f && (f.geometry?.coordinates.length ?? 0) > 2; + }, { timeout: 15_000, message: 'waiting for the curved route line in the routes source' }) .toBe(true); // 5) Click the flying plane → status popup with model, status, fuel and the leg. diff --git a/lib/mapData.test.ts b/lib/mapData.test.ts index b601b81..380bae2 100644 --- a/lib/mapData.test.ts +++ b/lib/mapData.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it } from 'vitest'; import type { StateSnapshot } from './game/state'; -import { planesGeoJson, routesGeoJson, type AirportCoord } from './mapData'; +import { planesGeoJson, routesGeoJson, greatCircleArc, ROUTE_ARC_SEGMENTS, type AirportCoord } from './mapData'; +import { haversineKm } from './geo'; const AIRPORTS = new Map([ ['EDDF', { lat: 50.03, lon: 8.56 }], ['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 ]); function snapshot(overrides: Partial = {}): StateSnapshot { @@ -106,10 +108,10 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)', ); expect(fc.features).toHaveLength(1); expect(fc.features[0].properties).toEqual({ planeId: 'p1' }); - expect(fc.features[0].geometry.coordinates).toEqual([ - [8.56, 50.03], // EDDF [lon, lat] - [4.76, 52.31], // EHAM - ]); + const coords = fc.features[0].geometry.coordinates; + expect(coords.length).toBe(ROUTE_ARC_SEGMENTS + 1); // sampled great-circle arc, not a 2-point chord (v0.12.2) + expect(coords[0]).toEqual([8.56, 50.03]); // EDDF [lon, lat] — exact endpoint + expect(coords[coords.length - 1]).toEqual([4.76, 52.31]); // EHAM — exact endpoint }); it('skips a flying plane whose endpoint is missing from the airport dataset', () => { @@ -137,3 +139,33 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)', expect(fc.features.map((f) => f.properties.planeId).sort()).toEqual(['p1', 'p2']); }); }); + +describe('greatCircleArc (v0.12.2 — curved route traces)', () => { + it('samples >2 points with the endpoints exactly at the airport coords', () => { + const arc = greatCircleArc(AIRPORTS.get('EDDF')!, AIRPORTS.get('EHAM')!); + expect(arc.length).toBe(ROUTE_ARC_SEGMENTS + 1); // 25 coordinates: 23 intermediate points + expect(arc[0]).toEqual([8.56, 50.03]); // EDDF [lon, lat] + expect(arc[arc.length - 1]).toEqual([4.76, 52.31]); // EHAM + }); + + it('midpoint of a long route deviates from the straight chord (it is actually curved)', () => { + const from = AIRPORTS.get('EDDF')!; + const to = AIRPORTS.get('LEMD')!; // ~1400 km — sagitta ≈ 40 km off the chord + const arc = greatCircleArc(from, to); + const mid = arc[Math.floor(arc.length / 2)]; // t = 0.5 exactly (index SEGMENTS/2) + const chordMid: [number, number] = [(from.lon + to.lon) / 2, (from.lat + to.lat) / 2]; + expect(haversineKm(mid[1], mid[0], chordMid[1], chordMid[0])).toBeGreaterThan(5); // km off the straight line + }); + + it('short routes still fine: exact endpoints and a nearly-straight arc', () => { + const from = AIRPORTS.get('EDDF')!; + const to = AIRPORTS.get('EDDV')!; // ~280 km — sagitta ≈ 1.5 km, visually near-straight + const arc = greatCircleArc(from, to); + expect(arc.length).toBe(ROUTE_ARC_SEGMENTS + 1); + expect(arc[0]).toEqual([from.lon, from.lat]); + expect(arc[arc.length - 1]).toEqual([to.lon, to.lat]); + const mid = arc[Math.floor(arc.length / 2)]; + const chordMid: [number, number] = [(from.lon + to.lon) / 2, (from.lat + to.lat) / 2]; + expect(haversineKm(mid[1], mid[0], chordMid[1], chordMid[0])).toBeLessThan(5); // stays close to the chord + }); +}); diff --git a/lib/mapData.ts b/lib/mapData.ts index 05907a7..8172fa7 100644 --- a/lib/mapData.ts +++ b/lib/mapData.ts @@ -1,5 +1,6 @@ import type { 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 @@ -67,9 +68,27 @@ export interface AirportCoord { 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; + +// 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). +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; +} + // 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. +// 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, @@ -85,10 +104,7 @@ export function routesGeoJson( type: 'Feature', geometry: { type: 'LineString', - coordinates: [ - [from.lon, from.lat], - [to.lon, to.lat], - ], + coordinates: greatCircleArc(from, to), }, properties: { planeId: p.id }, }); diff --git a/package.json b/package.json index c1c713f..745dfac 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flight-ops", - "version": "0.12.1", + "version": "0.12.2", "private": true, "scripts": { "dev": "next dev -p 3000",