fix: v0.12.2 — ISS-003 route leg lines now follow the curved great-circle path (greatCircleArc, 25 sampled points via sim slerp) instead of a straight two-point chord; e2e asserts >2 coordinates in the routes source
This commit is contained in:
+37
-5
@@ -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<string, AirportCoord>([
|
||||
['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> = {}): 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
|
||||
});
|
||||
});
|
||||
|
||||
+21
-5
@@ -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<string, AirportCoord>,
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user