From b83d098ba12e7ebe6b8fba1f0e7f2071f1c232d0 Mon Sep 17 00:00:00 2001 From: Cristian Curaba Date: Fri, 11 Sep 2026 10:52:44 +0200 Subject: [PATCH 1/2] Disable maplibre-gl's own resize tracking (fixes zoom stretching the map) Regression from the 5.24.0 -> 6.4.1 bump (#5). maplibre-gl 6 added an internal ResizeObserver on the `container` option (_setupResizeObserver, new in this major version) that calls resize() + redraw() on its own. We pass the SAME element as container that useMapViewportSync CSS-transforms every frame for the zoom sync (translate + scale, to keep the background locked to React Flow's viewport without a jumpTo() per frame) -- and that hook already has its own ResizeObserver on that element, calling resize() explicitly at every point that matters: on load, on tile style change, and on a real container resize. Two independent resize-tracking paths racing on the one CSS-transformed element is what made zooming visibly stretch/narrow the map canvas instead of just scaling it smoothly. trackResize: false disables only maplibre's own new auto-resize path (confirmed its only consumer in the library). Nothing else depends on it -- our code already explicitly calls .resize() at every point maplibre's internal tracking would have. Verified: tsc 0 errors, 230/230 tests, eslint 0 new warnings, clean build. Visual confirmation (does zoom now scale smoothly) still needs an actual browser -- please check after pulling. Co-Authored-By: Claude Opus 5 --- CASCADE-app/components/geo/geo-map-background.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CASCADE-app/components/geo/geo-map-background.tsx b/CASCADE-app/components/geo/geo-map-background.tsx index 78ed4eb..bb86bcd 100644 --- a/CASCADE-app/components/geo/geo-map-background.tsx +++ b/CASCADE-app/components/geo/geo-map-background.tsx @@ -259,6 +259,16 @@ export function GeoMapBackground({ canvasId }: GeoMapBackgroundProps) { // Keep the last rendered frame in the WebGL buffer so html-to-image can // read it when the user exports PNG/SVG on a georeferenced canvas. canvasContextAttributes: { preserveDrawingBuffer: true }, + // maplibre-gl 6 added its own ResizeObserver on `container` — the same + // element useMapViewportSync CSS-transforms every frame for the zoom + // sync, and already watches with its own ResizeObserver (which calls + // resize() explicitly at every point that matters: on load, on tile + // style change, on a real layout resize). A second, redundant resize + // path racing against the per-frame `transform: scale()` sync is what + // made zooming visibly stretch the canvas instead of just scaling it — + // disabling maplibre's own tracking removes that race outright, rather + // than papering over its symptom. + trackResize: false, }); map.on("load", () => { setMapReady(true); map.resize(); }); From ab2ed9d86533f614cf53d5904050a82e7ee1e38b Mon Sep 17 00:00:00 2001 From: Cristian Curaba Date: Fri, 11 Sep 2026 11:40:48 +0200 Subject: [PATCH 2/2] Fix blank geo map under maplibre-gl 6: serve its worker from public/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit maplibre-gl 6 ships ESM-only and loads tile decoding into a Web Worker that imports a sibling chunk (maplibre-gl-shared.mjs) by relative path. Next.js — in both Turbopack and --webpack mode — emits that worker as a lone hashed asset without the sibling, so the worker throws on its first import. The map mounts and fires "load", but never requests a tile: a blank background, no console error, and the CSS-transform viewport sync left as the only thing moving (hence "zoom pans the window, not the map"). Follow the upstream Turbopack/Next.js recipe: copy both dist files into public/maplibre/ at predev/prebuild time (always matching the installed version) and point setWorkerUrl at the same-origin path. Caddy already allows worker-src 'self' and leaves /maplibre/* on no-cache, so an upgrade can't serve a stale worker. Also reverts the speculative trackResize:false from the previous commit — it addressed a hypothesis, not this cause, and maplibre's own resize tracking is harmless. Docs: architecture.md gains the build step and the version bump to 6. Ref: https://maplibre.org/maplibre-gl-js/docs/ → Installation → Turbopack Signed-off-by: Cristian Curaba --- CASCADE-app/.gitignore | 3 ++ .../components/geo/geo-map-background.tsx | 19 +++++------ CASCADE-app/eslint.config.mjs | 13 ++++++- CASCADE-app/package.json | 5 +-- CASCADE-app/scripts/copy-maplibre-worker.mjs | 34 +++++++++++++++++++ docs/project/architecture.md | 3 +- 6 files changed, 63 insertions(+), 14 deletions(-) create mode 100644 CASCADE-app/scripts/copy-maplibre-worker.mjs diff --git a/CASCADE-app/.gitignore b/CASCADE-app/.gitignore index 8494bcb..d85ddae 100644 --- a/CASCADE-app/.gitignore +++ b/CASCADE-app/.gitignore @@ -32,3 +32,6 @@ samples/private/ # ── OS ─────────────────────────────────────────────────────────────────────── .DS_Store Thumbs.db + +# ── Generated from node_modules by scripts/copy-maplibre-worker.mjs ────────── +public/maplibre/ diff --git a/CASCADE-app/components/geo/geo-map-background.tsx b/CASCADE-app/components/geo/geo-map-background.tsx index bb86bcd..7668986 100644 --- a/CASCADE-app/components/geo/geo-map-background.tsx +++ b/CASCADE-app/components/geo/geo-map-background.tsx @@ -25,6 +25,15 @@ import { useCanvasStore } from "@/store/canvas-store"; import { useMapViewportSync } from "@/hooks/useMapViewportSync"; import type { GeoAnchor } from "@/lib/schemas/network"; +// maplibre-gl 6 loads its tile-decoding work into a Web Worker, and under +// Next.js the worker file has to be served as a plain same-origin asset: +// `scripts/copy-maplibre-worker.mjs` puts it (and the shared chunk it imports) +// under public/maplibre/ at predev/prebuild time. Without this call the map +// mounts and reports "load", but never requests a single tile — a blank +// background with no error. Module scope, so it runs exactly once before any +// Map is constructed. +maplibregl.setWorkerUrl("/maplibre/maplibre-gl-worker.mjs"); + // --------------------------------------------------------------------------- // Ghost graph overlay — shows node/edge positions in setup mode // --------------------------------------------------------------------------- @@ -259,16 +268,6 @@ export function GeoMapBackground({ canvasId }: GeoMapBackgroundProps) { // Keep the last rendered frame in the WebGL buffer so html-to-image can // read it when the user exports PNG/SVG on a georeferenced canvas. canvasContextAttributes: { preserveDrawingBuffer: true }, - // maplibre-gl 6 added its own ResizeObserver on `container` — the same - // element useMapViewportSync CSS-transforms every frame for the zoom - // sync, and already watches with its own ResizeObserver (which calls - // resize() explicitly at every point that matters: on load, on tile - // style change, on a real layout resize). A second, redundant resize - // path racing against the per-frame `transform: scale()` sync is what - // made zooming visibly stretch the canvas instead of just scaling it — - // disabling maplibre's own tracking removes that race outright, rather - // than papering over its symptom. - trackResize: false, }); map.on("load", () => { setMapReady(true); map.resize(); }); diff --git a/CASCADE-app/eslint.config.mjs b/CASCADE-app/eslint.config.mjs index c7430b8..e1280b1 100644 --- a/CASCADE-app/eslint.config.mjs +++ b/CASCADE-app/eslint.config.mjs @@ -5,7 +5,18 @@ import coreWebVitals from "eslint-config-next/core-web-vitals"; import typescript from "eslint-config-next/typescript"; const config = [ - { ignores: ["node_modules/**", ".next/**", "out/**", "shared/schemas/**", "next-env.d.ts"] }, + // public/maplibre/** is MapLibre's worker bundle copied verbatim out of + // node_modules by scripts/copy-maplibre-worker.mjs — vendor code, not ours. + { + ignores: [ + "node_modules/**", + ".next/**", + "out/**", + "shared/schemas/**", + "next-env.d.ts", + "public/maplibre/**", + ], + }, ...coreWebVitals, ...typescript, { diff --git a/CASCADE-app/package.json b/CASCADE-app/package.json index 9172348..1f114e3 100644 --- a/CASCADE-app/package.json +++ b/CASCADE-app/package.json @@ -4,11 +4,12 @@ "license": "AGPL-3.0-or-later", "private": true, "scripts": { - "predev": "npm run sync:samples", + "predev": "npm run sync:samples && npm run sync:maplibre-worker", "dev": "next dev --turbopack", - "prebuild": "npm run sync:samples", + "prebuild": "npm run sync:samples && npm run sync:maplibre-worker", "build": "next build", "sync:samples": "node scripts/sync-samples.mjs", + "sync:maplibre-worker": "node scripts/copy-maplibre-worker.mjs", "start": "next start", "lint": "eslint .", "type-check": "tsc --noEmit", diff --git a/CASCADE-app/scripts/copy-maplibre-worker.mjs b/CASCADE-app/scripts/copy-maplibre-worker.mjs new file mode 100644 index 0000000..74b3f96 --- /dev/null +++ b/CASCADE-app/scripts/copy-maplibre-worker.mjs @@ -0,0 +1,34 @@ +/** + * Copy MapLibre's web worker (and the shared chunk it imports) into + * public/maplibre/ so `setWorkerUrl` can point at a real, same-origin URL. + * + * Why this exists: maplibre-gl 6 ships as ES modules only, and its worker + * imports a sibling file (`maplibre-gl-shared.mjs`) by relative path. Next.js + * — in both its Turbopack and its --webpack mode — turns the usual + * `new URL('maplibre-gl/dist/maplibre-gl-worker.mjs', import.meta.url)` trick + * into a single hashed asset WITHOUT emitting that sibling next to it. The + * worker then throws on its first import and the map mounts but never requests + * a tile: a blank background, no error in the page. Serving both files verbatim + * from public/ is the upstream-documented fix for this bundler. + * + * See: https://maplibre.org/maplibre-gl-js/docs/ → Installation → Turbopack tab. + * + * The copy runs at build time from node_modules, so it always matches the + * installed version — nothing to keep in sync by hand. + */ +import { copyFileSync, mkdirSync } from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; + +const dist = path.join( + path.dirname(createRequire(import.meta.url).resolve("maplibre-gl/package.json")), + "dist", +); +const dest = path.join(process.cwd(), "public", "maplibre"); + +mkdirSync(dest, { recursive: true }); +// Both files, not just the worker: the worker imports the shared chunk by +// relative path, so they have to land in the same directory. +for (const file of ["maplibre-gl-worker.mjs", "maplibre-gl-shared.mjs"]) { + copyFileSync(path.join(dist, file), path.join(dest, file)); +} diff --git a/docs/project/architecture.md b/docs/project/architecture.md index 100319f..8f14c8d 100644 --- a/docs/project/architecture.md +++ b/docs/project/architecture.md @@ -374,6 +374,7 @@ A georeferenced Canvas renders a MapLibre map as a **non-interactive background - **`components/geo/geo-map-background.tsx`** — owns the MapLibre lifecycle (init, tile-style swap, interaction toggle, resize) and the setup/synced UI (style picker, "Set anchor", crosshair, debug overlay). In *setup* mode the map is fully interactive so the user can navigate and drop a **GeoAnchor**; in *synced* mode interaction is disabled and the map follows the viewport. - **`hooks/useMapViewportSync.ts`** — the viewport-sync seam. Mirrors React Flow's transform onto the map container every frame (GPU compositor, zero lag) and reloads sharp tiles via `map.jumpTo()` only when a gesture ends. Returns `invalidate()` for style swaps. +- **`scripts/copy-maplibre-worker.mjs`** — copies MapLibre's tile-decoding worker (and the shared chunk it imports by relative path) from `node_modules` into `public/maplibre/` on `predev`/`prebuild`, so `setWorkerUrl` can point at a same-origin asset. MapLibre 6 is ESM-only, and Next.js emits the worker as a lone hashed asset without its sibling — the map then mounts and fires `load` but never requests a tile, showing a blank background with no error. This is the bundler setup upstream documents for Turbopack/Next.js. - **`lib/geo-utils.ts`** — the **GeoAnchor projection**: exact Web Mercator (`anchorFlowToGeo`, `anchorGeoToFlow`, `computeMapTarget`). One seam converts flow ↔ geo, so `node.geo`-on-drag and the map camera can never use disagreeing projections. See CONTEXT.md → *GeoAnchor*. A node carries both `position` (flow) and `geo` (lng/lat); see CONTEXT.md → *Node Position vs Geo Coordinates*. The GeoAnchor is the single per-Canvas correspondence tying the two. @@ -566,7 +567,7 @@ CASCADE-v2/ | State | Zustand + Immer | 5 / 11 | Lightweight, immutable stores | | Styling | Tailwind CSS | 4 | Utility-first design system | | Validation | Zod | 4 | Runtime schema validation, type inference | -| Maps | MapLibre GL JS | 5 | Open-source map background for georeferenced Canvases | +| Maps | MapLibre GL JS | 6 | Open-source map background for georeferenced Canvases | | API Server | FastAPI | — | High-performance async Python API | | Backend validation | Pydantic v2 | — | Request/response schema enforcement | | Auth | OAuth2/OIDC (provider-agnostic) | — | Identity, JWT validation |