fix: v0.2.1 — clamp map zoom to 3–14

This commit is contained in:
2026-09-16 19:50:26 +00:00
parent eef3430ad5
commit abdeca2a6a
4 changed files with 89 additions and 1 deletions
+4
View File
@@ -1,5 +1,9 @@
# Changelog # Changelog
## v0.2.1 — 2026-09-16 19:49
- Clamp map zoom to the useful gameplay range: `minZoom: 3` / `maxZoom: 14` on the MapLibre options in `components/Map.tsx` (exported as `MIN_ZOOM`/`MAX_ZOOM`) — wheel travel from the Europe overview is now ~10 levels instead of ~16, and street-level zoom past the z12 pitch transition no longer exists
- e2e: new test that wheels well past both limits via `page.mouse.wheel` (basemap tile fetches blocked for speed/determinism; clamping is pure camera behavior) and asserts via `expect.poll` on the map instance that `getZoom()` settles at 14 / 3, i.e. clamped within [3, 14]
## v0.2.0 — 2026-09-16 18:32 ## v0.2.0 — 2026-09-16 18:32
- Docker deployment: multi-stage `Dockerfile` (node:22-alpine; deps → build → runtime) running `next start` on port 3000; no `output: 'standalone'` — the runtime stage keeps full `node_modules` because `/api/mlworker/[file]` reads maplibre-gl dist files from disk at runtime (and `/api/airports` reads `data/*.csv`) - Docker deployment: multi-stage `Dockerfile` (node:22-alpine; deps → build → runtime) running `next start` on port 3000; no `output: 'standalone'` — the runtime stage keeps full `node_modules` because `/api/mlworker/[file]` reads maplibre-gl dist files from disk at runtime (and `/api/airports` reads `data/*.csv`)
- `.dockerignore`: node_modules, .next, data/*.db, test-results, *.tsbuildinfo, plus .git/.env/.codenomad and the e2e harness (`@playwright/test` is a host-global install, not a project dependency — Next's build-time type check would fail on it inside the image) - `.dockerignore`: node_modules, .next, data/*.db, test-results, *.tsbuildinfo, plus .git/.env/.codenomad and the e2e harness (`@playwright/test` is a host-global install, not a project dependency — Next's build-time type check would fail on it inside the image)
+6
View File
@@ -7,6 +7,10 @@ import { pitchForZoom } from '@/lib/camera';
const EUROPE_CENTER: [number, number] = [10, 52]; const EUROPE_CENTER: [number, number] = [10, 52];
const INITIAL_ZOOM = 4; const INITIAL_ZOOM = 4;
// Useful gameplay range: continent overview → city detail (10–100 m/px). The
// zoom→pitch camera transition completes at z12; street-level zoom has no value.
export const MIN_ZOOM = 3;
export const MAX_ZOOM = 14;
const OPENFREEMAP_STYLE = 'https://tiles.openfreemap.org/styles/liberty'; const OPENFREEMAP_STYLE = 'https://tiles.openfreemap.org/styles/liberty';
const TERRAIN_TILES = ['https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png']; const TERRAIN_TILES = ['https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png'];
@@ -35,6 +39,8 @@ export default function Map() {
center: EUROPE_CENTER, center: EUROPE_CENTER,
zoom: INITIAL_ZOOM, zoom: INITIAL_ZOOM,
pitch: 0, pitch: 0,
minZoom: MIN_ZOOM,
maxZoom: MAX_ZOOM,
}); });
// Dev/e2e hook so tests can project coordinates to screen space. // Dev/e2e hook so tests can project coordinates to screen space.
(window as unknown as Record<string, unknown>).__flightMap = map; (window as unknown as Record<string, unknown>).__flightMap = map;
+78
View File
@@ -4,6 +4,10 @@ import { test, expect, type Page } from '@playwright/test';
// the terrain code path stays toggleable on by default in the app. // the terrain code path stays toggleable on by default in the app.
const APP_URL = '/?terrain=0'; const APP_URL = '/?terrain=0';
// Must match MIN_ZOOM / MAX_ZOOM exported from components/Map.tsx.
const MIN_ZOOM = 3;
const MAX_ZOOM = 14;
async function waitForAirportCount(page: Page): Promise<number> { async function waitForAirportCount(page: Page): Promise<number> {
const countEl = page.getByTestId('airport-count'); const countEl = page.getByTestId('airport-count');
await expect.poll(async () => { await expect.poll(async () => {
@@ -54,3 +58,77 @@ test('clicking an airport marker opens a popup with name and ICAO', async ({ pag
await expect(popup).toContainText('Bornholm Airport'); await expect(popup).toContainText('Bornholm Airport');
await expect(popup).toContainText('EKRN'); await expect(popup).toContainText('EKRN');
}); });
test('wheel zoom is clamped between min and max zoom', async ({ page }) => {
// Zoom clamping is pure camera behavior, so block basemap tile fetches (the
// style JSON still loads): it keeps the headless main thread idle and makes the
// test fast and independent of tile-server latency.
await page.route('**://*.openfreemap.org/**', (route) => {
if (route.request().url().includes('/styles/')) return route.continue();
return route.abort();
});
await page.goto(APP_URL);
const getZoom = () => page.evaluate(() => (window as any).__flightMap?.getZoom() ?? -1);
// Wait until the map instance exists and its style is fully applied, so the
// camera state is stable before we start wheeling.
await expect.poll(
() =>
page.evaluate(() => {
const m = (window as any).__flightMap;
return m && m.isStyleLoaded() ? m.getZoom() : -1;
}),
{ timeout: 15_000 },
).toBeGreaterThan(0);
// Wheel events must land on the canvas.
const box = (await page.getByTestId('map').boundingBox())!;
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
// Position the camera close to a limit so the wheeling under test has short
// travel — headless tile rendering is slow across many zoom levels.
const jumpTo = async (zoom: number) => {
await page.evaluate((z) => (window as any).__flightMap.jumpTo({ zoom: z }), zoom);
await expect.poll(getZoom, { timeout: 5_000 }).toBeCloseTo(zoom);
};
// MapLibre consumes all accumulated wheel delta in a single render frame, capped
// at one zoom level per frame — so keep feeding large deltas while polling until
// the camera reaches (or passes) the limit; frames consume them as they run.
const wheelToLimit = async (deltaY: number, atLimit: (z: number) => boolean) => {
await expect.poll(async () => {
if ((await getZoom()) === -1) return 'pending';
if (atLimit(await getZoom())) return 'done';
for (let i = 0; i < 10; i++) await page.mouse.wheel(0, deltaY);
return 'pending';
}, { timeout: 20_000 }).toBe('done');
};
// Wait until the camera stops moving so we assert on the settled value, not a
// mid-animation one (each wheel eases for ~200ms).
const waitForStableZoom = async () => {
let prev = await getZoom();
let stable = 0;
await expect.poll(async () => {
const z = await getZoom();
stable = Math.abs(z - prev) < 1e-9 ? stable + 1 : 0;
prev = z;
return stable >= 3 ? 'stable' : 'pending';
}, { timeout: 5_000 }).toBe('stable');
};
// Zoom in well past MAX_ZOOM with several large deltas.
await jumpTo(MAX_ZOOM - 2);
await wheelToLimit(-600, (z) => z >= MAX_ZOOM - 1e-9);
await waitForStableZoom();
expect(await getZoom()).toBeCloseTo(MAX_ZOOM);
// Zoom out well below MIN_ZOOM with several large deltas.
await jumpTo(MIN_ZOOM + 1);
await wheelToLimit(600, (z) => z <= MIN_ZOOM + 1e-9);
await waitForStableZoom();
const zoom = await getZoom();
expect(zoom).toBeCloseTo(MIN_ZOOM);
expect(zoom).toBeGreaterThanOrEqual(MIN_ZOOM);
expect(zoom).toBeLessThanOrEqual(MAX_ZOOM);
});
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "flight-ops", "name": "flight-ops",
"version": "0.2.0", "version": "0.2.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 3000", "dev": "next dev -p 3000",