v0.53.0 — antimeridian routes no longer chord across the map (#120) #123
@@ -1,5 +1,10 @@
|
|||||||
# Changelog
|
# 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
|
## 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.
|
- **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`.
|
- **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`.
|
||||||
|
|||||||
+62
-2
@@ -171,6 +171,16 @@ async function fitToAirports(page: Page, icaos: string[]): Promise<void> {
|
|||||||
if (!f) throw new Error(`airport ${icao} not found in /api/airports`);
|
if (!f) throw new Error(`airport ${icao} not found in /api/airports`);
|
||||||
return [f.properties.lon, f.properties.lat] as [number, number];
|
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][]) => {
|
await page.evaluate((pts: [number, number][]) => {
|
||||||
const m = (window as any).__flightMap;
|
const m = (window as any).__flightMap;
|
||||||
if (!m) throw new Error('map not ready');
|
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<void> {
|
async function expectHoverRoute(page: Page, fromIcao: string, toIcao: string): Promise<void> {
|
||||||
const [from, to] = await Promise.all([airportLonLat(page, fromIcao), airportLonLat(page, toIcao)]);
|
const [from, to] = await Promise.all([airportLonLat(page, fromIcao), airportLonLat(page, toIcao)]);
|
||||||
const near = (a: [number, number], b: [number, number]) =>
|
// ISS-120 (v0.53.0): antimeridian-crossing traces end UNWRAPPED — the dest-side endpoint is ≡ dest
|
||||||
Math.abs(a[0] - b[0]) <= HOVER_ROUTE_EPS_DEG && Math.abs(a[1] - b[1]) <= HOVER_ROUTE_EPS_DEG;
|
// (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 () => {
|
await expect.poll(async () => {
|
||||||
const feats = (await renderedFeatures(page, ['hover-route'])) as Array<{ geometry?: { type: string; coordinates: [number, number][] } }>;
|
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
|
// 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);
|
}).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('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
|
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
||||||
await flyHomeViaApi(page); // deterministic: the starter sits grounded at EDDF
|
await flyHomeViaApi(page); // deterministic: the starter sits grounded at EDDF
|
||||||
|
|||||||
@@ -8,8 +8,26 @@ const AIRPORTS = new Map<string, AirportCoord>([
|
|||||||
['EDDV', { lat: 52.46, lon: 9.68 }],
|
['EDDV', { lat: 52.46, lon: 9.68 }],
|
||||||
['EHAM', { lat: 52.31, lon: 4.76 }],
|
['EHAM', { lat: 52.31, lon: 4.76 }],
|
||||||
['LEMD', { lat: 40.47, lon: -3.56 }], // Madrid — ~1400 km from EDDF for the long-route test
|
['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> = {}): StateSnapshot {
|
function snapshot(overrides: Partial<StateSnapshot> = {}): StateSnapshot {
|
||||||
return {
|
return {
|
||||||
account: { cash: 0, reputation: 0 },
|
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
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+24
-3
@@ -74,16 +74,34 @@ export interface AirportCoord {
|
|||||||
// curved path the plane actually flies (same slerp as lib/sim/movement.ts).
|
// curved path the plane actually flies (same slerp as lib/sim/movement.ts).
|
||||||
export const ROUTE_ARC_SEGMENTS = 24;
|
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
|
// 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
|
// t = i/SEGMENTS for i in 0..SEGMENTS. Pure — the first sample is exactly `from` and the last is
|
||||||
// coords (positionAt returns them verbatim at t=0 and t=1).
|
// ≡ `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][] {
|
export function greatCircleArc(from: AirportCoord, to: AirportCoord): [number, number][] {
|
||||||
const coords: [number, number][] = [];
|
const coords: [number, number][] = [];
|
||||||
for (let i = 0; i <= ROUTE_ARC_SEGMENTS; i++) {
|
for (let i = 0; i <= ROUTE_ARC_SEGMENTS; i++) {
|
||||||
const pos = positionAt(from.lat, from.lon, to.lat, to.lon, i / ROUTE_ARC_SEGMENTS);
|
const pos = positionAt(from.lat, from.lon, to.lat, to.lon, i / ROUTE_ARC_SEGMENTS);
|
||||||
coords.push([pos.lon, pos.lat]);
|
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
|
// 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]!);
|
const arc = greatCircleArc(pts[i]!, pts[i + 1]!);
|
||||||
coords.push(...(i === 0 ? arc : arc.slice(1))); // drop the duplicated joint point
|
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({
|
features.push({
|
||||||
type: 'Feature',
|
type: 'Feature',
|
||||||
geometry: { type: 'LineString', coordinates: coords },
|
geometry: { type: 'LineString', coordinates: coords },
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "flight-ops",
|
"name": "flight-ops",
|
||||||
"version": "0.52.0",
|
"version": "0.53.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
|
|||||||
Reference in New Issue
Block a user