diff --git a/apps/geolibre-desktop/package.json b/apps/geolibre-desktop/package.json index f07bae073..e60945699 100644 --- a/apps/geolibre-desktop/package.json +++ b/apps/geolibre-desktop/package.json @@ -65,7 +65,7 @@ "i18next": "^26.3.6", "jspdf": "^4.2.1", "mapillary-js": "^4.1.2", - "maplibre-gl": "^5.24.0", + "maplibre-gl": "^6.1.0", "maplibre-gl-3d-tiles": "^0.5.6", "maplibre-gl-basemap-control": "^0.13.0", "maplibre-gl-components": "^0.30.0", diff --git a/apps/geolibre-desktop/src/components/comments/CommentMapOverlay.tsx b/apps/geolibre-desktop/src/components/comments/CommentMapOverlay.tsx index f9ab23589..7970fdf92 100644 --- a/apps/geolibre-desktop/src/components/comments/CommentMapOverlay.tsx +++ b/apps/geolibre-desktop/src/components/comments/CommentMapOverlay.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef } from "react"; import { useAppStore, type ProjectComment } from "@geolibre/core"; import type { MapController } from "@geolibre/map"; -import maplibreGl from "maplibre-gl"; +import * as maplibreGl from "maplibre-gl"; interface CommentMapOverlayProps { mapControllerRef: React.RefObject; diff --git a/apps/geolibre-desktop/src/components/comments/useCommentTool.ts b/apps/geolibre-desktop/src/components/comments/useCommentTool.ts index 1cb35e1e6..41ad3e13d 100644 --- a/apps/geolibre-desktop/src/components/comments/useCommentTool.ts +++ b/apps/geolibre-desktop/src/components/comments/useCommentTool.ts @@ -5,7 +5,7 @@ import { useAppStore, type CommentAnchor, type ProjectComment } from "@geolibre/ import type { MapController } from "@geolibre/map"; import { v4 as uuidv4 } from "uuid"; import type { CollaborationApi } from "../../hooks/useCollaboration"; -import type maplibreGl from "maplibre-gl"; +import type * as maplibreGl from "maplibre-gl"; interface UseCommentToolOptions { mapControllerRef: React.RefObject; diff --git a/apps/geolibre-desktop/src/components/layout/FieldCollectionDialog.tsx b/apps/geolibre-desktop/src/components/layout/FieldCollectionDialog.tsx index dfeba761c..6e5c082a4 100644 --- a/apps/geolibre-desktop/src/components/layout/FieldCollectionDialog.tsx +++ b/apps/geolibre-desktop/src/components/layout/FieldCollectionDialog.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import maplibregl from "maplibre-gl"; +import * as maplibregl from "maplibre-gl"; import type { MapController } from "@geolibre/map"; import { getAttributeFormField, diff --git a/apps/geolibre-desktop/src/components/layout/GeoreferencerDialog.tsx b/apps/geolibre-desktop/src/components/layout/GeoreferencerDialog.tsx index 189f587aa..f266cea25 100644 --- a/apps/geolibre-desktop/src/components/layout/GeoreferencerDialog.tsx +++ b/apps/geolibre-desktop/src/components/layout/GeoreferencerDialog.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import type maplibregl from "maplibre-gl"; +import type * as maplibregl from "maplibre-gl"; import type { MapController } from "@geolibre/map"; import { DEFAULT_LAYER_STYLE, type GeoLibreLayer, useAppStore } from "@geolibre/core"; import { diff --git a/apps/geolibre-desktop/src/components/layout/GpsTrackingDialog.tsx b/apps/geolibre-desktop/src/components/layout/GpsTrackingDialog.tsx index 2f71f73b2..3b8744241 100644 --- a/apps/geolibre-desktop/src/components/layout/GpsTrackingDialog.tsx +++ b/apps/geolibre-desktop/src/components/layout/GpsTrackingDialog.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import type { Feature, FeatureCollection } from "geojson"; -import maplibregl from "maplibre-gl"; +import * as maplibregl from "maplibre-gl"; import type { MapController } from "@geolibre/map"; import { useAppStore } from "@geolibre/core"; import { diff --git a/apps/geolibre-desktop/src/components/layout/MapContextMenu.tsx b/apps/geolibre-desktop/src/components/layout/MapContextMenu.tsx index 0cfc47d94..03b97dc1e 100644 --- a/apps/geolibre-desktop/src/components/layout/MapContextMenu.tsx +++ b/apps/geolibre-desktop/src/components/layout/MapContextMenu.tsx @@ -11,7 +11,7 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@geolibre/ui"; -import type maplibregl from "maplibre-gl"; +import type * as maplibregl from "maplibre-gl"; import { BookOpen, Braces, diff --git a/apps/geolibre-desktop/src/components/layout/PixelTimeSeriesControl.tsx b/apps/geolibre-desktop/src/components/layout/PixelTimeSeriesControl.tsx index d552ef855..7642e19ae 100644 --- a/apps/geolibre-desktop/src/components/layout/PixelTimeSeriesControl.tsx +++ b/apps/geolibre-desktop/src/components/layout/PixelTimeSeriesControl.tsx @@ -22,7 +22,7 @@ import { useState, } from "react"; import { useTranslation } from "react-i18next"; -import maplibregl from "maplibre-gl"; +import * as maplibregl from "maplibre-gl"; import type { MapController } from "@geolibre/map"; import { clamp } from "../../lib/clamp"; import { type ChartDomain, resolveChartDomain } from "../../lib/chart-domain"; diff --git a/apps/geolibre-desktop/src/components/layout/RemoteCursorsOverlay.tsx b/apps/geolibre-desktop/src/components/layout/RemoteCursorsOverlay.tsx index e0097be6e..16606828e 100644 --- a/apps/geolibre-desktop/src/components/layout/RemoteCursorsOverlay.tsx +++ b/apps/geolibre-desktop/src/components/layout/RemoteCursorsOverlay.tsx @@ -1,5 +1,5 @@ import { useAppStore, type CollaborationPresence } from "@geolibre/core"; -import maplibregl from "maplibre-gl"; +import * as maplibregl from "maplibre-gl"; import { useEffect, useRef } from "react"; import type { RefObject } from "react"; import type { MapController } from "@geolibre/map"; diff --git a/apps/geolibre-desktop/src/components/panels/LayerPanelPlaceSearch.tsx b/apps/geolibre-desktop/src/components/panels/LayerPanelPlaceSearch.tsx index 02a9dc29e..3ab7fcbc1 100644 --- a/apps/geolibre-desktop/src/components/panels/LayerPanelPlaceSearch.tsx +++ b/apps/geolibre-desktop/src/components/panels/LayerPanelPlaceSearch.tsx @@ -9,7 +9,7 @@ import { useState, } from "react"; import { useTranslation } from "react-i18next"; -import maplibregl from "maplibre-gl"; +import * as maplibregl from "maplibre-gl"; import { type GeocodeMatch, geocodeForward, diff --git a/apps/geolibre-desktop/src/components/storymap/StoryMapHandoutDialog.tsx b/apps/geolibre-desktop/src/components/storymap/StoryMapHandoutDialog.tsx index 869a491e6..2713e8f86 100644 --- a/apps/geolibre-desktop/src/components/storymap/StoryMapHandoutDialog.tsx +++ b/apps/geolibre-desktop/src/components/storymap/StoryMapHandoutDialog.tsx @@ -1,5 +1,5 @@ import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import maplibregl from "maplibre-gl"; +import * as maplibregl from "maplibre-gl"; import { useTranslation } from "react-i18next"; import type { StoryActiveSlideMode, StoryChapter, StoryMap } from "@geolibre/core"; import type { MapController } from "@geolibre/map"; diff --git a/apps/geolibre-desktop/src/components/storymap/StoryMapPresenter.tsx b/apps/geolibre-desktop/src/components/storymap/StoryMapPresenter.tsx index 5e9abb80a..4188ccc44 100644 --- a/apps/geolibre-desktop/src/components/storymap/StoryMapPresenter.tsx +++ b/apps/geolibre-desktop/src/components/storymap/StoryMapPresenter.tsx @@ -10,7 +10,7 @@ import { } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; -import maplibregl from "maplibre-gl"; +import * as maplibregl from "maplibre-gl"; import { useAppStore, type StoryActiveSlideMode, diff --git a/apps/geolibre-desktop/src/hooks/useCommandBridge.ts b/apps/geolibre-desktop/src/hooks/useCommandBridge.ts index 16265c402..96b7cd247 100644 Binary files a/apps/geolibre-desktop/src/hooks/useCommandBridge.ts and b/apps/geolibre-desktop/src/hooks/useCommandBridge.ts differ diff --git a/apps/geolibre-desktop/src/hooks/useNotebookBridge.ts b/apps/geolibre-desktop/src/hooks/useNotebookBridge.ts index 6c90fe55b..f573ff2ae 100644 --- a/apps/geolibre-desktop/src/hooks/useNotebookBridge.ts +++ b/apps/geolibre-desktop/src/hooks/useNotebookBridge.ts @@ -1,5 +1,5 @@ import { useAppStore } from "@geolibre/core"; -import type maplibregl from "maplibre-gl"; +import type * as maplibregl from "maplibre-gl"; import { type RefObject, useEffect } from "react"; import type { MapController } from "@geolibre/map"; import { createScriptingHandlers } from "../lib/scripting/scriptingApi"; diff --git a/apps/geolibre-desktop/src/lib/assistant/tools.ts b/apps/geolibre-desktop/src/lib/assistant/tools.ts index cd74b7168..ae67af160 100644 --- a/apps/geolibre-desktop/src/lib/assistant/tools.ts +++ b/apps/geolibre-desktop/src/lib/assistant/tools.ts @@ -6,7 +6,7 @@ import { } from "@geolibre/core"; import type { MapController } from "@geolibre/map"; import type { InvokableTool, JSONValue } from "@strands-agents/sdk"; -import maplibregl from "maplibre-gl"; +import * as maplibregl from "maplibre-gl"; import { tool } from "@strands-agents/sdk"; import type { FeatureCollection } from "geojson"; import { z } from "zod"; diff --git a/apps/geolibre-desktop/src/lib/kml-super-overlay.ts b/apps/geolibre-desktop/src/lib/kml-super-overlay.ts index 0adcd15ac..ae2bd8992 100644 --- a/apps/geolibre-desktop/src/lib/kml-super-overlay.ts +++ b/apps/geolibre-desktop/src/lib/kml-super-overlay.ts @@ -80,10 +80,10 @@ export function setKmlSuperOverlayResolver(resolver: KmlSuperOverlayResolver | n async function ensureProtocol(): Promise { if (protocolRegistered) return; // Keep MapLibre out of tauri-io's static module graph. This also lets the - // DOM-only file-loader tests import tauri-io in Node — where maplibre-gl - // resolves to its CJS build, so the named exports sit under `default`. - const maplibre = await import("maplibre-gl"); - const addProtocol = maplibre.addProtocol ?? maplibre.default.addProtocol; + // DOM-only file-loader tests import tauri-io in Node without pulling the map + // in. MapLibre v6 is ESM-only with named exports and no default export, so + // this reads `addProtocol` straight off the namespace. + const { addProtocol } = await import("maplibre-gl"); addProtocol(PROTOCOL, handleTileRequest); protocolRegistered = true; } diff --git a/apps/geolibre-desktop/src/lib/maplibre-worker.ts b/apps/geolibre-desktop/src/lib/maplibre-worker.ts new file mode 100644 index 000000000..a8c47a464 --- /dev/null +++ b/apps/geolibre-desktop/src/lib/maplibre-worker.ts @@ -0,0 +1,22 @@ +import { setWorkerUrl } from "maplibre-gl"; +// Vite bundles the worker (it imports `./maplibre-gl-shared.mjs`) and hands back +// the emitted asset URL. +import maplibreWorkerUrl from "maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url"; + +/** + * Point MapLibre at its bundled worker. + * + * v6 ships the worker as a **separate file** and locates it at runtime with + * `new URL("./maplibre-gl-worker.mjs", import.meta.url)`. That is a computed + * string, not a static `new URL(…, import.meta.url)` literal, so no bundler can + * see it: the file is never emitted, and at runtime the URL resolves next to the + * hashed app chunk (`/assets/maplibre-gl-worker.mjs`), where nothing exists. In + * the web build the SPA fallback answers that request with `index.html`, so the + * failure is not even a 404 — the worker is handed HTML, and the request hangs + * rather than erroring. + * + * `setWorkerUrl` overrides that lookup with an asset the build actually emits. + * Must run before the first `Map` is constructed, so it is imported for effect + * from the app entry. + */ +setWorkerUrl(maplibreWorkerUrl); diff --git a/apps/geolibre-desktop/src/lib/print-layout-export.ts b/apps/geolibre-desktop/src/lib/print-layout-export.ts index d0ff028f2..7edb4fa2e 100644 --- a/apps/geolibre-desktop/src/lib/print-layout-export.ts +++ b/apps/geolibre-desktop/src/lib/print-layout-export.ts @@ -6,7 +6,7 @@ * export helpers rasterize {@link drawLayout} at print resolution. */ import { zipSync } from "fflate"; -import jsPDF from "jspdf"; +import { jsPDF } from "jspdf"; import { isFullViewportMapCanvas } from "./print-capture"; import { drawLayout, pageMm, pagePx, resolvePageSize, type LayoutOptions } from "./print-layout"; import type { PrintExtent } from "./print-extent"; diff --git a/apps/geolibre-desktop/src/lib/rtl-text.ts b/apps/geolibre-desktop/src/lib/rtl-text.ts index 011629a34..d71efdf3e 100644 --- a/apps/geolibre-desktop/src/lib/rtl-text.ts +++ b/apps/geolibre-desktop/src/lib/rtl-text.ts @@ -1,4 +1,4 @@ -import maplibregl from "maplibre-gl"; +import * as maplibregl from "maplibre-gl"; // Vite resolves this to a hashed, same-origin asset URL at build time, so the // RTL plugin ships with the app (web, desktop, and the Jupyter embed) instead of // being fetched from a CDN — keeps every build's CSP simple and works offline. diff --git a/apps/geolibre-desktop/src/lib/storymap-pdf.ts b/apps/geolibre-desktop/src/lib/storymap-pdf.ts index ff25c7318..36c980662 100644 --- a/apps/geolibre-desktop/src/lib/storymap-pdf.ts +++ b/apps/geolibre-desktop/src/lib/storymap-pdf.ts @@ -10,7 +10,7 @@ * stays free of MapLibre and the DOM and can be unit tested with data-URL * images. */ -import jsPDF from "jspdf"; +import { jsPDF } from "jspdf"; import { pageMm, resolvePageSize, type Orientation, type PaperSizeId } from "./print-layout"; /** An image to embed: a canvas (app) or a PNG/JPEG data URL (tests), plus its diff --git a/apps/geolibre-desktop/src/main.tsx b/apps/geolibre-desktop/src/main.tsx index bf9407156..ba4bc7966 100644 --- a/apps/geolibre-desktop/src/main.tsx +++ b/apps/geolibre-desktop/src/main.tsx @@ -1,4 +1,6 @@ import "./lib/symbol-dispose-polyfill"; +// Must precede any Map construction (see the module docs). +import "./lib/maplibre-worker"; import React from "react"; import ReactDOM from "react-dom/client"; import "@geoman-io/maplibre-geoman-free/dist/maplibre-geoman.css"; diff --git a/apps/geolibre-desktop/vite-plugins/maplibre-default-import-shim.ts b/apps/geolibre-desktop/vite-plugins/maplibre-default-import-shim.ts new file mode 100644 index 000000000..7e001f9d3 --- /dev/null +++ b/apps/geolibre-desktop/vite-plugins/maplibre-default-import-shim.ts @@ -0,0 +1,67 @@ +import type { Plugin } from "vite"; + +/** + * Rewrite `import X from "maplibre-gl"` to `import * as X from "maplibre-gl"` + * inside the published bundles of packages that still target MapLibre v5. + * + * MapLibre v6 is ESM-only with **no default export**, so a dist file compiled + * against v5's default export is a hard bundling error: + * + * [MISSING_EXPORT] "default" is not exported by ".../maplibre-gl.mjs" + * + * A namespace object is a drop-in for what these bundles actually do with the + * binding (`X.Map`, `new X.Popup()`, …), so the rewrite is safe. + * + * This is a **temporary** shim for third-party packages we do not control. Every + * opengeos-owned package has already been migrated and released, so the list + * below should only ever shrink. See opengeos/GeoLibre#1489 (blocker 1). + * + * The plugin fails the build if a listed package stops matching — that means it + * either shipped a fix (delete the entry) or changed its bundle shape (revisit), + * and silently shimming nothing would hide both. + */ +const SHIMMED_PACKAGES = ["@esri/maplibre-arcgis", "@geoman-io/maplibre-geoman-free"] as const; + +// Matches a default import of maplibre-gl in minified or unminified ESM: +// import Zt from"maplibre-gl" import e from "maplibre-gl" +const DEFAULT_IMPORT = /\bimport\s+([A-Za-z_$][\w$]*)\s+from\s*(["'])maplibre-gl\2/g; + +export function maplibreDefaultImportShim(): Plugin { + const rewritten = new Set(); + + return { + name: "geolibre:maplibre-default-import-shim", + enforce: "pre", + apply: () => true, + + transform(code, id) { + const pkg = SHIMMED_PACKAGES.find((name) => id.includes(`/node_modules/${name}/`)); + if (!pkg || !code.includes("maplibre-gl")) return null; + + DEFAULT_IMPORT.lastIndex = 0; + if (!DEFAULT_IMPORT.test(code)) return null; + + rewritten.add(pkg); + DEFAULT_IMPORT.lastIndex = 0; + return { + code: code.replace( + DEFAULT_IMPORT, + (_match, binding: string, quote: string) => + `import * as ${binding} from ${quote}maplibre-gl${quote}`, + ), + map: null, + }; + }, + + buildEnd(error) { + if (error) return; + const stale = SHIMMED_PACKAGES.filter((name) => !rewritten.has(name)); + if (stale.length === 0) return; + this.error( + `maplibre default-import shim matched nothing in: ${stale.join(", ")}. ` + + `If the package now ships a v6-compatible build, remove it from ` + + `SHIMMED_PACKAGES in vite-plugins/maplibre-default-import-shim.ts.`, + ); + }, + }; +} diff --git a/apps/geolibre-desktop/vite.config.ts b/apps/geolibre-desktop/vite.config.ts index 47a0956fd..4b9e1f61d 100644 --- a/apps/geolibre-desktop/vite.config.ts +++ b/apps/geolibre-desktop/vite.config.ts @@ -11,6 +11,7 @@ import { bundledPlugins } from "./vite-plugins/bundled-plugins"; import { copyCesiumAssets } from "./vite-plugins/copy-cesium-assets"; import { copyRtlText } from "./vite-plugins/copy-rtl-text"; import { copyVectorOps } from "./vite-plugins/copy-vector-ops"; +import { maplibreDefaultImportShim } from "./vite-plugins/maplibre-default-import-shim"; import { proxyBinaryRequestGuarded } from "./vite-proxy-guard"; const GEOAGENT_BROWSER_BUNDLE = "maplibre-gl-geoagent/dist/browser-"; @@ -846,6 +847,7 @@ function pwaPlugin(): Plugin[] { export default defineConfig({ base: APP_BASE, plugins: [ + maplibreDefaultImportShim(), ...(PGLITE_CDN ? [pgliteCdnLoaderPlugin()] : []), ...(CEREUS_CDN ? [cereusCdnLoaderPlugin()] : []), duckdbWasmBundlesPlugin(), @@ -959,6 +961,13 @@ export default defineConfig({ // asset reference, so serve it as-is. Only reached through the lazy // dynamic import in local-netcdf.ts when a user opens a local file. "h5wasm", + // These two still default-import maplibre-gl, which v6 does not provide. + // `maplibreDefaultImportShim` rewrites that, but the dependency optimizer + // runs outside the plugin pipeline and would fail before the shim is ever + // consulted, so serve them unbundled and let the shim transform them. + // Remove alongside their entries in the shim. See opengeos/GeoLibre#1489. + "@esri/maplibre-arcgis", + "@geoman-io/maplibre-geoman-free", ], }, build: { diff --git a/package-lock.json b/package-lock.json index d9f97f0ba..633030b22 100644 --- a/package-lock.json +++ b/package-lock.json @@ -78,7 +78,7 @@ "i18next": "^26.3.6", "jspdf": "^4.2.1", "mapillary-js": "^4.1.2", - "maplibre-gl": "^5.24.0", + "maplibre-gl": "^6.1.0", "maplibre-gl-3d-tiles": "^0.5.6", "maplibre-gl-basemap-control": "^0.13.0", "maplibre-gl-components": "^0.30.0", @@ -5447,9 +5447,9 @@ "license": "BSD-2-Clause" }, "node_modules/@mapbox/unitbezier": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", - "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz", + "integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==", "license": "BSD-2-Clause" }, "node_modules/@mapbox/vector-tile": { @@ -5461,15 +5461,6 @@ "@mapbox/point-geometry": "~0.1.0" } }, - "node_modules/@mapbox/whoots-js": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", - "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", - "license": "ISC", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@maplibre/geojson-vt": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz", @@ -5493,12 +5484,12 @@ } }, "node_modules/@maplibre/maplibre-gl-style-spec": { - "version": "24.10.0", - "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.10.0.tgz", - "integrity": "sha512-lichxSiagMEBBrqHF0trtMQH9RKh+9jUlIJl0qW0QHvt2H/tbvUWdE+ZzI2Jd0/pT7j/iavLonlPu7EQ/ixTOw==", + "version": "26.2.1", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-26.2.1.tgz", + "integrity": "sha512-QFKCXkOeSzOr8jF75jm6kySOg+dUvOehPhRi68gcOYPHb7U5JloUq0dJW0Y5/fZV8ygfT0Vp2RWodvq+fyxFWA==", "license": "ISC", "dependencies": { - "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/jsonlint-lines-primitives": "^2.0.3", "@mapbox/unitbezier": "^1.0.0", "json-stringify-pretty-compact": "^4.0.0", "minimist": "^1.2.8", @@ -5511,12 +5502,6 @@ "gl-style-validate": "dist/gl-style-validate.mjs" } }, - "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/@mapbox/unitbezier": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz", - "integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==", - "license": "BSD-2-Clause" - }, "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/tinyqueue": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", @@ -16776,27 +16761,25 @@ "license": "MIT" }, "node_modules/maplibre-gl": { - "version": "5.24.0", - "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.24.0.tgz", - "integrity": "sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.1.0.tgz", + "integrity": "sha512-vLRukjvbUai4SXW2/jKo8rPsw6YMXuA/b4fKFVSulZKFVRHkOvk4ZmNlDSVZpTYPV0/7/iTqq8ltYdyr764b7w==", "license": "BSD-3-Clause", "dependencies": { - "@mapbox/jsonlint-lines-primitives": "^2.0.2", "@mapbox/point-geometry": "^1.1.0", - "@mapbox/tiny-sdf": "^2.1.0", - "@mapbox/unitbezier": "^0.0.1", - "@mapbox/vector-tile": "^2.0.4", - "@mapbox/whoots-js": "^3.1.0", - "@maplibre/geojson-vt": "^6.1.0", - "@maplibre/maplibre-gl-style-spec": "^24.8.1", - "@maplibre/mlt": "^1.1.8", - "@maplibre/vt-pbf": "^4.3.0", + "@mapbox/tiny-sdf": "^2.2.0", + "@mapbox/unitbezier": "^1.0.0", + "@mapbox/vector-tile": "^3.0.0", + "@maplibre/geojson-vt": "^6.1.1", + "@maplibre/maplibre-gl-style-spec": "^26.2.1", + "@maplibre/mlt": "^1.1.12", + "@maplibre/vt-pbf": "^4.3.2", "@types/geojson": "^7946.0.16", - "earcut": "^3.0.2", + "earcut": "^3.2.3", "gl-matrix": "^3.4.4", - "kdbush": "^4.0.2", + "kdbush": "^4.1.0", "murmurhash-js": "^1.0.0", - "pbf": "^4.0.1", + "pbf": "^5.1.2", "potpack": "^2.1.0", "quickselect": "^3.0.0", "tinyqueue": "^3.0.0" @@ -17482,14 +17465,14 @@ "license": "ISC" }, "node_modules/maplibre-gl/node_modules/@mapbox/vector-tile": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.5.tgz", - "integrity": "sha512-pXj8m7KTsqZt+1jsE0xIpGvqTSbblfkuEJL/NJmNePMtEwxO8V3XMDo9WMSfDeqHvCtBI9Lmt4mGcGR10zecmw==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-3.0.0.tgz", + "integrity": "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==", "license": "BSD-3-Clause", "dependencies": { "@mapbox/point-geometry": "~1.1.0", "@types/geojson": "^7946.0.16", - "pbf": "^4.0.2" + "pbf": "^5.0.0" } }, "node_modules/maplibre-gl/node_modules/earcut": { @@ -17498,6 +17481,18 @@ "integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==", "license": "ISC" }, + "node_modules/maplibre-gl/node_modules/pbf": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz", + "integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, "node_modules/maplibre-gl/node_modules/tinyqueue": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", @@ -22233,37 +22228,6 @@ "typescript": "^7.0.2" } }, - "packages/core/node_modules/@mapbox/unitbezier": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz", - "integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==", - "license": "BSD-2-Clause" - }, - "packages/core/node_modules/@maplibre/maplibre-gl-style-spec": { - "version": "26.2.1", - "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-26.2.1.tgz", - "integrity": "sha512-QFKCXkOeSzOr8jF75jm6kySOg+dUvOehPhRi68gcOYPHb7U5JloUq0dJW0Y5/fZV8ygfT0Vp2RWodvq+fyxFWA==", - "license": "ISC", - "dependencies": { - "@mapbox/jsonlint-lines-primitives": "^2.0.3", - "@mapbox/unitbezier": "^1.0.0", - "json-stringify-pretty-compact": "^4.0.0", - "minimist": "^1.2.8", - "quickselect": "^3.0.0", - "tinyqueue": "^3.0.0" - }, - "bin": { - "gl-style-format": "dist/gl-style-format.mjs", - "gl-style-migrate": "dist/gl-style-migrate.mjs", - "gl-style-validate": "dist/gl-style-validate.mjs" - } - }, - "packages/core/node_modules/tinyqueue": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", - "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", - "license": "ISC" - }, "packages/core/node_modules/typescript": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", @@ -22359,7 +22323,7 @@ "@turf/mask": "^7.3.5", "cesium": "^1.143.0", "fast-xml-parser": "^5.10.1", - "maplibre-gl": "^5.24.0", + "maplibre-gl": "^6.1.0", "maplibre-gl-layer-control": "^0.17.4", "pmtiles": "^4.4.1", "react": "^19.2.8", @@ -22445,7 +22409,7 @@ "h5wasm": "^0.10.3", "jspdf": "^4.2.1", "mapillary-js": "^4.1.2", - "maplibre-gl": "^5.24.0", + "maplibre-gl": "^6.1.0", "maplibre-gl-3d-tiles": "^0.5.6", "maplibre-gl-basemap-control": "^0.13.0", "maplibre-gl-components": "^0.30.0", diff --git a/package.json b/package.json index ea441be9c..079034fb9 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,8 @@ "typecheck": "npm run build", "lint": "eslint apps packages workers tests", "test": "npm run test:frontend", - "test:frontend": "node --import tsx --test tests/*.test.ts", - "test:frontend:coverage": "node --import tsx --test --experimental-test-coverage --test-coverage-lines=78 --test-coverage-branches=78 --test-coverage-functions=63 --test-coverage-exclude=\"tests/**\" --test-coverage-exclude=\"e2e/**\" --test-coverage-exclude=\"**/*.config.*\" tests/*.test.ts", + "test:frontend": "node --import ./tests/hooks/register-maplibre-shim.mjs --import tsx --test tests/*.test.ts", + "test:frontend:coverage": "node --import ./tests/hooks/register-maplibre-shim.mjs --import tsx --test --experimental-test-coverage --test-coverage-lines=78 --test-coverage-branches=78 --test-coverage-functions=63 --test-coverage-exclude=\"tests/**\" --test-coverage-exclude=\"e2e/**\" --test-coverage-exclude=\"**/*.config.*\" tests/*.test.ts", "test:backend": "python -m pytest backend/geolibre_server/tests", "test:backend:coverage": "python -m pytest backend/geolibre_server/tests --cov=geolibre_server --cov-report=term-missing --cov-fail-under=55", "test:worker": "npm run typecheck -w geolibre-viewer-worker && npm run typecheck -w geolibre-collab-worker && npm run typecheck -w geolibre-collab-node && npm test -w geolibre-collab-node && npm run typecheck -w geolibre-tiles-worker && npm run typecheck -w geolibre-ai-proxy-worker", @@ -46,6 +46,7 @@ "brace-expansion": "^5.0.8", "fast-uri": "^3.1.5", "filelist": "^2.0.2", + "maplibre-gl": "^6.1.0", "sweepline-intersections": "^1.5.0", "uuid": "^14.0.0" }, diff --git a/packages/map/package.json b/packages/map/package.json index 6b98f026f..e7f686abb 100644 --- a/packages/map/package.json +++ b/packages/map/package.json @@ -22,7 +22,7 @@ "@turf/mask": "^7.3.5", "cesium": "^1.143.0", "fast-xml-parser": "^5.10.1", - "maplibre-gl": "^5.24.0", + "maplibre-gl": "^6.1.0", "maplibre-gl-layer-control": "^0.17.4", "pmtiles": "^4.4.1", "react": "^19.2.8", diff --git a/packages/map/src/MapCanvas.tsx b/packages/map/src/MapCanvas.tsx index fb3a230c1..1caf1241f 100644 --- a/packages/map/src/MapCanvas.tsx +++ b/packages/map/src/MapCanvas.tsx @@ -6,7 +6,7 @@ import { useAppStore, type GeoLibreLayer, } from "@geolibre/core"; -import maplibregl from "maplibre-gl"; +import * as maplibregl from "maplibre-gl"; import { memo, useEffect, useMemo, useRef } from "react"; import { circleLayerId, diff --git a/packages/map/src/collapsed-attribution-control.ts b/packages/map/src/collapsed-attribution-control.ts index d8f3675ee..16391e266 100644 --- a/packages/map/src/collapsed-attribution-control.ts +++ b/packages/map/src/collapsed-attribution-control.ts @@ -1,4 +1,4 @@ -import maplibregl from "maplibre-gl"; +import * as maplibregl from "maplibre-gl"; /** Set by MapLibre once the control is in compact (collapsible) mode. */ const COMPACT_CLASS = "maplibregl-compact"; diff --git a/packages/map/src/dynamic-style-property.ts b/packages/map/src/dynamic-style-property.ts new file mode 100644 index 000000000..854e31507 --- /dev/null +++ b/packages/map/src/dynamic-style-property.ts @@ -0,0 +1,58 @@ +/** + * Escape hatches for style properties whose names are only known at runtime. + * + * MapLibre v6 made `set/getPaintProperty` and `set/getLayoutProperty` generic + * over `keyof AllPaintProperties` / `keyof AllLayoutProperties`, so they accept + * only literal property names the compiler can see. GeoLibre computes property + * names in several places that the type system cannot follow: + * + * - iterating `Object.entries(spec.paint)` when applying a whole style block, + * - building transition names as `` `${prop}-transition` ``, + * - taking a `property: string` parameter through a generic helper. + * + * Each needs a cast. They live here — one documented module — rather than being + * sprinkled at every call site, so the blast radius is visible if MapLibre + * tightens or relaxes these signatures again. Prefer the typed MapLibre methods + * whenever the property name *is* a literal; reach for these only when it is not. + */ + +/** + * The slice of a MapLibre `Map` these helpers reach through. + * + * The getters are optional: they are read to skip no-op writes, and some + * external-control and test map objects implement only the setters. A missing + * getter must degrade to "unknown current value" (write anyway), never throw. + * The setters are required — silently dropping a write would be a real bug. + */ +interface DynamicStyleTarget { + setPaintProperty: (layerId: string, property: string, value: unknown) => void; + getPaintProperty?: (layerId: string, property: string) => unknown; + setLayoutProperty: (layerId: string, property: string, value: unknown) => void; + getLayoutProperty?: (layerId: string, property: string) => unknown; +} + +export function setDynamicPaintProperty( + map: object, + layerId: string, + property: string, + value: unknown, +): void { + (map as DynamicStyleTarget).setPaintProperty(layerId, property, value); +} + +export function getDynamicPaintProperty(map: object, layerId: string, property: string): unknown { + return (map as DynamicStyleTarget).getPaintProperty?.(layerId, property); +} + +export function setDynamicLayoutProperty( + map: object, + layerId: string, + property: string, + value: unknown, +): void { + (map as DynamicStyleTarget).setLayoutProperty(layerId, property, value); +} + +export function getDynamicLayoutProperty(map: object, layerId: string, property: string): unknown { + return (map as DynamicStyleTarget).getLayoutProperty?.(layerId, property); +} diff --git a/packages/map/src/generated-images.ts b/packages/map/src/generated-images.ts index a056036bb..caaa58c60 100644 --- a/packages/map/src/generated-images.ts +++ b/packages/map/src/generated-images.ts @@ -1,4 +1,4 @@ -import type maplibregl from "maplibre-gl"; +import type * as maplibregl from "maplibre-gl"; /** * Lazily-generated MapLibre sprite images (fill-pattern tiles and marker icons). diff --git a/packages/map/src/layer-sync.ts b/packages/map/src/layer-sync.ts index 1ba5fac24..3293d715b 100644 --- a/packages/map/src/layer-sync.ts +++ b/packages/map/src/layer-sync.ts @@ -15,7 +15,7 @@ import { } from "@geolibre/core"; import { addProtocol, config } from "maplibre-gl"; import type { GeoJSON } from "geojson"; -import type maplibregl from "maplibre-gl"; +import type * as maplibregl from "maplibre-gl"; import type { PropertyValueSpecification } from "maplibre-gl"; import { FileSource, PMTiles, Protocol } from "pmtiles"; import { @@ -57,6 +57,12 @@ import { } from "./derived-geometry"; import { ensureGeneratedImageHandler } from "./generated-images"; import { prepareFillPattern } from "./fill-patterns"; +import { + getDynamicLayoutProperty, + getDynamicPaintProperty, + setDynamicLayoutProperty, + setDynamicPaintProperty, +} from "./dynamic-style-property"; import { prepareLineDecoration } from "./line-decorations"; import { KML_ICON_URL_PROPERTY, @@ -1684,8 +1690,8 @@ function syncVectorControlPointSymbology( // of the other rule-based paint overrides apply to control-owned layers. const radius = proportionalRadiusExpression(layer.style); if (radius) { - if (!styleValuesEqual(map.getPaintProperty?.(circleNativeId, "circle-radius"), radius)) { - map.setPaintProperty(circleNativeId, "circle-radius", radius); + if (!styleValuesEqual(getDynamicPaintProperty(map, circleNativeId, "circle-radius"), radius)) { + setDynamicPaintProperty(map, circleNativeId, "circle-radius", radius); } overriddenRadiusIdsFor(map).add(circleNativeId); } else { @@ -1741,8 +1747,8 @@ function setExternalNativeLayerPaint( for (const [property, value] of Object.entries(paint)) { try { - if (!styleValuesEqual(map.getPaintProperty?.(nativeLayerId, property), value)) { - map.setPaintProperty(nativeLayerId, property, value); + if (!styleValuesEqual(getDynamicPaintProperty(map, nativeLayerId, property), value)) { + setDynamicPaintProperty(map, nativeLayerId, property, value); } } catch { // External controls can create heterogeneous style layers. Ignore paint @@ -3426,15 +3432,15 @@ function ensureLayer( if (map.getLayer(id)) { if (spec.paint) { for (const [key, value] of Object.entries(spec.paint)) { - if (!styleValuesEqual(map.getPaintProperty?.(id, key), value)) { - map.setPaintProperty(id, key, value); + if (!styleValuesEqual(getDynamicPaintProperty(map, id, key), value)) { + setDynamicPaintProperty(map, id, key, value); } } } if (spec.layout) { for (const [key, value] of Object.entries(spec.layout)) { - if (!styleValuesEqual(map.getLayoutProperty?.(id, key), value)) { - map.setLayoutProperty(id, key, value); + if (!styleValuesEqual(getDynamicLayoutProperty(map, id, key), value)) { + setDynamicLayoutProperty(map, id, key, value); } } } diff --git a/packages/map/src/map-controller.ts b/packages/map/src/map-controller.ts index ffc956e1f..72eea8f31 100644 --- a/packages/map/src/map-controller.ts +++ b/packages/map/src/map-controller.ts @@ -18,7 +18,7 @@ import type { } from "@geolibre/core"; import bbox from "@turf/bbox"; import type { Feature, FeatureCollection, Geometry } from "geojson"; -import maplibregl from "maplibre-gl"; +import * as maplibregl from "maplibre-gl"; import { LayerControl, type CustomLayerAdapter, type LayerState } from "maplibre-gl-layer-control"; import { CollapsedAttributionControl } from "./collapsed-attribution-control"; import { @@ -50,6 +50,18 @@ import { getOfflineBasemapStyle, isOfflineBasemapSentinel } from "./protomaps-ba import { ResetBearingControl } from "./reset-bearing-control"; import { MaptoolkitLogoControl } from "./maptoolkit-logo-control"; import { TerrainControl, DEFAULT_TERRAIN_EXAGGERATION } from "./terrain-control"; +import { getDynamicPaintProperty, setDynamicPaintProperty } from "./dynamic-style-property"; + +/** + * GeolocateControl is constructed through this indirection so tests can + * substitute a fake. Assigning over `maplibregl.GeolocateControl` is not an + * option: a MapLibre v6 ESM namespace is sealed and rejects assignment + * (`TypeError: Cannot assign to property 'GeolocateControl' of [object Module]`). + */ +export const geolocateControlFactory = { + create: (options: maplibregl.GeolocateControlOptions): maplibregl.GeolocateControl => + new maplibregl.GeolocateControl(options), +}; const DEFAULT_PROJECTION: maplibregl.ProjectionSpecification = { type: "globe", @@ -644,11 +656,11 @@ export class MapController { const props = OPACITY_PAINT_PROPERTIES[styleLayer.type] ?? []; for (const prop of props) { if (typeof durationMs === "number" && durationMs >= 0) { - this.map.setPaintProperty(nativeId, `${prop}-transition`, { + setDynamicPaintProperty(this.map, nativeId, `${prop}-transition`, { duration: durationMs, }); } - this.map.setPaintProperty(nativeId, prop, clamped); + setDynamicPaintProperty(this.map, nativeId, prop, clamped); } } } @@ -674,7 +686,7 @@ export class MapController { const styleLayer = this.map.getLayer(nativeId); if (!styleLayer) continue; for (const prop of OPACITY_PAINT_PROPERTIES[styleLayer.type] ?? []) { - this.map.setPaintProperty(nativeId, `${prop}-transition`, { + setDynamicPaintProperty(this.map, nativeId, `${prop}-transition`, { duration: 0, }); } @@ -1197,7 +1209,7 @@ export class MapController { this.basemapOriginalPaintValues.set(layerId, originalPaintValues); } if (!originalPaintValues.has(property)) { - originalPaintValues.set(property, this.map.getPaintProperty(layerId, property)); + originalPaintValues.set(property, getDynamicPaintProperty(this.map, layerId, property)); } const original = originalPaintValues.get(property); @@ -1208,9 +1220,9 @@ export class MapController { ? original * this.basemapOpacity : this.basemapOpacity; try { - const current = this.map.getPaintProperty(layerId, property); + const current = getDynamicPaintProperty(this.map, layerId, property); if (styleValuesEqual(current, opacity)) return; - this.map.setPaintProperty(layerId, property, opacity); + setDynamicPaintProperty(this.map, layerId, property, opacity); } catch { // Some third-party custom style layers may not expose paint properties. } @@ -2349,7 +2361,7 @@ export class MapController { if (!this.map || this.geolocateControl || !this.controlVisibility.geolocate) { return false; } - const control = new maplibregl.GeolocateControl({ + const control = geolocateControlFactory.create({ positionOptions: { enableHighAccuracy: true, }, diff --git a/packages/map/src/mapbox-style.ts b/packages/map/src/mapbox-style.ts index c29122f3a..c9b0880dd 100644 --- a/packages/map/src/mapbox-style.ts +++ b/packages/map/src/mapbox-style.ts @@ -29,7 +29,7 @@ * free of credential plumbing and makes a saved project self-contained. */ -import type maplibregl from "maplibre-gl"; +import type * as maplibregl from "maplibre-gl"; /** Host serving both the style descriptors and the `mapbox://` redirect targets. */ const MAPBOX_API_HOST = "api.mapbox.com"; diff --git a/packages/map/src/maptoolkit-logo-control.ts b/packages/map/src/maptoolkit-logo-control.ts index 707218864..f8641a0d2 100644 --- a/packages/map/src/maptoolkit-logo-control.ts +++ b/packages/map/src/maptoolkit-logo-control.ts @@ -1,4 +1,4 @@ -import type maplibregl from "maplibre-gl"; +import type * as maplibregl from "maplibre-gl"; /** Where the logo links, and the brand name used for the tooltip/aria label. */ const MAPTOOLKIT_URL = "https://www.maptoolkit.org/"; diff --git a/packages/map/src/planetary-scale-control.ts b/packages/map/src/planetary-scale-control.ts index 9107ec50b..18954e10e 100644 --- a/packages/map/src/planetary-scale-control.ts +++ b/packages/map/src/planetary-scale-control.ts @@ -5,7 +5,7 @@ import { scaleSpan, type MapScaleUnit, } from "@geolibre/core"; -import maplibregl from "maplibre-gl"; +import * as maplibregl from "maplibre-gl"; // Re-exported so the nice-number rounding and unit conversion — now shared with // the Print Layout scale bar in `@geolibre/core` — can still be imported from diff --git a/packages/map/src/protomaps-basemap.ts b/packages/map/src/protomaps-basemap.ts index 983dd70bf..77149a7b5 100644 --- a/packages/map/src/protomaps-basemap.ts +++ b/packages/map/src/protomaps-basemap.ts @@ -8,7 +8,7 @@ // via getOfflineBasemapStyle(). Glyphs and sprites default to app-bundled // assets (see scripts/fetch-basemaps-assets.mjs) so it renders fully offline. import { layers, namedFlavor } from "@protomaps/basemaps"; -import type maplibregl from "maplibre-gl"; +import type * as maplibregl from "maplibre-gl"; /** The Protomaps basemap flavors we expose. */ export const PROTOMAPS_FLAVORS = ["light", "dark", "white", "grayscale", "black"] as const; diff --git a/packages/map/src/reset-bearing-control.ts b/packages/map/src/reset-bearing-control.ts index cf48fdfb0..7b657a41f 100644 --- a/packages/map/src/reset-bearing-control.ts +++ b/packages/map/src/reset-bearing-control.ts @@ -1,4 +1,4 @@ -import type maplibregl from "maplibre-gl"; +import type * as maplibregl from "maplibre-gl"; /** * Bearing/pitch below this magnitude is treated as "north-up and flat", so a diff --git a/packages/map/src/terrain-control.ts b/packages/map/src/terrain-control.ts index 0a2e26017..24b4fd666 100644 --- a/packages/map/src/terrain-control.ts +++ b/packages/map/src/terrain-control.ts @@ -1,4 +1,4 @@ -import type maplibregl from "maplibre-gl"; +import type * as maplibregl from "maplibre-gl"; /** * Clicks landing within this window of the previous one are treated as a diff --git a/packages/plugins/package.json b/packages/plugins/package.json index 90acec81f..357c2ea3f 100644 --- a/packages/plugins/package.json +++ b/packages/plugins/package.json @@ -40,7 +40,7 @@ "h5wasm": "^0.10.3", "jspdf": "^4.2.1", "mapillary-js": "^4.1.2", - "maplibre-gl": "^5.24.0", + "maplibre-gl": "^6.1.0", "maplibre-gl-3d-tiles": "^0.5.6", "maplibre-gl-basemap-control": "^0.13.0", "maplibre-gl-components": "^0.30.0", diff --git a/packages/plugins/src/arcgis-maplibre.d.ts b/packages/plugins/src/arcgis-maplibre.d.ts index a439abd80..9a9f9680f 100644 --- a/packages/plugins/src/arcgis-maplibre.d.ts +++ b/packages/plugins/src/arcgis-maplibre.d.ts @@ -1,5 +1,5 @@ declare module "@esri/maplibre-arcgis" { - import type maplibregl from "maplibre-gl"; + import type * as maplibregl from "maplibre-gl"; export interface IHostedLayerOptions { attribution?: string; diff --git a/packages/plugins/src/plugins/arcgis-layer.ts b/packages/plugins/src/plugins/arcgis-layer.ts index d04db496d..e09092ed1 100644 --- a/packages/plugins/src/plugins/arcgis-layer.ts +++ b/packages/plugins/src/plugins/arcgis-layer.ts @@ -3,7 +3,7 @@ import { DEFAULT_LAYER_STYLE, type GeoLibreLayer, useAppStore } from "@geolibre/core"; import type { HostedLayer, VectorTileLayer } from "@esri/maplibre-arcgis"; import type { FeatureCollection } from "geojson"; -import type maplibregl from "maplibre-gl"; +import type * as maplibregl from "maplibre-gl"; import type { GeoLibreAppAPI } from "../types"; export type ArcGISLayerType = "feature" | "vector-tile"; diff --git a/packages/plugins/src/plugins/geoagent-layer-sync.ts b/packages/plugins/src/plugins/geoagent-layer-sync.ts index 2d93b504e..ee0fcd7f4 100644 --- a/packages/plugins/src/plugins/geoagent-layer-sync.ts +++ b/packages/plugins/src/plugins/geoagent-layer-sync.ts @@ -32,12 +32,19 @@ export type GeoAgentOverlayRecord = { /** * The slice of the GeoAgent tools surface the store -> GeoAgent sync needs. * Structural (rather than maplibre-gl types) so tests can pass fakes. + * + * Declared with method syntax, not function-typed properties: MapLibre v6 made + * `set/getPaintProperty` generic over `keyof AllPaintProperties`, and under + * `strictFunctionTypes` a function-typed property is checked contravariantly, + * so a real `Map` stops satisfying a `(id: string, property: string, ...)` + * slice. Methods are checked bivariantly, which accepts both a real map and a + * test fake. */ export type GeoAgentSyncableTools = { map?: { - getLayer: (id: string) => { type: string } | undefined; - setLayoutProperty: (id: string, property: string, value: unknown) => unknown; - setPaintProperty: (id: string, property: string, value: unknown) => unknown; + getLayer(id: string): { type: string } | undefined; + setLayoutProperty(id: string, property: string, value: unknown): unknown; + setPaintProperty(id: string, property: string, value: unknown): unknown; }; removeOverlay?: (name: string) => boolean; }; diff --git a/packages/plugins/src/plugins/maplibre-annotations.ts b/packages/plugins/src/plugins/maplibre-annotations.ts index c04deef53..d3bb04953 100644 --- a/packages/plugins/src/plugins/maplibre-annotations.ts +++ b/packages/plugins/src/plugins/maplibre-annotations.ts @@ -1,6 +1,8 @@ import { DEFAULT_LAYER_STYLE, type GeoLibreLayer, useAppStore } from "@geolibre/core"; import type { Feature, FeatureCollection, Position } from "geojson"; -import maplibregl from "maplibre-gl"; +// A value (not type-only) namespace import: main added runtime use of +// `maplibregl.LngLat`/`maplibregl.Marker` here, and v6 has no default export. +import * as maplibregl from "maplibre-gl"; import type { GeoLibreAppAPI, GeoLibreMapControlPosition, GeoLibrePlugin } from "../types"; import { ANNOTATIONS_PLUGIN_ID } from "../plugin-ids"; diff --git a/packages/plugins/src/plugins/maplibre-components.ts b/packages/plugins/src/plugins/maplibre-components.ts index f542f6d42..d5b993df5 100644 --- a/packages/plugins/src/plugins/maplibre-components.ts +++ b/packages/plugins/src/plugins/maplibre-components.ts @@ -17,7 +17,7 @@ import type { Layer } from "@deck.gl/core"; import type { MapboxOverlay } from "@deck.gl/mapbox"; import { RasterLayer, type RasterLayerProps } from "@developmentseed/deck.gl-raster"; import { fromArrayBuffer } from "geotiff"; -import type maplibregl from "maplibre-gl"; +import type * as maplibregl from "maplibre-gl"; import proj4 from "proj4"; import type { AddVectorControl, diff --git a/packages/plugins/src/plugins/maplibre-geo-editor.ts b/packages/plugins/src/plugins/maplibre-geo-editor.ts index 8e2582a2e..d962704d0 100644 --- a/packages/plugins/src/plugins/maplibre-geo-editor.ts +++ b/packages/plugins/src/plugins/maplibre-geo-editor.ts @@ -1,7 +1,7 @@ import { type GeoLibreLayer, lineWidthValue, styleValue, useAppStore } from "@geolibre/core"; import { Geoman, defaultLayerStyles } from "@geoman-io/maplibre-geoman-free"; import type { Feature, FeatureCollection } from "geojson"; -import type maplibregl from "maplibre-gl"; +import type * as maplibregl from "maplibre-gl"; import { GeoEditor, type GeoEditorOptions } from "maplibre-gl-geo-editor"; import { type EditedFeatureProperties, @@ -123,6 +123,16 @@ let viewImportLoadCounter = 0; const GEOMAN_EDIT_SYNC_EVENTS = ["gm:dragend", "gm:editend", "gm:rotateend"] as const; +/** + * MapLibre v6 narrowed the catch-all `Map#on`/`off` overload from `type: string` + * to `type: keyof MapEventType`, so Geoman's `gm:*` events no longer type-check + * even though the map dispatches them. Bind through this slice instead. + */ +interface ThirdPartyEventTarget { + on(type: string, listener: () => void): unknown; + off(type: string, listener: () => void): unknown; +} + export { GEO_EDITOR_PLUGIN_ID }; export const maplibreGeoEditorPlugin: GeoLibrePlugin = { @@ -239,14 +249,14 @@ function bindGeomanEditSync(map: maplibregl.Map): void { unbindGeomanEditSync(); geomanEditSyncMap = map; for (const eventName of GEOMAN_EDIT_SYNC_EVENTS) { - map.on(eventName, handleGeomanEditSync); + (map as unknown as ThirdPartyEventTarget).on(eventName, handleGeomanEditSync); } } function unbindGeomanEditSync(): void { if (!geomanEditSyncMap) return; for (const eventName of GEOMAN_EDIT_SYNC_EVENTS) { - geomanEditSyncMap.off(eventName, handleGeomanEditSync); + (geomanEditSyncMap as unknown as ThirdPartyEventTarget).off(eventName, handleGeomanEditSync); } geomanEditSyncMap = null; } @@ -1261,6 +1271,11 @@ function applyGeomanDisplayLayerOpacity( } } +/** See the cast inside `setGeomanPaintProperty`. */ +interface DynamicPaintTarget { + setPaintProperty(layerId: string, property: string, value: unknown): void; +} + function setGeomanPaintProperty( map: maplibregl.Map, layerId: string, @@ -1268,7 +1283,11 @@ function setGeomanPaintProperty( value: unknown, ): void { try { - map.setPaintProperty(layerId, property, value); + // v6 types setPaintProperty as generic over `keyof AllPaintProperties`, and + // these names are computed per layer type. Same rationale as + // `dynamic-style-property.ts` in @geolibre/map, which this package cannot + // import (plugins depends only on @geolibre/core). + (map as unknown as DynamicPaintTarget).setPaintProperty(layerId, property, value); } catch { // Geoman layers are rebuilt often and may not support every paint property. } diff --git a/tests/hooks/maplibre-default-import-shim.mjs b/tests/hooks/maplibre-default-import-shim.mjs new file mode 100644 index 000000000..13ae1c70f --- /dev/null +++ b/tests/hooks/maplibre-default-import-shim.mjs @@ -0,0 +1,42 @@ +/** + * Node module-loader analogue of `apps/geolibre-desktop/vite-plugins/maplibre-default-import-shim.ts`. + * + * MapLibre v6 is ESM-only with no default export, so a third-party bundle + * compiled against v5's default export fails to instantiate under `node --test`: + * + * SyntaxError: The requested module 'maplibre-gl' does not provide an + * export named 'default' + * + * `tests/geo-editor-plugin.test.ts` hits this because the geo-editor plugin + * statically imports Geoman. The Vite shim cannot help here — `node --test` + * does not go through Vite — so the same rewrite is applied as a load hook. + * + * Temporary, and the list only ever shrinks: see opengeos/GeoLibre#1489 + * (blocker 1). `tests/maplibre-shim-parity.test.ts` fails if this list and the + * Vite plugin's stop agreeing. + */ + +/** Packages whose published ESM still default-imports maplibre-gl. */ +export const SHIMMED_PACKAGES = ["@esri/maplibre-arcgis", "@geoman-io/maplibre-geoman-free"]; + +// Matches a default import of maplibre-gl in minified or unminified ESM: +// import Zt from"maplibre-gl" import e from "maplibre-gl" +const DEFAULT_IMPORT = /\bimport\s+([A-Za-z_$][\w$]*)\s+from\s*(["'])maplibre-gl\2/g; + +/** Rewrite default imports of maplibre-gl to namespace imports. */ +export function rewriteDefaultImports(source) { + return source.replace( + DEFAULT_IMPORT, + (_match, binding, quote) => `import * as ${binding} from ${quote}maplibre-gl${quote}`, + ); +} + +export async function load(url, context, nextLoad) { + const result = await nextLoad(url, context); + const shimmed = SHIMMED_PACKAGES.some((name) => url.includes(`/node_modules/${name}/`)); + if (!shimmed || result.format !== "module" || result.source == null) return result; + + const source = result.source.toString(); + if (!source.includes("maplibre-gl")) return result; + return { ...result, source: rewriteDefaultImports(source) }; +} diff --git a/tests/hooks/register-maplibre-shim.mjs b/tests/hooks/register-maplibre-shim.mjs new file mode 100644 index 000000000..afac2af3a --- /dev/null +++ b/tests/hooks/register-maplibre-shim.mjs @@ -0,0 +1,9 @@ +/** + * Registers the maplibre default-import load hook for `node --test`. + * Used via `node --import ./tests/hooks/register-maplibre-shim.mjs`, alongside + * tsx's own hooks — the two do not overlap (this one only rewrites published + * bundles under node_modules). + */ +import { register } from "node:module"; + +register("./maplibre-default-import-shim.mjs", import.meta.url); diff --git a/tests/map-controller.test.ts b/tests/map-controller.test.ts index a996ba462..376aeb1b4 100644 --- a/tests/map-controller.test.ts +++ b/tests/map-controller.test.ts @@ -1,8 +1,12 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; -import maplibregl from "maplibre-gl"; +import * as maplibregl from "maplibre-gl"; import { DEFAULT_LAYER_STYLE, type GeoLibreLayer, type LayerStyle } from "@geolibre/core"; -import { createMapController, MapController } from "../packages/map/src/map-controller"; +import { + createMapController, + geolocateControlFactory, + MapController, +} from "../packages/map/src/map-controller"; // Internal shape of MapController we reach into to inject a fake map. The // controller only ever constructs a real maplibregl.Map through init(), which @@ -1077,12 +1081,13 @@ function controllerWithGeolocate(): { const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); describe("MapController geolocate permission-denied recovery", () => { - const originalControl = maplibregl.GeolocateControl; + const originalCreate = geolocateControlFactory.create; function withStubbedControl(run: () => Promise): Promise { - (maplibregl as { GeolocateControl: unknown }).GeolocateControl = FakeGeolocateControl; + geolocateControlFactory.create = () => + new FakeGeolocateControl() as unknown as maplibregl.GeolocateControl; return run().finally(() => { - (maplibregl as { GeolocateControl: unknown }).GeolocateControl = originalControl; + geolocateControlFactory.create = originalCreate; }); } diff --git a/tests/mapbox-style.test.ts b/tests/mapbox-style.test.ts index 23fd514d4..11e422180 100644 --- a/tests/mapbox-style.test.ts +++ b/tests/mapbox-style.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { validateStyleMin } from "@maplibre/maplibre-gl-style-spec"; import { getMapboxAccessToken } from "@geolibre/core"; -import type maplibregl from "maplibre-gl"; +import type * as maplibregl from "maplibre-gl"; import { isMapboxStyleUrl, mapboxAccessTokenFromStyleUrl, diff --git a/tests/maplibre-shim-parity.test.ts b/tests/maplibre-shim-parity.test.ts new file mode 100644 index 000000000..36d5f7775 --- /dev/null +++ b/tests/maplibre-shim-parity.test.ts @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; +import { SHIMMED_PACKAGES } from "./hooks/maplibre-default-import-shim.mjs"; + +/** + * Two shims rewrite `import X from "maplibre-gl"` to a namespace import inside + * third-party bundles that still target v5: a Vite plugin for the app build and + * a Node load hook for `node --test` (Vite cannot reach the test runner). They + * must cover the same packages — a package shimmed in only one place fails in + * whichever environment was missed, and only there. + * + * Both are temporary. When `@esri/maplibre-arcgis` or + * `@geoman-io/maplibre-geoman-free` ship v6-compatible builds, drop the package + * from both lists together. See opengeos/GeoLibre#1489 (blocker 1). + */ +const VITE_PLUGIN = fileURLToPath( + new URL("../apps/geolibre-desktop/vite-plugins/maplibre-default-import-shim.ts", import.meta.url), +); + +function shimmedPackagesInVitePlugin(): string[] { + const source = readFileSync(VITE_PLUGIN, "utf8"); + const list = /const SHIMMED_PACKAGES = \[([^\]]*)\]/.exec(source); + assert.ok(list, "SHIMMED_PACKAGES array not found in the Vite plugin"); + return [...list[1].matchAll(/"([^"]+)"/g)].map((match) => match[1]); +} + +describe("maplibre default-import shims", () => { + it("cover the same packages in the Vite build and the Node test runner", () => { + assert.deepEqual(shimmedPackagesInVitePlugin(), [...SHIMMED_PACKAGES]); + }); + + it("rewrites the default import forms both packages actually publish", async () => { + const { rewriteDefaultImports } = await import("./hooks/maplibre-default-import-shim.mjs"); + + // @esri/maplibre-arcgis (minified, no space before the specifier — the + // rewrite normalizes that to one space, which is still valid ESM). + assert.equal( + rewriteDefaultImports('import Zt from"maplibre-gl"'), + 'import * as Zt from "maplibre-gl"', + ); + // @geoman-io/maplibre-geoman-free (spaced). + assert.equal( + rewriteDefaultImports('import e from "maplibre-gl"'), + 'import * as e from "maplibre-gl"', + ); + // Named and namespace imports are already v6-safe and must be left alone. + assert.equal( + rewriteDefaultImports('import { Popup } from "maplibre-gl"'), + 'import { Popup } from "maplibre-gl"', + ); + assert.equal( + rewriteDefaultImports('import * as ml from "maplibre-gl"'), + 'import * as ml from "maplibre-gl"', + ); + }); +}); diff --git a/tests/package.json b/tests/package.json new file mode 100644 index 000000000..3dbc1ca59 --- /dev/null +++ b/tests/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/tests/terrain-control.test.ts b/tests/terrain-control.test.ts index 377e755f0..fe3db57a9 100644 --- a/tests/terrain-control.test.ts +++ b/tests/terrain-control.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { afterEach, describe, it, mock } from "node:test"; -import type maplibregl from "maplibre-gl"; +import type * as maplibregl from "maplibre-gl"; import { TerrainControl } from "../packages/map/src/terrain-control"; // TerrainControl.onAdd builds DOM nodes; node:test has no DOM, so stand up a