From abdeca2a6a9c090997df2288caf7b4a198a60fef Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Wed, 16 Sep 2026 19:50:26 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20v0.2.1=20=E2=80=94=20clamp=20map=20zoom?= =?UTF-8?q?=20to=203=E2=80=9314?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 +++ components/Map.tsx | 6 ++++ e2e/map.spec.ts | 78 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 2 +- 4 files changed, 89 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dcd6838..2f21565 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 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 - 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) diff --git a/components/Map.tsx b/components/Map.tsx index ce90ff4..24f3bc1 100644 --- a/components/Map.tsx +++ b/components/Map.tsx @@ -7,6 +7,10 @@ import { pitchForZoom } from '@/lib/camera'; const EUROPE_CENTER: [number, number] = [10, 52]; 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 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, zoom: INITIAL_ZOOM, pitch: 0, + minZoom: MIN_ZOOM, + maxZoom: MAX_ZOOM, }); // Dev/e2e hook so tests can project coordinates to screen space. (window as unknown as Record).__flightMap = map; diff --git a/e2e/map.spec.ts b/e2e/map.spec.ts index 927aad4..f7f0c7d 100644 --- a/e2e/map.spec.ts +++ b/e2e/map.spec.ts @@ -4,6 +4,10 @@ import { test, expect, type Page } from '@playwright/test'; // the terrain code path stays toggleable on by default in the app. 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 { const countEl = page.getByTestId('airport-count'); 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('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); +}); diff --git a/package.json b/package.json index a79fd23..b796e8c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flight-ops", - "version": "0.2.0", + "version": "0.2.1", "private": true, "scripts": { "dev": "next dev -p 3000",