Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4620707abc | ||
|
|
3024606ac8 |
@@ -1,5 +1,9 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.23.2 — 2026-09-20 09:15
|
||||||
|
- **On-board package hover previews the route from the carrier's current position (ISS-040).** Hovering a loaded package in the left pane used to draw the great-circle trace from the package's ORIGIN airport; it now starts at the carrying plane's live lat/lon — identical while grounded at the pickup, correct once the plane has departed with cargo still loaded. New pure builder `hoverTraceGeoJson(from: AirportCoord, toIcao, airportsByIcao)` in `lib/mapData.ts` (sibling of `hoverRouteGeoJson`, same shape/conventions; empty collection when the destination is missing); `components/Map.tsx`'s `applyHoverRef` looks up the carrier via the hover's `planeId` and uses it, falling back to the ICAO trace when no carrier is in state. The `[state]` effect now also re-applies the hover so the trace follows a moving carrier between polls while hovered (idempotent: one small setData + ring filters). Endpoint rings untouched — still ICAO-filtered origin/dest circles
|
||||||
|
- Tests: unit — `lib/mapData.test.ts` gains a `hoverTraceGeoJson` block (exact endpoints from an arbitrary start coord; empty on missing dest). E2E — `e2e/hover.spec.ts`: new test loads a package at EDDF, dispatches the starter to LEMD via API, waits until its live position is >0.2° away from the hub, pauses the sim, hovers the on-board row and asserts some rendered trace segment starts within EPS of the carrier's live lat/lon AND some ends within EPS of LEMD AND no segment starts near EDDF (per-tile clipping handled via the existing cross-segment endpoint pattern); cleanup resumes + fast-forwards to arrival (auto-delivery) and flies home
|
||||||
|
|
||||||
## v0.23.1 — 2026-09-20 08:42
|
## v0.23.1 — 2026-09-20 08:42
|
||||||
- **Toasts repositioned to the map's right edge (ISS-039).** The `.toasts` stack was `position: fixed` at the viewport bottom-right, overlapping the 280px right pane; its `right` changed 12px → 292px (= 280px right pane + 12px inset) so it now sits flush against the map's right edge with the same 12px margin. No JSX/endpoint changes — all positioning lives in that one CSS value
|
- **Toasts repositioned to the map's right edge (ISS-039).** The `.toasts` stack was `position: fixed` at the viewport bottom-right, overlapping the 280px right pane; its `right` changed 12px → 292px (= 280px right pane + 12px inset) so it now sits flush against the map's right edge with the same 12px margin. No JSX/endpoint changes — all positioning lives in that one CSS value
|
||||||
- Tests: e2e wording only — `toasts.spec.ts` header comment, test title, and toast visibility assertion message updated from "bottom-right" to "map right edge / clear of the right pane"; no position assertions added (none existed), suite unchanged at 41 tests in 12 files
|
- Tests: e2e wording only — `toasts.spec.ts` header comment, test title, and toast visibility assertion message updated from "bottom-right" to "map right edge / clear of the right pane"; no position assertions added (none existed), suite unchanged at 41 tests in 12 files
|
||||||
|
|||||||
+12
-2
@@ -8,7 +8,7 @@ import { DEFAULT_STYLE_ID, STYLES, STYLE_STORAGE_KEY, isStyleId, localAssetsAvai
|
|||||||
import type { GeoJsonFeatureCollection } from '../lib/airports';
|
import type { GeoJsonFeatureCollection } from '../lib/airports';
|
||||||
import type { StateSnapshot } from '../lib/game/state';
|
import type { StateSnapshot } from '../lib/game/state';
|
||||||
import type { HoverTarget } from './hoverTypes';
|
import type { HoverTarget } from './hoverTypes';
|
||||||
import { hoverRouteGeoJson, planesGeoJson, routesGeoJson, type AirportCoord, type PlaneFeatureProps } from '../lib/mapData';
|
import { hoverRouteGeoJson, hoverTraceGeoJson, planesGeoJson, routesGeoJson, type AirportCoord, type PlaneFeatureProps } from '../lib/mapData';
|
||||||
import { weatherFor } from '../lib/weather';
|
import { weatherFor } from '../lib/weather';
|
||||||
import { approxLocalTime } from '../lib/localTime';
|
import { approxLocalTime } from '../lib/localTime';
|
||||||
|
|
||||||
@@ -100,6 +100,9 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
stateRef.current = state ?? null;
|
stateRef.current = state ?? null;
|
||||||
applyDynamicRef.current();
|
applyDynamicRef.current();
|
||||||
|
// ISS-040 (v0.23.2): re-apply the hover too, so an on-board package's trace follows a moving
|
||||||
|
// carrier between polls while hovered — idempotent and cheap (one small setData + ring filters).
|
||||||
|
applyHoverRef.current();
|
||||||
}, [state]);
|
}, [state]);
|
||||||
|
|
||||||
// ISS-002 (v0.15.0): hover highlight — same ref + effect pattern as the snapshot above.
|
// ISS-002 (v0.15.0): hover highlight — same ref + effect pattern as the snapshot above.
|
||||||
@@ -138,7 +141,14 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
|||||||
// ICAOs (package origin→dest / relocate fromIcao→toIcao); empty when either endpoint is unknown.
|
// ICAOs (package origin→dest / relocate fromIcao→toIcao); empty when either endpoint is unknown.
|
||||||
const hoverSource = map.getSource(HOVER_ROUTES_SOURCE_ID) as maplibregl.GeoJSONSource | undefined;
|
const hoverSource = map.getSource(HOVER_ROUTES_SOURCE_ID) as maplibregl.GeoJSONSource | undefined;
|
||||||
if (hoverSource) {
|
if (hoverSource) {
|
||||||
void hoverSource.setData(hoverRouteGeoJson(fromIcao, toIcao, airportsIndexRef.current ?? new Map()));
|
// ISS-040 (v0.23.2): an on-board package's trace starts from the carrier plane's LIVE position,
|
||||||
|
// not its origin airport — identical to before while grounded at the pickup, different once it
|
||||||
|
// has departed with cargo still loaded. No carrier in state yet → fall back to the ICAO trace.
|
||||||
|
const idx = airportsIndexRef.current ?? new Map();
|
||||||
|
const carrier = h?.kind === 'package' && h.planeId ? stateRef.current?.planes.find((p) => p.id === h.planeId) : undefined;
|
||||||
|
void hoverSource.setData(
|
||||||
|
carrier ? hoverTraceGeoJson({ lat: carrier.lat, lon: carrier.lon }, toIcao, idx) : hoverRouteGeoJson(fromIcao, toIcao, idx),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// Package hovers ring BOTH endpoints (v0.15.0 behavior); 'route' hovers ring the TARGET only —
|
// Package hovers ring BOTH endpoints (v0.15.0 behavior); 'route' hovers ring the TARGET only —
|
||||||
// the relocation's destination is what ISS-027 asks to see. Plane hovers ring nothing.
|
// the relocation's destination is what ISS-027 asks to see. Plane hovers ring nothing.
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ interface PlaneState {
|
|||||||
maxFuel: number;
|
maxFuel: number;
|
||||||
locationIcao: string;
|
locationIcao: string;
|
||||||
destIcao: string | null;
|
destIcao: string | null;
|
||||||
|
lat: number; // live position — interpolated per snapshot while flying, airport coords when grounded
|
||||||
|
lon: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PackageState {
|
interface PackageState {
|
||||||
@@ -496,3 +498,95 @@ test('hovering an on-board block of a flying plane highlights its route', async
|
|||||||
})
|
})
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('hovering an on-board package while its carrier is mid-flight traces from current position', async ({ page }) => {
|
||||||
|
test.setTimeout(600_000); // self-heal + one flight at scale 1000 + fly-home cleanup
|
||||||
|
await flyHomeViaApi(page);
|
||||||
|
|
||||||
|
// Full tank before departure — the EDDF↔LEMD round trip burns ~4970 L of the 6300 L tank.
|
||||||
|
const refuel = await page.request.post('/api/planes/plane-starter/refuel');
|
||||||
|
expect(refuel.status()).toBe(200);
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
await waitForAirportCount(page);
|
||||||
|
|
||||||
|
// Load + fly via the API (same pattern as e2e/map-interactions.spec.ts), then speed up.
|
||||||
|
const pkg = await createPackage(page, HUB, DEST);
|
||||||
|
const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } });
|
||||||
|
expect(load.status()).toBe(200);
|
||||||
|
const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
|
||||||
|
expect(fly.status()).toBe(200);
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
|
||||||
|
// Wait until the starter is GENUINELY mid-flight — its live position clearly away from EDDF in
|
||||||
|
// both axes (well past HOVER_ROUTE_EPS_DEG) so "the trace starts at the plane" and "it starts at
|
||||||
|
// the pickup airport" are distinguishable. The ~10 s leg at scale 1000 gives a wide window.
|
||||||
|
const hubLonLat = await airportLonLat(page, HUB);
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
||||||
|
if (!p || p.status !== 'flying') return false;
|
||||||
|
return Math.abs(p.lon - hubLonLat[0]) > 0.2 && Math.abs(p.lat - hubLonLat[1]) > 0.2;
|
||||||
|
}, { timeout: 60_000, message: 'waiting for plane-starter to be clearly away from the hub' })
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
// Freeze mid-flight so the position is stable while we assert (same pause pattern as the
|
||||||
|
// flying-plane hover test above): paused, every /api/state snapshot reports the same lat/lon.
|
||||||
|
await postSim(page, { paused: true });
|
||||||
|
|
||||||
|
const row = page.locator(`[data-testid="onboard-package-${pkg.id}"]`);
|
||||||
|
await expect(row, 'the loaded package appears in the On board section').toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
// Both endpoints must be in-viewport for queryRenderedFeatures to see the trace.
|
||||||
|
await fitToAirports(page, [HUB, DEST]);
|
||||||
|
|
||||||
|
const live = (await getState(page)).planes.find((x) => x.id === 'plane-starter')!;
|
||||||
|
const liveLonLat: [number, number] = [live.lon, live.lat]; // stable — the sim is paused
|
||||||
|
const destLonLat = await airportLonLat(page, DEST);
|
||||||
|
|
||||||
|
await row.hover();
|
||||||
|
// ISS-040 (v0.23.2): the trace starts at the carrier's LIVE position and ends at the destination,
|
||||||
|
// and NO segment may start near the pickup airport (the old origin-based behavior). Same per-tile
|
||||||
|
// clipping/quantization caveats as expectHoverRoute — check endpoints across all segments with EPS.
|
||||||
|
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;
|
||||||
|
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;
|
||||||
|
const startsAtLive = segs.some((f) => near(f.geometry.coordinates[0], liveLonLat));
|
||||||
|
const endsAtDest = segs.some((f) => near(f.geometry.coordinates[f.geometry.coordinates.length - 1], destLonLat));
|
||||||
|
const noneStartsAtHub = !segs.some((f) => near(f.geometry.coordinates[0], hubLonLat));
|
||||||
|
return startsAtLive && endsAtDest && noneStartsAtHub;
|
||||||
|
}, { timeout: 15_000, message: 'waiting for the hover-route trace to run from the live position to the destination' }).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);
|
||||||
|
|
||||||
|
// Resume and fast-forward to arrival — landing at DEST auto-delivers the package (v0.12.0), so
|
||||||
|
// flying home afterwards leaves the starter clean for the other specs.
|
||||||
|
await postSim(page, { timeScale: SCALE, paused: false });
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
||||||
|
return !!p && p.status === 'available' && p.locationIcao === DEST;
|
||||||
|
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` })
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
// ISS-011: the arrival above auto-slown the sim to 1×, so re-set SCALE for this leg.
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||||
|
expect(home.status()).toBe(200);
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao === HUB, {
|
||||||
|
timeout: 120_000,
|
||||||
|
message: 'waiting for plane-starter to land back at the hub',
|
||||||
|
})
|
||||||
|
.toBe(true);
|
||||||
|
});
|
||||||
|
|||||||
+18
-1
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import type { StateSnapshot } from './game/state';
|
import type { StateSnapshot } from './game/state';
|
||||||
import { planesGeoJson, routesGeoJson, hoverRouteGeoJson, greatCircleArc, ROUTE_ARC_SEGMENTS, type AirportCoord } from './mapData';
|
import { planesGeoJson, routesGeoJson, hoverRouteGeoJson, hoverTraceGeoJson, greatCircleArc, ROUTE_ARC_SEGMENTS, type AirportCoord } from './mapData';
|
||||||
import { haversineKm } from './geo';
|
import { haversineKm } from './geo';
|
||||||
|
|
||||||
const AIRPORTS = new Map<string, AirportCoord>([
|
const AIRPORTS = new Map<string, AirportCoord>([
|
||||||
@@ -176,6 +176,23 @@ describe('hoverRouteGeoJson (ISS-022/025/027 v0.22.0 — hover route trace)', ()
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('hoverTraceGeoJson (ISS-040 v0.23.2 — on-board package trace from the carrier\'s live position)', () => {
|
||||||
|
it('draws one great-circle line from the given start coord to the dest airport with exact endpoints', () => {
|
||||||
|
const from = { lat: 51.2, lon: 9.1 }; // a mid-flight position — not an airport
|
||||||
|
const fc = hoverTraceGeoJson(from, 'EHAM', AIRPORTS);
|
||||||
|
expect(fc.features).toHaveLength(1);
|
||||||
|
expect(fc.features[0].properties).toEqual({ planeId: '' }); // pure overlay — no owning plane
|
||||||
|
const coords = fc.features[0].geometry.coordinates;
|
||||||
|
expect(coords.length).toBe(ROUTE_ARC_SEGMENTS + 1); // sampled arc, not a 2-point chord
|
||||||
|
expect(coords[0]).toEqual([9.1, 51.2]); // live start — exact endpoint
|
||||||
|
expect(coords[coords.length - 1]).toEqual([4.76, 52.31]); // EHAM — exact endpoint
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty collection when the dest airport is missing from the dataset', () => {
|
||||||
|
expect(hoverTraceGeoJson({ lat: 0, lon: 0 }, 'ZZZZ', AIRPORTS)).toEqual({ type: 'FeatureCollection', features: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('greatCircleArc (v0.12.2 — curved route traces)', () => {
|
describe('greatCircleArc (v0.12.2 — curved route traces)', () => {
|
||||||
it('samples >2 points with the endpoints exactly at the airport coords', () => {
|
it('samples >2 points with the endpoints exactly at the airport coords', () => {
|
||||||
const arc = greatCircleArc(AIRPORTS.get('EDDF')!, AIRPORTS.get('EHAM')!);
|
const arc = greatCircleArc(AIRPORTS.get('EDDF')!, AIRPORTS.get('EHAM')!);
|
||||||
|
|||||||
@@ -112,6 +112,32 @@ export function hoverRouteGeoJson(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ISS-040 (v0.23.2): the on-board package hover trace — same shape as hoverRouteGeoJson but the
|
||||||
|
// start is a live COORDINATE (the carrier plane's current position), not an airport ICAO: once the
|
||||||
|
// plane has departed with cargo still loaded, the preview must begin where it actually is. Empty
|
||||||
|
// collection when the destination is missing from the dataset — never guessed.
|
||||||
|
export function hoverTraceGeoJson(
|
||||||
|
from: AirportCoord,
|
||||||
|
toIcao: string,
|
||||||
|
airportsByIcao: ReadonlyMap<string, AirportCoord>,
|
||||||
|
): FeatureCollection<MapLineFeature> {
|
||||||
|
const to = airportsByIcao.get(toIcao);
|
||||||
|
if (!to) return EMPTY_LINES;
|
||||||
|
return {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: {
|
||||||
|
type: 'LineString',
|
||||||
|
coordinates: greatCircleArc(from, to),
|
||||||
|
},
|
||||||
|
properties: { planeId: '' }, // no owning plane — a pure hover overlay
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// One LineString per FLYING plane — its current leg from locationIcao to dest_icao.
|
// 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
|
// Grounded planes draw nothing; a flying plane missing either endpoint from the
|
||||||
// dataset is skipped, never guessed. The line is sampled along the great circle
|
// dataset is skipped, never guessed. The line is sampled along the great circle
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "flight-ops",
|
"name": "flight-ops",
|
||||||
"version": "0.23.1",
|
"version": "0.23.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
|
|||||||
Reference in New Issue
Block a user