From 6d838619369436195c0523a976ed44afe6c9178a Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Tue, 10 Feb 2026 21:50:14 +0100 Subject: [PATCH 1/3] feat: add deck.gl renderer with code-split dynamic loading Add a new DeckGLRenderer that uses MapLibre GL standalone + deck.gl overlay for WebGL-accelerated entity rendering. Selectable via ?renderer=deckgl URL param; deck.gl bundle (~817KB) is only loaded when requested. Pre-work: extract shared constants (icon sizes/paths/states, transition duration, grid utils) from Leaflet-specific files into src/renderers/shared/ so both renderers can reuse them. --- static-next/package-lock.json | 280 +++++++- static-next/package.json | 3 + static-next/src/App.tsx | 11 +- static-next/src/main.tsx | 21 +- .../src/renderers/deckgl/deckgl-icon-atlas.ts | 153 +++++ .../src/renderers/deckgl/deckgl-layers.ts | 166 +++++ .../src/renderers/deckgl/deckgl-renderer.ts | 628 ++++++++++++++++++ .../src/renderers/deckgl/deckgl-state.ts | 159 +++++ .../leaflet/__tests__/grid-utils.test.ts | 2 +- .../src/renderers/leaflet/leaflet-grid.ts | 2 +- .../src/renderers/leaflet/leaflet-icons.ts | 78 +-- .../renderers/leaflet/leaflet-smoothing.ts | 15 +- static-next/src/renderers/mock-renderer.ts | 4 + .../{leaflet => shared}/grid-utils.ts | 0 .../src/renderers/shared/icon-constants.ts | 68 ++ .../src/renderers/shared/transitions.ts | 11 + static-next/src/ui/__tests__/App.test.tsx | 27 +- 17 files changed, 1519 insertions(+), 109 deletions(-) create mode 100644 static-next/src/renderers/deckgl/deckgl-icon-atlas.ts create mode 100644 static-next/src/renderers/deckgl/deckgl-layers.ts create mode 100644 static-next/src/renderers/deckgl/deckgl-renderer.ts create mode 100644 static-next/src/renderers/deckgl/deckgl-state.ts rename static-next/src/renderers/{leaflet => shared}/grid-utils.ts (100%) create mode 100644 static-next/src/renderers/shared/icon-constants.ts create mode 100644 static-next/src/renderers/shared/transitions.ts diff --git a/static-next/package-lock.json b/static-next/package-lock.json index 4a358844..d03d2be2 100644 --- a/static-next/package-lock.json +++ b/static-next/package-lock.json @@ -9,8 +9,10 @@ "version": "0.0.0", "dependencies": { "@bufbuild/protobuf": "^2.11.0", + "@deck.gl/core": "^9.2.6", + "@deck.gl/layers": "^9.2.6", + "@deck.gl/mapbox": "^9.2.6", "@maplibre/maplibre-gl-leaflet": "^0.0.22", - "flatbuffers": "^25.9.23", "leaflet": "^1.9.0", "leaflet-rotatedmarker": "^0.2.0", "maplibre-gl": "^4.7.0", @@ -513,6 +515,75 @@ "node": ">=20.19.0" } }, + "node_modules/@deck.gl/core": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@deck.gl/core/-/core-9.2.6.tgz", + "integrity": "sha512-bBFfwfythPPpXS/OKUMvziQ8td84mRGMnYZfqdUvfUVltzjFtQCBQUJTzgo3LubvOzSnzo8GTWskxHaZzkqdKQ==", + "license": "MIT", + "dependencies": { + "@loaders.gl/core": "^4.2.0", + "@loaders.gl/images": "^4.2.0", + "@luma.gl/constants": "^9.2.6", + "@luma.gl/core": "^9.2.6", + "@luma.gl/engine": "^9.2.6", + "@luma.gl/shadertools": "^9.2.6", + "@luma.gl/webgl": "^9.2.6", + "@math.gl/core": "^4.1.0", + "@math.gl/sun": "^4.1.0", + "@math.gl/types": "^4.1.0", + "@math.gl/web-mercator": "^4.1.0", + "@probe.gl/env": "^4.1.0", + "@probe.gl/log": "^4.1.0", + "@probe.gl/stats": "^4.1.0", + "@types/offscreencanvas": "^2019.6.4", + "gl-matrix": "^3.0.0", + "mjolnir.js": "^3.0.0" + } + }, + "node_modules/@deck.gl/layers": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@deck.gl/layers/-/layers-9.2.6.tgz", + "integrity": "sha512-ASwL5CHm/QX+fVW+MejmtA/84RKO0BaL2/Fv9N+l+WcSII2M5s730rrTw3JgyQ66AUGFPe1SL3JDkqsUlVJ0yg==", + "license": "MIT", + "dependencies": { + "@loaders.gl/images": "^4.2.0", + "@loaders.gl/schema": "^4.2.0", + "@luma.gl/shadertools": "^9.2.6", + "@mapbox/tiny-sdf": "^2.0.5", + "@math.gl/core": "^4.1.0", + "@math.gl/polygon": "^4.1.0", + "@math.gl/web-mercator": "^4.1.0", + "earcut": "^2.2.4" + }, + "peerDependencies": { + "@deck.gl/core": "~9.2.0", + "@loaders.gl/core": "^4.2.0", + "@luma.gl/core": "~9.2.6", + "@luma.gl/engine": "~9.2.6" + } + }, + "node_modules/@deck.gl/layers/node_modules/earcut": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", + "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==", + "license": "ISC" + }, + "node_modules/@deck.gl/mapbox": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@deck.gl/mapbox/-/mapbox-9.2.6.tgz", + "integrity": "sha512-gyqCHZwiZS8LOYY6LILQQp5YCCf++VFk/wRoGskZvhb/kdEPX2Onv8iV8pXe0h9UyMLO6Mj0wl3HlJWg2ILkrg==", + "license": "MIT", + "dependencies": { + "@luma.gl/constants": "^9.2.6", + "@math.gl/web-mercator": "^4.1.0" + }, + "peerDependencies": { + "@deck.gl/core": "~9.2.0", + "@luma.gl/constants": "~9.2.6", + "@luma.gl/core": "~9.2.6", + "@math.gl/web-mercator": "^4.1.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -1023,6 +1094,129 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@loaders.gl/core": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/core/-/core-4.3.4.tgz", + "integrity": "sha512-cG0C5fMZ1jyW6WCsf4LoHGvaIAJCEVA/ioqKoYRwoSfXkOf+17KupK1OUQyUCw5XoRn+oWA1FulJQOYlXnb9Gw==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "@loaders.gl/worker-utils": "4.3.4", + "@probe.gl/log": "^4.0.2" + } + }, + "node_modules/@loaders.gl/images": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/images/-/images-4.3.4.tgz", + "integrity": "sha512-qgc33BaNsqN9cWa/xvcGvQ50wGDONgQQdzHCKDDKhV2w/uptZoR5iofJfuG8UUV2vUMMd82Uk9zbopRx2rS4Ag==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.3.4" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/loader-utils": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/loader-utils/-/loader-utils-4.3.4.tgz", + "integrity": "sha512-tjMZvlKQSaMl2qmYTAxg+ySR6zd6hQn5n3XaU8+Ehp90TD3WzxvDKOMNDqOa72fFmIV+KgPhcmIJTpq4lAdC4Q==", + "license": "MIT", + "dependencies": { + "@loaders.gl/schema": "4.3.4", + "@loaders.gl/worker-utils": "4.3.4", + "@probe.gl/log": "^4.0.2", + "@probe.gl/stats": "^4.0.2" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/schema": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/schema/-/schema-4.3.4.tgz", + "integrity": "sha512-1YTYoatgzr/6JTxqBLwDiD3AVGwQZheYiQwAimWdRBVB0JAzych7s1yBuE0CVEzj4JDPKOzVAz8KnU1TiBvJGw==", + "license": "MIT", + "dependencies": { + "@types/geojson": "^7946.0.7" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/worker-utils": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/worker-utils/-/worker-utils-4.3.4.tgz", + "integrity": "sha512-EbsszrASgT85GH3B7jkx7YXfQyIYo/rlobwMx6V3ewETapPUwdSAInv+89flnk5n2eu2Lpdeh+2zS6PvqbL2RA==", + "license": "MIT", + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@luma.gl/constants": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@luma.gl/constants/-/constants-9.2.6.tgz", + "integrity": "sha512-rvFFrJuSm5JIWbDHFuR4Q2s4eudO3Ggsv0TsGKn9eqvO7bBiPm/ANugHredvh3KviEyYuMZZxtfJvBdr3kzldg==", + "license": "MIT" + }, + "node_modules/@luma.gl/core": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@luma.gl/core/-/core-9.2.6.tgz", + "integrity": "sha512-d8KcH8ZZcjDAodSN/G2nueA9YE2X8kMz7Q0OxDGpCww6to1MZXM3Ydate/Jqsb5DDKVgUF6yD6RL8P5jOki9Yw==", + "license": "MIT", + "dependencies": { + "@math.gl/types": "^4.1.0", + "@probe.gl/env": "^4.0.8", + "@probe.gl/log": "^4.0.8", + "@probe.gl/stats": "^4.0.8", + "@types/offscreencanvas": "^2019.6.4" + } + }, + "node_modules/@luma.gl/engine": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@luma.gl/engine/-/engine-9.2.6.tgz", + "integrity": "sha512-1AEDs2AUqOWh7Wl4onOhXmQF+Rz1zNdPXF+Kxm4aWl92RQ42Sh2CmTvRt2BJku83VQ91KFIEm/v3qd3Urzf+Uw==", + "license": "MIT", + "dependencies": { + "@math.gl/core": "^4.1.0", + "@math.gl/types": "^4.1.0", + "@probe.gl/log": "^4.0.8", + "@probe.gl/stats": "^4.0.8" + }, + "peerDependencies": { + "@luma.gl/core": "~9.2.0", + "@luma.gl/shadertools": "~9.2.0" + } + }, + "node_modules/@luma.gl/shadertools": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@luma.gl/shadertools/-/shadertools-9.2.6.tgz", + "integrity": "sha512-4+uUbynqPUra9d/z1nQChyHmhLgmKfSMjS7kOwLB6exSnhKnpHL3+Hu9fv55qyaX50nGH3oHawhGtJ6RRvu65w==", + "license": "MIT", + "dependencies": { + "@math.gl/core": "^4.1.0", + "@math.gl/types": "^4.1.0", + "wgsl_reflect": "^1.2.0" + }, + "peerDependencies": { + "@luma.gl/core": "~9.2.0" + } + }, + "node_modules/@luma.gl/webgl": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@luma.gl/webgl/-/webgl-9.2.6.tgz", + "integrity": "sha512-NGBTdxJMk7j8Ygr1zuTyAvr1Tw+EpupMIQo7RelFjEsZXg6pujFqiDMM+rgxex8voCeuhWBJc7Rs+MoSqd46UQ==", + "license": "MIT", + "dependencies": { + "@luma.gl/constants": "9.2.6", + "@math.gl/types": "^4.1.0", + "@probe.gl/env": "^4.0.8" + }, + "peerDependencies": { + "@luma.gl/core": "~9.2.0" + } + }, "node_modules/@mapbox/geojson-rewind": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz", @@ -1117,6 +1311,66 @@ "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==", "license": "ISC" }, + "node_modules/@math.gl/core": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@math.gl/core/-/core-4.1.0.tgz", + "integrity": "sha512-FrdHBCVG3QdrworwrUSzXIaK+/9OCRLscxI2OUy6sLOHyHgBMyfnEGs99/m3KNvs+95BsnQLWklVfpKfQzfwKA==", + "license": "MIT", + "dependencies": { + "@math.gl/types": "4.1.0" + } + }, + "node_modules/@math.gl/polygon": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@math.gl/polygon/-/polygon-4.1.0.tgz", + "integrity": "sha512-YA/9PzaCRHbIP5/0E9uTYrqe+jsYTQoqoDWhf6/b0Ixz8bPZBaGDEafLg3z7ffBomZLacUty9U3TlPjqMtzPjA==", + "license": "MIT", + "dependencies": { + "@math.gl/core": "4.1.0" + } + }, + "node_modules/@math.gl/sun": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@math.gl/sun/-/sun-4.1.0.tgz", + "integrity": "sha512-i3q6OCBLSZ5wgZVhXg+X7gsjY/TUtuFW/2KBiq/U1ypLso3S4sEykoU/MGjxUv1xiiGtr+v8TeMbO1OBIh/HmA==", + "license": "MIT" + }, + "node_modules/@math.gl/types": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@math.gl/types/-/types-4.1.0.tgz", + "integrity": "sha512-clYZdHcmRvMzVK5fjeDkQlHUzXQSNdZ7s4xOqC3nJPgz4C/TZkUecTo9YS4PruZqtDda/ag4erndP0MIn40dGA==", + "license": "MIT" + }, + "node_modules/@math.gl/web-mercator": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@math.gl/web-mercator/-/web-mercator-4.1.0.tgz", + "integrity": "sha512-HZo3vO5GCMkXJThxRJ5/QYUYRr3XumfT8CzNNCwoJfinxy5NtKUd7dusNTXn7yJ40UoB8FMIwkVwNlqaiRZZAw==", + "license": "MIT", + "dependencies": { + "@math.gl/core": "4.1.0" + } + }, + "node_modules/@probe.gl/env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@probe.gl/env/-/env-4.1.0.tgz", + "integrity": "sha512-5ac2Jm2K72VCs4eSMsM7ykVRrV47w32xOGMvcgqn8vQdEMF9PRXyBGYEV9YbqRKWNKpNKmQJVi4AHM/fkCxs9w==", + "license": "MIT" + }, + "node_modules/@probe.gl/log": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@probe.gl/log/-/log-4.1.0.tgz", + "integrity": "sha512-r4gRReNY6f+OZEMgfWEXrAE2qJEt8rX0HsDJQXUBMoc+5H47bdB7f/5HBHAmapK8UydwPKL9wCDoS22rJ0yq7Q==", + "license": "MIT", + "dependencies": { + "@probe.gl/env": "4.1.0" + } + }, + "node_modules/@probe.gl/stats": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@probe.gl/stats/-/stats-4.1.0.tgz", + "integrity": "sha512-EI413MkWKBDVNIfLdqbeNSJTs7ToBz/KVGkwi3D+dQrSIkRI2IYbWGAU3xX+D6+CI4ls8ehxMhNpUVMaZggDvQ==", + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.57.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", @@ -1664,6 +1918,12 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/offscreencanvas": { + "version": "2019.7.3", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", + "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", + "license": "MIT" + }, "node_modules/@types/pbf": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz", @@ -2299,12 +2559,6 @@ "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", "license": "MIT" }, - "node_modules/flatbuffers": { - "version": "25.9.23", - "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz", - "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==", - "license": "Apache-2.0" - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2723,6 +2977,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mjolnir.js": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mjolnir.js/-/mjolnir.js-3.0.0.tgz", + "integrity": "sha512-siX3YCG7N2HnmN1xMH3cK4JkUZJhbkhRFJL+G5N1vH0mh1t5088rJknIoqDFWDIU6NPGvRRgLnYW3ZHjSMEBLA==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3608,6 +3868,12 @@ "node": ">=20" } }, + "node_modules/wgsl_reflect": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/wgsl_reflect/-/wgsl_reflect-1.2.3.tgz", + "integrity": "sha512-BQWBIsOn411M+ffBxmA6QRLvAOVbuz3Uk4NusxnqC1H7aeQcVLhdA3k2k/EFFFtqVjhz3z7JOOZF1a9hj2tv4Q==", + "license": "MIT" + }, "node_modules/whatwg-mimetype": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", diff --git a/static-next/package.json b/static-next/package.json index f228ec34..36816fa6 100644 --- a/static-next/package.json +++ b/static-next/package.json @@ -12,6 +12,9 @@ }, "dependencies": { "@bufbuild/protobuf": "^2.11.0", + "@deck.gl/core": "^9.2.6", + "@deck.gl/layers": "^9.2.6", + "@deck.gl/mapbox": "^9.2.6", "@maplibre/maplibre-gl-leaflet": "^0.0.22", "leaflet": "^1.9.0", "leaflet-rotatedmarker": "^0.2.0", diff --git a/static-next/src/App.tsx b/static-next/src/App.tsx index 9f281301..ec2cbd4e 100644 --- a/static-next/src/App.tsx +++ b/static-next/src/App.tsx @@ -10,7 +10,6 @@ import type { DecoderStrategy } from "./data/decoders/decoder.interface"; import { ChunkManager } from "./data/chunk-manager"; import { PlaybackEngine } from "./playback/engine"; import { MarkerManager } from "./playback/marker-manager"; -import { LeafletRenderer } from "./renderers/leaflet/leaflet-renderer"; import type { MapRenderer } from "./renderers/renderer.interface"; import { EngineProvider } from "./ui/hooks/useEngine"; import { RendererProvider } from "./ui/hooks/useRenderer"; @@ -43,9 +42,10 @@ function parseUrlParams(): { zoom?: number; x?: number; y?: number; + renderer?: string; } { const params = new URLSearchParams(window.location.search); - const result: { op?: string; zoom?: number; x?: number; y?: number } = {}; + const result: { op?: string; zoom?: number; x?: number; y?: number; renderer?: string } = {}; const op = params.get("op"); if (op) result.op = op; @@ -68,6 +68,9 @@ function parseUrlParams(): { if (!Number.isNaN(n)) result.y = n; } + const renderer = params.get("renderer"); + if (renderer) result.renderer = renderer; + return result; } @@ -77,9 +80,9 @@ function parseUrlParams(): { * Wires together the API client, playback engine, and renderer. * Renders the MapContainer filling the viewport with panel overlays. */ -export function App(): JSX.Element { +export function App(props: { renderer: MapRenderer }): JSX.Element { const api = new ApiClient(); - const renderer: MapRenderer = new LeafletRenderer(); + const renderer = props.renderer; const engine = new PlaybackEngine(renderer); const markerManager = new MarkerManager(renderer); const [worldConfig, setWorldConfig] = createSignal( diff --git a/static-next/src/main.tsx b/static-next/src/main.tsx index 4f952875..8b6a1d2e 100644 --- a/static-next/src/main.tsx +++ b/static-next/src/main.tsx @@ -1,7 +1,22 @@ import { render } from "solid-js/web"; import { App } from "./App"; +import type { MapRenderer } from "./renderers/renderer.interface"; +import { LeafletRenderer } from "./renderers/leaflet/leaflet-renderer"; -const root = document.getElementById("root"); -if (root) { - render(() => , root); +async function bootstrap(): Promise { + let renderer: MapRenderer; + const params = new URLSearchParams(window.location.search); + if (params.get("renderer") === "deckgl") { + const { DeckGLRenderer } = await import("./renderers/deckgl/deckgl-renderer"); + renderer = new DeckGLRenderer(); + } else { + renderer = new LeafletRenderer(); + } + + const root = document.getElementById("root"); + if (root) { + render(() => , root); + } } + +void bootstrap(); diff --git a/static-next/src/renderers/deckgl/deckgl-icon-atlas.ts b/static-next/src/renderers/deckgl/deckgl-icon-atlas.ts new file mode 100644 index 00000000..551883b4 --- /dev/null +++ b/static-next/src/renderers/deckgl/deckgl-icon-atlas.ts @@ -0,0 +1,153 @@ +import { ICON_PATHS, ICON_SIZES, ICON_STATES } from "../shared/icon-constants"; +import { SIDE_CLASS } from "../../config/side-colors"; +import type { Side, AliveState } from "../../data/types"; + +// --------------- Types --------------- + +export interface IconMapping { + x: number; + y: number; + width: number; + height: number; + anchorY: number; +} + +export interface IconAtlas { + atlasUrl: string; + mapping: Record; +} + +// --------------- Constants --------------- + +const CELL_SIZE = 64; +const ENTITY_TYPES = Object.keys(ICON_PATHS); + +// --------------- Key generation --------------- + +/** + * Build the icon atlas key for a given entity type, side, and alive state. + * This key is used both when building the atlas and when looking up icons + * during rendering. + */ +export function getIconKey( + iconType: string, + side: Side | null, + alive: AliveState, +): string { + const type = ICON_PATHS[iconType] ? iconType : "unknown"; + let variant: string; + if (alive === 0) { + variant = "dead"; + } else if (alive === 2) { + variant = "unconscious"; + } else if (side) { + variant = SIDE_CLASS[side] ?? "unknown"; + } else { + variant = "dead"; // null side (empty vehicle) + } + return `${type}:${variant}`; +} + +// --------------- Atlas builder --------------- + +/** + * Load a single SVG as an Image, resolve with the Image element. + * Returns null if loading fails. + */ +function loadSVG(url: string): Promise { + return new Promise((resolve) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => resolve(null); + img.crossOrigin = "anonymous"; + img.src = url; + }); +} + +/** + * Build a single-canvas icon atlas containing all entity type × state + * combinations. Returns the canvas and a mapping from icon keys to + * atlas coordinates. + * + * Atlas layout: columns = entity types, rows = icon states. + * Each cell is CELL_SIZE × CELL_SIZE pixels. + */ +export async function buildIconAtlas(): Promise { + const cols = ENTITY_TYPES.length; + const rows = ICON_STATES.length; + const width = cols * CELL_SIZE; + const height = rows * CELL_SIZE; + + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d")!; + + const mapping: Record = {}; + + // Load all SVGs in parallel + const loadPromises: Array<{ + col: number; + row: number; + key: string; + promise: Promise; + type: string; + }> = []; + + for (let col = 0; col < cols; col++) { + const type = ENTITY_TYPES[col]; + const path = ICON_PATHS[type]; + for (let row = 0; row < rows; row++) { + const state = ICON_STATES[row]; + const key = `${type}:${state}`; + const url = `${path}${state}.svg`; + loadPromises.push({ + col, + row, + key, + promise: loadSVG(url), + type, + }); + } + } + + const results = await Promise.all( + loadPromises.map(async (entry) => ({ + ...entry, + img: await entry.promise, + })), + ); + + for (const { col, row, key, img, type } of results) { + const x = col * CELL_SIZE; + const y = row * CELL_SIZE; + + if (img) { + // Draw SVG centered in cell + const [srcW, srcH] = ICON_SIZES[type] ?? ICON_SIZES.unknown; + // Scale up to fill cell while maintaining aspect ratio + const scale = Math.min(CELL_SIZE / srcW, CELL_SIZE / srcH); + const dw = srcW * scale; + const dh = srcH * scale; + const dx = x + (CELL_SIZE - dw) / 2; + const dy = y + (CELL_SIZE - dh) / 2; + ctx.drawImage(img, dx, dy, dw, dh); + } else { + // Fallback: draw a colored circle + ctx.beginPath(); + ctx.arc(x + CELL_SIZE / 2, y + CELL_SIZE / 2, CELL_SIZE / 4, 0, Math.PI * 2); + ctx.fillStyle = "#888888"; + ctx.fill(); + } + + mapping[key] = { + x, + y, + width: CELL_SIZE, + height: CELL_SIZE, + anchorY: CELL_SIZE / 2, + }; + } + + return { atlasUrl: canvas.toDataURL("image/png"), mapping }; +} diff --git a/static-next/src/renderers/deckgl/deckgl-layers.ts b/static-next/src/renderers/deckgl/deckgl-layers.ts new file mode 100644 index 00000000..566a076a --- /dev/null +++ b/static-next/src/renderers/deckgl/deckgl-layers.ts @@ -0,0 +1,166 @@ +import { IconLayer, TextLayer, LineLayer, PathLayer, PolygonLayer, ScatterplotLayer } from "@deck.gl/layers"; +import type { Layer } from "@deck.gl/core"; +import type { IconAtlas } from "./deckgl-icon-atlas"; +import type { + EntityData, + LineData, + BriefingPolygonData, + BriefingPathData, + BriefingIconData, + PulseData, +} from "./deckgl-state"; +import { ICON_SIZES } from "../shared/icon-constants"; + +// --------------- Entity layers --------------- + +export function buildEntityIconLayer( + entities: EntityData[], + atlas: IconAtlas, + transitions?: { getPosition?: { duration: number } }, +): Layer { + const visible = entities.filter((e) => e.visible); + return new IconLayer({ + id: "entity-icons", + data: visible, + iconAtlas: atlas.atlasUrl, + iconMapping: atlas.mapping, + getIcon: (d) => d.iconKey, + getPosition: (d) => d.position, + getAngle: (d) => -d.angle, // deck.gl uses counter-clockwise + getSize: (d) => { + const size = ICON_SIZES[d.iconType] ?? ICON_SIZES.unknown; + return Math.max(size[0], size[1]) * d.sizeScale; + }, + getColor: (d) => [255, 255, 255, Math.round(d.opacity * 255)], + sizeScale: 1, + sizeUnits: "pixels", + billboard: true, + alphaCutoff: 0.05, + pickable: true, + transitions: transitions, + updateTriggers: { + getIcon: visible.map((e) => e.iconKey), + getAngle: visible.map((e) => e.angle), + getSize: visible.map((e) => e.iconType), + getColor: visible.map((e) => e.opacity), + }, + }); +} + +export function buildEntityLabelLayer( + entities: EntityData[], + nameMode: "players" | "all" | "none", +): Layer { + let visible: EntityData[]; + if (nameMode === "none") { + visible = []; + } else if (nameMode === "players") { + visible = entities.filter((e) => e.visible && e.isPlayer); + } else { + visible = entities.filter((e) => e.visible); + } + + return new TextLayer({ + id: "entity-labels", + data: visible, + getPosition: (d) => d.position, + getText: (d) => d.name, + getColor: [255, 255, 255, 220], + getSize: 12, + sizeUnits: "pixels", + getPixelOffset: [0, -20], + billboard: true, + background: true, + getBackgroundColor: [0, 0, 0, 160], + getBorderColor: [0, 0, 0, 0], + fontFamily: "Arial, sans-serif", + fontWeight: "normal", + getTextAnchor: "middle", + getAlignmentBaseline: "center", + pickable: false, + }); +} + +// --------------- Fire lines --------------- + +export function buildFireLineLayer(lines: LineData[]): Layer { + return new LineLayer({ + id: "fire-lines", + data: lines, + getSourcePosition: (d) => d.from, + getTargetPosition: (d) => d.to, + getColor: (d) => d.color, + getWidth: (d) => d.width, + widthUnits: "pixels", + pickable: false, + }); +} + +// --------------- Briefing layers --------------- + +export function buildBriefingPolygonLayer(polygons: BriefingPolygonData[]): Layer { + return new PolygonLayer({ + id: "briefing-polygons", + data: polygons, + getPolygon: (d) => d.polygon, + getFillColor: (d) => d.fillColor, + getLineColor: (d) => d.lineColor, + getLineWidth: 2, + lineWidthUnits: "pixels", + stroked: true, + filled: true, + pickable: false, + }); +} + +export function buildBriefingPathLayer(paths: BriefingPathData[]): Layer { + return new PathLayer({ + id: "briefing-paths", + data: paths, + getPath: (d) => d.path, + getColor: (d) => d.color, + getWidth: (d) => d.width, + widthUnits: "pixels", + pickable: false, + }); +} + +export function buildBriefingIconLayer(icons: BriefingIconData[]): Layer { + // Each briefing icon has a unique URL, so use individual icon atlases + return new IconLayer({ + id: "briefing-icons", + data: icons, + getPosition: (d) => d.position, + getIcon: (d) => ({ + url: d.iconUrl, + width: d.size[0], + height: d.size[1], + anchorY: d.size[1] / 2, + }), + getAngle: (d) => -d.angle, + getSize: (d) => Math.max(d.size[0], d.size[1]), + getColor: (d) => [255, 255, 255, Math.round(d.opacity * 255)], + sizeUnits: "pixels", + billboard: true, + pickable: false, + }); +} + +// --------------- Pulse effects --------------- + +export function buildPulseLayer(pulses: PulseData[]): Layer { + return new ScatterplotLayer({ + id: "pulse-effects", + data: pulses, + getPosition: (d) => d.position, + getRadius: (d) => d.radius, + getFillColor: (d) => d.fillColor, + getLineColor: (d) => d.color, + getLineWidth: 2, + lineWidthUnits: "pixels", + radiusUnits: "pixels", + stroked: true, + filled: true, + pickable: false, + }); +} diff --git a/static-next/src/renderers/deckgl/deckgl-renderer.ts b/static-next/src/renderers/deckgl/deckgl-renderer.ts new file mode 100644 index 00000000..e5c27f7f --- /dev/null +++ b/static-next/src/renderers/deckgl/deckgl-renderer.ts @@ -0,0 +1,628 @@ +import maplibregl from "maplibre-gl"; +import { MapboxOverlay } from "@deck.gl/mapbox"; +import type { Layer } from "@deck.gl/core"; +import type { ArmaCoord } from "../../utils/coordinates"; +import { METERS_PER_DEGREE } from "../../utils/coordinates"; +import { closestEquivalentAngle } from "../../utils/math"; +import type { WorldConfig, Side, AliveState } from "../../data/types"; +import type { MapRenderer } from "../renderer.interface"; +import type { + MarkerHandle, + EntityMarkerOpts, + EntityMarkerState, + BriefingMarkerHandle, + BriefingMarkerDef, + BriefingMarkerState, + LineHandle, + LineOpts, + PulseHandle, + PulseOpts, + RenderLayer, + RendererEvent, + RendererControls, +} from "../renderer.types"; +import { SIDE_CLASS } from "../../config/side-colors"; +import { + DeckState, + hexToRGBA, +} from "./deckgl-state"; +import type { + EntityData, + BriefingPolygonData, + BriefingPathData, + BriefingIconData, + PulseData, +} from "./deckgl-state"; +import { buildIconAtlas, getIconKey } from "./deckgl-icon-atlas"; +import type { IconAtlas } from "./deckgl-icon-atlas"; +import { + buildEntityIconLayer, + buildEntityLabelLayer, + buildFireLineLayer, + buildBriefingPolygonLayer, + buildBriefingPathLayer, + buildBriefingIconLayer, + buildPulseLayer, +} from "./deckgl-layers"; +import { getTransitionDuration } from "../shared/transitions"; + +// --------------- Internal handle types --------------- + +interface InternalMarkerHandle { + id: number; + lastDirection: number; +} + +interface InternalBriefingHandle { + id: number; + shape: "ICON" | "ELLIPSE" | "RECTANGLE" | "POLYLINE"; + size?: [number, number]; +} + +interface InternalLineHandle { + id: number; +} + +interface InternalPulseHandle { + id: number; +} + +function wrapMarker(data: InternalMarkerHandle): MarkerHandle { + return { _brand: undefined as any, _internal: data } as unknown as MarkerHandle; +} +function unwrapMarker(handle: MarkerHandle): InternalMarkerHandle { + return (handle as any)._internal as InternalMarkerHandle; +} +function wrapBriefing(data: InternalBriefingHandle): BriefingMarkerHandle { + return { _brand: undefined as any, _internal: data } as unknown as BriefingMarkerHandle; +} +function unwrapBriefing(handle: BriefingMarkerHandle): InternalBriefingHandle { + return (handle as any)._internal as InternalBriefingHandle; +} +function wrapLine(data: InternalLineHandle): LineHandle { + return { _brand: undefined as any, _internal: data } as unknown as LineHandle; +} +function unwrapLine(handle: LineHandle): InternalLineHandle { + return (handle as any)._internal as InternalLineHandle; +} +function wrapPulse(data: InternalPulseHandle): PulseHandle { + return { _brand: undefined as any, _internal: data } as unknown as PulseHandle; +} +function unwrapPulse(handle: PulseHandle): InternalPulseHandle { + return (handle as any)._internal as InternalPulseHandle; +} + +// --------------- Coordinate conversion (pure) --------------- + +function armaToLngLat(coords: ArmaCoord): [number, number] { + return [coords[0] / METERS_PER_DEGREE, coords[1] / METERS_PER_DEGREE]; +} + +function lngLatToArma(lngLat: [number, number]): ArmaCoord { + return [lngLat[0] * METERS_PER_DEGREE, lngLat[1] * METERS_PER_DEGREE]; +} + +// --------------- Renderer --------------- + +export class DeckGLRenderer implements MapRenderer { + private map!: maplibregl.Map; + private overlay!: MapboxOverlay; + private state!: DeckState; + private iconAtlas!: IconAtlas; + + private nameDisplayMode: "players" | "all" | "none" = "players"; + private smoothingEnabled = false; + private smoothingSpeed = 1; + + private listeners = new Map void>>(); + + // ==================== Lifecycle ==================== + + init(container: HTMLElement, world: WorldConfig): void { + const worldSizeDeg = world.worldSize / METERS_PER_DEGREE; + + // Register PMTiles protocol (idempotent) + this.registerPMTiles(); + + // Resolve font glyph base URL + const fontsBaseURL = new URL("images/maps/fonts/", window.location.href).href; + + // Build style URL + const styleUrl = world.tileBaseUrl + ? `${world.tileBaseUrl}/styles/topo.json` + : undefined; + + // transformRequest: rewrite glyph requests to Go server's font endpoint + const transformRequest = (url: string, resourceType?: string) => { + if (resourceType === "Glyphs") { + const match = url.match(/([^/]+)\/(\d+-\d+\.pbf)(?:\?|$)/); + if (match) { + return { url: fontsBaseURL + match[1] + "/" + match[2] }; + } + } + }; + + // Create MapLibre map (standalone, no Leaflet) + this.map = new maplibregl.Map({ + container, + style: styleUrl ?? { + version: 8, + sources: {}, + layers: [{ id: "background", type: "background", paint: { "background-color": "#1a1a2e" } }], + }, + center: [worldSizeDeg / 2, worldSizeDeg / 2], + zoom: 12, + minZoom: 10, + maxZoom: 20, + transformRequest, + attributionControl: {}, + // Disable rotation/pitch: 2D icons have no height axis, so tilting + // the camera causes them to clip into the ground or get cut off. + dragRotate: false, + pitchWithRotate: false, + touchPitch: false, + }); + + // Initialize state and overlay + this.state = new DeckState( + () => this.buildLayers(), + (layers) => this.overlay.setProps({ layers }), + ); + + this.overlay = new MapboxOverlay({ + layers: [], + interleaved: false, + }); + + this.map.addControl(this.overlay as any); + + // Build icon atlas async, then trigger first render + void buildIconAtlas().then((atlas) => { + this.iconAtlas = atlas; + this.state.flushNow(); + }); + + // Forward map events + this.map.on("zoomend", () => { + this.fireEvent("zoom", this.map.getZoom()); + }); + this.map.on("dragstart", () => { + this.fireEvent("dragstart"); + }); + this.map.on("click", (e) => { + this.fireEvent("click", lngLatToArma([e.lngLat.lng, e.lngLat.lat])); + }); + + // Fit to world bounds + this.map.fitBounds( + [[0, 0], [worldSizeDeg, worldSizeDeg]] as maplibregl.LngLatBoundsLike, + { animate: false }, + ); + } + + private registerPMTiles(): void { + if ((window as any)._pmtilesRegistered) return; + void (async () => { + try { + const { Protocol } = await import("pmtiles"); + const protocol = new Protocol(); + maplibregl.addProtocol("pmtiles", protocol.tile); + (window as any)._pmtilesRegistered = true; + } catch { + // PMTiles not available + } + })(); + } + + dispose(): void { + if (this.state) { + this.state.dispose(); + } + this.listeners.clear(); + if (this.map) { + this.map.remove(); + } + } + + // ==================== Layer building ==================== + + private buildLayers(): Layer[] { + const layers: Layer[] = []; + const s = this.state; + + if (s.enabledLayers.has("entities") && this.iconAtlas) { + const entities = Array.from(s.entities.values()); + const transitions = this.smoothingEnabled + ? { getPosition: { duration: getTransitionDuration(this.smoothingSpeed) * 1000 } } + : undefined; + layers.push(buildEntityIconLayer(entities, this.iconAtlas, transitions)); + layers.push(buildEntityLabelLayer(entities, this.nameDisplayMode)); + } + + if (s.enabledLayers.has("projectileMarkers")) { + const lines = Array.from(s.lines.values()); + if (lines.length > 0) { + layers.push(buildFireLineLayer(lines)); + } + } + + if (s.enabledLayers.has("briefingMarkers")) { + const polygons = Array.from(s.briefingPolygons.values()); + const paths = Array.from(s.briefingPaths.values()); + const icons = Array.from(s.briefingIcons.values()); + if (polygons.length > 0) layers.push(buildBriefingPolygonLayer(polygons)); + if (paths.length > 0) layers.push(buildBriefingPathLayer(paths)); + if (icons.length > 0) layers.push(buildBriefingIconLayer(icons)); + } + + const pulses = Array.from(s.pulses.values()); + if (pulses.length > 0) { + layers.push(buildPulseLayer(pulses)); + } + + return layers; + } + + // ==================== Camera ==================== + + getZoom(): number { + return this.map.getZoom(); + } + + setView(armaPos: ArmaCoord, zoom?: number, animate?: boolean): void { + const center = armaToLngLat(armaPos); + if (animate ?? true) { + this.map.flyTo({ + center: center as maplibregl.LngLatLike, + zoom: zoom ?? this.map.getZoom(), + duration: 500, + }); + } else { + this.map.jumpTo({ + center: center as maplibregl.LngLatLike, + zoom: zoom ?? this.map.getZoom(), + }); + } + } + + fitBounds(sw: ArmaCoord, ne: ArmaCoord): void { + const swLngLat = armaToLngLat(sw); + const neLngLat = armaToLngLat(ne); + this.map.fitBounds( + [swLngLat, neLngLat] as maplibregl.LngLatBoundsLike, + ); + } + + getCenter(): ArmaCoord { + const c = this.map.getCenter(); + return lngLatToArma([c.lng, c.lat]); + } + + // ==================== Entity markers ==================== + + createEntityMarker(id: number, opts: EntityMarkerOpts): MarkerHandle { + const position = armaToLngLat(opts.position); + const iconKey = getIconKey(opts.iconType, opts.side, 1); + + const entity: EntityData = { + id, + position, + angle: 0, + iconKey, + iconType: opts.iconType, + opacity: 1, + sizeScale: 1, + name: opts.name, + isPlayer: opts.isPlayer, + visible: true, + }; + + this.state.entities.set(id, entity); + this.state.markDirty(); + + return wrapMarker({ id, lastDirection: 0 }); + } + + updateEntityMarker(handle: MarkerHandle, state: EntityMarkerState): void { + const internal = unwrapMarker(handle); + const entity = this.state.entities.get(internal.id); + if (!entity) return; + + entity.position = armaToLngLat(state.position); + + // Smooth angle transitions + const newAngle = closestEquivalentAngle(internal.lastDirection, state.direction); + entity.angle = newAngle; + internal.lastDirection = newAngle; + + entity.iconKey = getIconKey(state.iconType, state.side, state.alive); + entity.iconType = state.iconType; + entity.opacity = state.alive === 0 ? 0.4 : 1; + entity.name = state.name; + entity.isPlayer = state.isPlayer; + entity.visible = !state.isInVehicle; + + this.state.markDirty(); + } + + removeEntityMarker(handle: MarkerHandle): void { + const internal = unwrapMarker(handle); + this.state.entities.delete(internal.id); + this.state.markDirty(); + } + + // ==================== Briefing markers ==================== + + createBriefingMarker(def: BriefingMarkerDef): BriefingMarkerHandle { + const id = this.state.allocBriefingId(); + const cssColor = `#${def.color}`; + + if (def.shape === "POLYLINE") { + const pathData: BriefingPathData = { + id, + path: [], + color: hexToRGBA(cssColor, 1), + width: 2, + }; + this.state.briefingPaths.set(id, pathData); + } else if (def.shape === "ELLIPSE" || def.shape === "RECTANGLE") { + const fillAlpha = this.getBrushFillAlpha(def.brush); + const polyData: BriefingPolygonData = { + id, + polygon: [], + fillColor: hexToRGBA(cssColor, fillAlpha), + lineColor: hexToRGBA(cssColor, 1), + stroke: this.getBrushStroke(def.brush), + }; + this.state.briefingPolygons.set(id, polyData); + } else { + // ICON + const isMagIcon = def.type.indexOf("magIcons") > -1; + let iconUrl: string; + if (isMagIcon) { + iconUrl = `images/markers/${def.type.toLowerCase()}.png`; + } else { + iconUrl = `images/markers/${def.type}/${def.color}.png`; + } + const iconSize: [number, number] = def.size + ? [def.size[0] * 35, def.size[1] * 35] + : [35, 35]; + + const iconData: BriefingIconData = { + id, + position: [0, 0], + iconUrl, + size: iconSize, + angle: 0, + opacity: 1, + }; + this.state.briefingIcons.set(id, iconData); + } + + this.state.markDirty(); + return wrapBriefing({ id, shape: def.shape, size: def.size }); + } + + updateBriefingMarker( + handle: BriefingMarkerHandle, + state: BriefingMarkerState, + ): void { + const internal = unwrapBriefing(handle); + + if (internal.shape === "ICON") { + const icon = this.state.briefingIcons.get(internal.id); + if (!icon) return; + icon.position = armaToLngLat(state.position); + icon.opacity = state.alpha; + icon.angle = state.direction; + } else if (internal.shape === "ELLIPSE") { + const poly = this.state.briefingPolygons.get(internal.id); + if (!poly) return; + const [cx, cy] = state.position; + const rx = internal.size?.[0] ?? 100; + const ry = internal.size?.[1] ?? 100; + const rad = state.direction * (Math.PI / 180); + const cos = Math.cos(rad); + const sin = Math.sin(rad); + + const ring: [number, number][] = []; + for (let i = 0; i < 36; i++) { + const angle = (i / 36) * 2 * Math.PI; + const dx = rx * Math.cos(angle); + const dy = ry * Math.sin(angle); + ring.push(armaToLngLat([ + cx + cos * dx - sin * dy, + cy + sin * dx + cos * dy, + ])); + } + poly.polygon = ring; + poly.fillColor[3] = Math.round(Math.min(poly.fillColor[3] / 255, state.alpha) * 255); + } else if (internal.shape === "RECTANGLE") { + const poly = this.state.briefingPolygons.get(internal.id); + if (!poly) return; + const [cx, cy] = state.position; + const sx = internal.size?.[0] ?? 100; + const sy = internal.size?.[1] ?? 100; + const rad = state.direction * (Math.PI / 180); + const cos = Math.cos(rad); + const sin = Math.sin(rad); + + const corners: [number, number][] = [ + [-sx, +sy], [+sx, +sy], [+sx, -sy], [-sx, -sy], + ]; + poly.polygon = corners.map(([dx, dy]) => + armaToLngLat([ + cx + cos * dx - sin * dy, + cy + sin * dx + cos * dy, + ]), + ); + poly.fillColor[3] = Math.round(Math.min(poly.fillColor[3] / 255, state.alpha) * 255); + } else if (internal.shape === "POLYLINE" && state.points) { + const path = this.state.briefingPaths.get(internal.id); + if (!path) return; + path.path = state.points.map((p) => armaToLngLat(p)); + path.color[3] = Math.round(state.alpha * 255); + } + + this.state.markDirty(); + } + + removeBriefingMarker(handle: BriefingMarkerHandle): void { + const internal = unwrapBriefing(handle); + this.state.briefingPolygons.delete(internal.id); + this.state.briefingPaths.delete(internal.id); + this.state.briefingIcons.delete(internal.id); + this.state.markDirty(); + } + + // ==================== Briefing helpers ==================== + + private getBrushFillAlpha(brush?: string): number { + switch (brush?.toLowerCase()) { + case "solidfull": return 0.8; + case "border": return 0; + case "solidborder": return 0.3; + default: return 0.3; + } + } + + private getBrushStroke(brush?: string): boolean { + switch (brush?.toLowerCase()) { + case "border": + case "solidborder": + return true; + default: + return false; + } + } + + // ==================== Lines ==================== + + addLine(from: ArmaCoord, to: ArmaCoord, opts: LineOpts): LineHandle { + const id = this.state.allocLineId(); + this.state.lines.set(id, { + id, + from: armaToLngLat(from), + to: armaToLngLat(to), + color: hexToRGBA(opts.color, opts.opacity), + width: opts.weight, + }); + this.state.markDirty(); + return wrapLine({ id }); + } + + removeLine(handle: LineHandle): void { + const internal = unwrapLine(handle); + this.state.lines.delete(internal.id); + this.state.markDirty(); + } + + // ==================== Pulses ==================== + + addPulse(pos: ArmaCoord, opts: PulseOpts): PulseHandle { + const id = this.state.allocPulseId(); + const maxRadius = Math.max(opts.iconSize[0], opts.iconSize[1]) / 2; + const pulse: PulseData = { + id, + position: armaToLngLat(pos), + color: hexToRGBA(opts.color, 1), + fillColor: hexToRGBA(opts.fillColor, 0.5), + radius: 0, + maxRadius, + }; + this.state.pulses.set(id, pulse); + + // Animate radius expansion + const iterations = opts.iterationCount ?? 3; + const durationMs = 800; + let iteration = 0; + const startTime = performance.now(); + + const animate = () => { + const elapsed = performance.now() - startTime; + const cycleProgress = (elapsed % durationMs) / durationMs; + iteration = Math.floor(elapsed / durationMs); + + if (iteration >= iterations) { + this.state.pulses.delete(id); + this.state.markDirty(); + return; + } + + pulse.radius = cycleProgress * maxRadius; + pulse.fillColor[3] = Math.round((1 - cycleProgress) * 128); + this.state.markDirty(); + pulse.animFrameId = requestAnimationFrame(animate); + }; + pulse.animFrameId = requestAnimationFrame(animate); + + this.state.markDirty(); + return wrapPulse({ id }); + } + + removePulse(handle: PulseHandle): void { + const internal = unwrapPulse(handle); + const pulse = this.state.pulses.get(internal.id); + if (pulse?.animFrameId) cancelAnimationFrame(pulse.animFrameId); + this.state.pulses.delete(internal.id); + this.state.markDirty(); + } + + // ==================== Layer visibility ==================== + + setLayerVisible(layer: RenderLayer, visible: boolean): void { + if (visible) { + this.state.enabledLayers.add(layer); + } else { + this.state.enabledLayers.delete(layer); + } + this.state.flushNow(); + } + + // ==================== Settings ==================== + + setSmoothingEnabled(enabled: boolean, speed?: number): void { + this.smoothingEnabled = enabled; + if (speed !== undefined) { + this.smoothingSpeed = speed; + } + this.state.markDirty(); + } + + setNameDisplayMode(mode: "players" | "all" | "none"): void { + this.nameDisplayMode = mode; + this.state.markDirty(); + } + + // ==================== Events ==================== + + on(event: RendererEvent, cb: (...args: any[]) => void): void { + let set = this.listeners.get(event); + if (!set) { + set = new Set(); + this.listeners.set(event, set); + } + set.add(cb); + } + + off(event: RendererEvent, cb: (...args: any[]) => void): void { + this.listeners.get(event)?.delete(cb); + } + + private fireEvent(event: RendererEvent, ...args: any[]): void { + const set = this.listeners.get(event); + if (set) { + for (const cb of set) { + cb(...args); + } + } + } + + // ==================== Controls ==================== + + getControls(): RendererControls { + return { + container: this.map?.getContainer(), + }; + } +} diff --git a/static-next/src/renderers/deckgl/deckgl-state.ts b/static-next/src/renderers/deckgl/deckgl-state.ts new file mode 100644 index 00000000..df4bb393 --- /dev/null +++ b/static-next/src/renderers/deckgl/deckgl-state.ts @@ -0,0 +1,159 @@ +import type { Layer } from "@deck.gl/core"; +import type { RenderLayer } from "../renderer.types"; + +// --------------- Internal data types --------------- + +export interface EntityData { + id: number; + position: [number, number]; // [lng, lat] + angle: number; + iconKey: string; // e.g. "man:blufor", "tank:dead" + iconType: string; + opacity: number; + sizeScale: number; + name: string; + isPlayer: boolean; + visible: boolean; // false if isInVehicle +} + +export interface LineData { + id: number; + from: [number, number]; // [lng, lat] + to: [number, number]; // [lng, lat] + color: [number, number, number, number]; // RGBA 0-255 + width: number; +} + +export interface BriefingPolygonData { + id: number; + polygon: [number, number][]; // ring of [lng, lat] + fillColor: [number, number, number, number]; + lineColor: [number, number, number, number]; + stroke: boolean; +} + +export interface BriefingPathData { + id: number; + path: [number, number][]; // [lng, lat][] + color: [number, number, number, number]; + width: number; +} + +export interface BriefingIconData { + id: number; + position: [number, number]; // [lng, lat] + iconUrl: string; + size: [number, number]; + angle: number; + opacity: number; +} + +export interface PulseData { + id: number; + position: [number, number]; // [lng, lat] + color: [number, number, number, number]; + fillColor: [number, number, number, number]; + radius: number; + maxRadius: number; + animFrameId?: number; +} + +// --------------- Color utility --------------- + +/** + * Parse a hex color string to RGBA array for deck.gl. + * Supports #RGB, #RRGGBB formats. + */ +export function hexToRGBA(hex: string, alpha = 1): [number, number, number, number] { + let r = 0, g = 0, b = 0; + const h = hex.replace("#", ""); + if (h.length === 3) { + r = parseInt(h[0] + h[0], 16); + g = parseInt(h[1] + h[1], 16); + b = parseInt(h[2] + h[2], 16); + } else if (h.length >= 6) { + r = parseInt(h.slice(0, 2), 16); + g = parseInt(h.slice(2, 4), 16); + b = parseInt(h.slice(4, 6), 16); + } + return [r, g, b, Math.round(alpha * 255)]; +} + +// --------------- State store --------------- + +export type FlushCallback = (layers: Layer[]) => void; + +export class DeckState { + entities = new Map(); + lines = new Map(); + briefingPolygons = new Map(); + briefingPaths = new Map(); + briefingIcons = new Map(); + pulses = new Map(); + + enabledLayers = new Set([ + "entities", + "briefingMarkers", + "systemMarkers", + "projectileMarkers", + ]); + + private dirty = false; + private scheduled = false; + private flushCallback: FlushCallback; + private buildLayersFn: () => Layer[]; + + constructor(buildLayersFn: () => Layer[], flushCallback: FlushCallback) { + this.buildLayersFn = buildLayersFn; + this.flushCallback = flushCallback; + } + + markDirty(): void { + this.dirty = true; + if (!this.scheduled) { + this.scheduled = true; + requestAnimationFrame(() => { + this.scheduled = false; + if (this.dirty) { + this.dirty = false; + this.flushCallback(this.buildLayersFn()); + } + }); + } + } + + /** Force an immediate flush (e.g. after layer visibility toggle). */ + flushNow(): void { + this.dirty = false; + this.scheduled = false; + this.flushCallback(this.buildLayersFn()); + } + + private nextLineId = 0; + allocLineId(): number { + return this.nextLineId++; + } + + private nextBriefingId = 0; + allocBriefingId(): number { + return this.nextBriefingId++; + } + + private nextPulseId = 0; + allocPulseId(): number { + return this.nextPulseId++; + } + + dispose(): void { + // Cancel any pending pulse animations + for (const pulse of this.pulses.values()) { + if (pulse.animFrameId) cancelAnimationFrame(pulse.animFrameId); + } + this.entities.clear(); + this.lines.clear(); + this.briefingPolygons.clear(); + this.briefingPaths.clear(); + this.briefingIcons.clear(); + this.pulses.clear(); + } +} diff --git a/static-next/src/renderers/leaflet/__tests__/grid-utils.test.ts b/static-next/src/renderers/leaflet/__tests__/grid-utils.test.ts index 5c5543a4..0978852e 100644 --- a/static-next/src/renderers/leaflet/__tests__/grid-utils.test.ts +++ b/static-next/src/renderers/leaflet/__tests__/grid-utils.test.ts @@ -4,7 +4,7 @@ import { formatGridLabel, formatCoordLabel, computeGridLines, -} from "../grid-utils"; +} from "../../shared/grid-utils"; // ------------------------------------------------------------------ // getGridInterval — Legacy mode (zoom levels ~0-8) diff --git a/static-next/src/renderers/leaflet/leaflet-grid.ts b/static-next/src/renderers/leaflet/leaflet-grid.ts index 52043f33..82d19fc3 100644 --- a/static-next/src/renderers/leaflet/leaflet-grid.ts +++ b/static-next/src/renderers/leaflet/leaflet-grid.ts @@ -7,7 +7,7 @@ */ import L from "leaflet"; import type { ArmaCoord } from "../../utils/coordinates"; -import { getGridInterval, formatCoordLabel, computeGridLines } from "./grid-utils"; +import { getGridInterval, formatCoordLabel, computeGridLines } from "../shared/grid-utils"; // --------------- Types --------------- diff --git a/static-next/src/renderers/leaflet/leaflet-icons.ts b/static-next/src/renderers/leaflet/leaflet-icons.ts index abd6c3ac..230086bd 100644 --- a/static-next/src/renderers/leaflet/leaflet-icons.ts +++ b/static-next/src/renderers/leaflet/leaflet-icons.ts @@ -1,6 +1,17 @@ import L from "leaflet"; import type { Side, AliveState } from "../../data/types"; import { SIDE_CLASS, SIDE_COLORS_DARK } from "../../config/side-colors"; +import { + ICON_SIZES, + ICON_PATHS, + ICON_STATES, + aliveVariant, +} from "../shared/icon-constants"; +import type { IconState } from "../shared/icon-constants"; + +// Re-export shared constants so existing consumers don't break +export { ICON_SIZES, ICON_PATHS, ICON_STATES, aliveVariant }; +export type { IconState }; // --------------- Side → CSS class / colour mapping --------------- @@ -16,54 +27,6 @@ export function sideStyle(side: Side): SideStyle { }; } -// --------------- Icon sizes per entity type --------------- - -const ICON_SIZES: Record = { - man: [16, 16], - ship: [28, 28], - parachute: [20, 20], - heli: [32, 32], - plane: [32, 32], - truck: [28, 28], - car: [24, 24], - apc: [28, 28], - tank: [28, 28], - staticMortar: [20, 20], - staticWeapon: [20, 20], - unknown: [28, 28], -}; - -/** Image path directory per entity type. */ -const ICON_PATHS: Record = { - man: "images/markers/man/", - ship: "images/markers/ship/", - parachute: "images/markers/parachute/", - heli: "images/markers/heli/", - plane: "images/markers/plane/", - truck: "images/markers/truck/", - car: "images/markers/car/", - apc: "images/markers/apc/", - tank: "images/markers/tank/", - staticMortar: "images/markers/static-mortar/", - staticWeapon: "images/markers/static-weapon/", - unknown: "images/markers/unknown/", -}; - -/** - * Map the alive-state variant name used in icon filenames. - * Alive uses the side-class name (e.g. "blufor"), dead/unconscious are fixed. - */ -function aliveVariant(alive: AliveState, sideClass: string): string { - switch (alive) { - case 0: - return "dead"; - case 2: - return "unconscious"; - default: - return sideClass; - } -} - // --------------- Public API --------------- export interface EntityIcon { @@ -120,25 +83,6 @@ export function iconSize(iconType: string): [number, number] { // --------------- Icon atlas: entityType × state string --------------- -/** - * All visual states an entity icon can be in. - * Side states use the side CSS class name; others are fixed filenames. - */ -export const ICON_STATES = [ - "blufor", - "opfor", - "ind", - "civ", - "logic", - "unknown", - "dead", - "hit", - "follow", - "unconscious", -] as const; - -export type IconState = (typeof ICON_STATES)[number]; - /** * Build a Leaflet L.Icon for a given entity type and string-based state. * diff --git a/static-next/src/renderers/leaflet/leaflet-smoothing.ts b/static-next/src/renderers/leaflet/leaflet-smoothing.ts index bd9191d1..05833227 100644 --- a/static-next/src/renderers/leaflet/leaflet-smoothing.ts +++ b/static-next/src/renderers/leaflet/leaflet-smoothing.ts @@ -16,19 +16,8 @@ * speed 10+ → 0.15 s (the default `.marker-transition` rule) */ -// --------------- Speed → duration mapping --------------- - -/** - * Return the CSS transition duration (in seconds) for a given playback speed. - * - * This is a pure function suitable for unit testing. - */ -export function getTransitionDuration(speed: number): number { - if (speed >= 10) return 0.15; - if (speed < 1) return 1; - // speed 1 → 1.0, speed 2 → 0.9, …, speed 9 → 0.2 - return Math.round((1.1 - speed * 0.1) * 100) / 100; -} +// Re-export from shared so existing consumers don't break +export { getTransitionDuration } from "../shared/transitions"; // --------------- Class manipulation --------------- diff --git a/static-next/src/renderers/mock-renderer.ts b/static-next/src/renderers/mock-renderer.ts index 7fd05274..c43833e4 100644 --- a/static-next/src/renderers/mock-renderer.ts +++ b/static-next/src/renderers/mock-renderer.ts @@ -113,6 +113,10 @@ export class MockRenderer implements MapRenderer { // no-op } + setNameDisplayMode(_mode: "players" | "all" | "none"): void { + // no-op + } + on(event: RendererEvent, cb: (...args: any[]) => void): void { let set = this.listeners.get(event); if (!set) { diff --git a/static-next/src/renderers/leaflet/grid-utils.ts b/static-next/src/renderers/shared/grid-utils.ts similarity index 100% rename from static-next/src/renderers/leaflet/grid-utils.ts rename to static-next/src/renderers/shared/grid-utils.ts diff --git a/static-next/src/renderers/shared/icon-constants.ts b/static-next/src/renderers/shared/icon-constants.ts new file mode 100644 index 00000000..695b4dd3 --- /dev/null +++ b/static-next/src/renderers/shared/icon-constants.ts @@ -0,0 +1,68 @@ +import type { AliveState } from "../../data/types"; + +// --------------- Icon sizes per entity type --------------- + +export const ICON_SIZES: Record = { + man: [16, 16], + ship: [28, 28], + parachute: [20, 20], + heli: [32, 32], + plane: [32, 32], + truck: [28, 28], + car: [24, 24], + apc: [28, 28], + tank: [28, 28], + staticMortar: [20, 20], + staticWeapon: [20, 20], + unknown: [28, 28], +}; + +/** Image path directory per entity type. */ +export const ICON_PATHS: Record = { + man: "images/markers/man/", + ship: "images/markers/ship/", + parachute: "images/markers/parachute/", + heli: "images/markers/heli/", + plane: "images/markers/plane/", + truck: "images/markers/truck/", + car: "images/markers/car/", + apc: "images/markers/apc/", + tank: "images/markers/tank/", + staticMortar: "images/markers/static-mortar/", + staticWeapon: "images/markers/static-weapon/", + unknown: "images/markers/unknown/", +}; + +/** + * All visual states an entity icon can be in. + * Side states use the side CSS class name; others are fixed filenames. + */ +export const ICON_STATES = [ + "blufor", + "opfor", + "ind", + "civ", + "logic", + "unknown", + "dead", + "hit", + "follow", + "unconscious", +] as const; + +export type IconState = (typeof ICON_STATES)[number]; + +/** + * Map the alive-state variant name used in icon filenames. + * Alive uses the side-class name (e.g. "blufor"), dead/unconscious are fixed. + */ +export function aliveVariant(alive: AliveState, sideClass: string): string { + switch (alive) { + case 0: + return "dead"; + case 2: + return "unconscious"; + default: + return sideClass; + } +} diff --git a/static-next/src/renderers/shared/transitions.ts b/static-next/src/renderers/shared/transitions.ts new file mode 100644 index 00000000..67197410 --- /dev/null +++ b/static-next/src/renderers/shared/transitions.ts @@ -0,0 +1,11 @@ +/** + * Return the transition duration (in seconds) for a given playback speed. + * + * This is a pure function with no DOM dependencies. + */ +export function getTransitionDuration(speed: number): number { + if (speed >= 10) return 0.15; + if (speed < 1) return 1; + // speed 1 → 1.0, speed 2 → 0.9, …, speed 9 → 0.2 + return Math.round((1.1 - speed * 0.1) * 100) / 100; +} diff --git a/static-next/src/ui/__tests__/App.test.tsx b/static-next/src/ui/__tests__/App.test.tsx index 1933034b..ec98d80c 100644 --- a/static-next/src/ui/__tests__/App.test.tsx +++ b/static-next/src/ui/__tests__/App.test.tsx @@ -1,10 +1,15 @@ import { describe, it, expect, vi, afterEach } from "vitest"; import { render, cleanup } from "@solidjs/testing-library"; import { App } from "../../App"; +import type { MapRenderer } from "../../renderers/renderer.interface"; -// Mock LeafletRenderer to avoid Leaflet in jsdom -vi.mock("../../renderers/leaflet/leaflet-renderer", () => ({ - LeafletRenderer: vi.fn().mockImplementation(() => ({ +// Mock storage factory to avoid actual OPFS/IndexedDB access in tests +vi.mock("../../data/storage/storage-factory", () => ({ + createStorage: vi.fn().mockRejectedValue(new Error("not available in test")), +})); + +function createMockRenderer(): MapRenderer { + return { init: vi.fn(), dispose: vi.fn(), getZoom: vi.fn().mockReturnValue(1), @@ -23,16 +28,12 @@ vi.mock("../../renderers/leaflet/leaflet-renderer", () => ({ removePulse: vi.fn(), setLayerVisible: vi.fn(), setSmoothingEnabled: vi.fn(), + setNameDisplayMode: vi.fn(), on: vi.fn(), off: vi.fn(), getControls: vi.fn().mockReturnValue({}), - })), -})); - -// Mock storage factory to avoid actual OPFS/IndexedDB access in tests -vi.mock("../../data/storage/storage-factory", () => ({ - createStorage: vi.fn().mockRejectedValue(new Error("not available in test")), -})); + }; +} describe("App", () => { afterEach(() => { @@ -40,17 +41,17 @@ describe("App", () => { }); it("renders without crashing", () => { - const { container } = render(() => ); + const { container } = render(() => ); expect(container).toBeDefined(); }); it("renders the map container", () => { - const { getByTestId } = render(() => ); + const { getByTestId } = render(() => ); expect(getByTestId("map-container")).toBeDefined(); }); it("renders panel components", () => { - const { container } = render(() => ); + const { container } = render(() => ); // Panels are rendered (may be hidden depending on signal state) expect(container.innerHTML).toBeDefined(); expect(container.innerHTML.length).toBeGreaterThan(0); From d4f5ec3dd86800f5fb0517ba8c88bd0616af24cf Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Tue, 10 Feb 2026 22:45:17 +0100 Subject: [PATCH 2/3] perf: optimize deck.gl renderer with granular dirty tracking and MapLibre tuning - Replace markDirty() with per-collection dirty methods (dirtyEntities, dirtyLines, dirtyBriefing, dirtyPulses) and revision counters - Cache data arrays in DeckState to avoid Array.from() every frame - Use revision counters as updateTriggers instead of N-element arrays - Add MapLibre ScaleControl, maxBounds, renderWorldCopies: false, fadeDuration: 0, maxTileCacheSize: 256, collectResourceTiming: false - Enable interleaved: true for camera-synced rendering - Add CSS offsets for MapLibre control containers matching panel layout --- .../src/renderers/deckgl/deckgl-layers.ts | 79 ++++++++--- .../src/renderers/deckgl/deckgl-renderer.ts | 78 +++++++---- .../src/renderers/deckgl/deckgl-state.ts | 130 +++++++++++++++++- static-next/src/ui/styles/global.css | 30 ++++ 4 files changed, 268 insertions(+), 49 deletions(-) diff --git a/static-next/src/renderers/deckgl/deckgl-layers.ts b/static-next/src/renderers/deckgl/deckgl-layers.ts index 566a076a..dd95c006 100644 --- a/static-next/src/renderers/deckgl/deckgl-layers.ts +++ b/static-next/src/renderers/deckgl/deckgl-layers.ts @@ -13,15 +13,19 @@ import { ICON_SIZES } from "../shared/icon-constants"; // --------------- Entity layers --------------- +/** + * @param visibleEntities Pre-filtered array of visible entities (cached by DeckState). + * @param revision Monotonic counter — deck.gl only re-diffs accessors when this changes. + */ export function buildEntityIconLayer( - entities: EntityData[], + visibleEntities: EntityData[], atlas: IconAtlas, + revision: number, transitions?: { getPosition?: { duration: number } }, ): Layer { - const visible = entities.filter((e) => e.visible); return new IconLayer({ id: "entity-icons", - data: visible, + data: visibleEntities, iconAtlas: atlas.atlasUrl, iconMapping: atlas.mapping, getIcon: (d) => d.iconKey, @@ -37,32 +41,40 @@ export function buildEntityIconLayer( billboard: true, alphaCutoff: 0.05, pickable: true, - transitions: transitions, + transitions, + // Single revision counter — deck.gl only re-evaluates accessors when + // this value changes, instead of diffing N-element arrays every frame. updateTriggers: { - getIcon: visible.map((e) => e.iconKey), - getAngle: visible.map((e) => e.angle), - getSize: visible.map((e) => e.iconType), - getColor: visible.map((e) => e.opacity), + getIcon: revision, + getAngle: revision, + getSize: revision, + getColor: revision, + getPosition: revision, }, }); } +/** + * @param visibleEntities Pre-filtered array of visible entities. + * @param revision Monotonic counter for updateTriggers. + */ export function buildEntityLabelLayer( - entities: EntityData[], + visibleEntities: EntityData[], nameMode: "players" | "all" | "none", + revision: number, ): Layer { - let visible: EntityData[]; + let data: EntityData[]; if (nameMode === "none") { - visible = []; + data = []; } else if (nameMode === "players") { - visible = entities.filter((e) => e.visible && e.isPlayer); + data = visibleEntities.filter((e) => e.isPlayer); } else { - visible = entities.filter((e) => e.visible); + data = visibleEntities; } return new TextLayer({ id: "entity-labels", - data: visible, + data, getPosition: (d) => d.position, getText: (d) => d.name, getColor: [255, 255, 255, 220], @@ -78,12 +90,16 @@ export function buildEntityLabelLayer( getTextAnchor: "middle", getAlignmentBaseline: "center", pickable: false, + updateTriggers: { + getPosition: revision, + getText: revision, + }, }); } // --------------- Fire lines --------------- -export function buildFireLineLayer(lines: LineData[]): Layer { +export function buildFireLineLayer(lines: LineData[], revision: number): Layer { return new LineLayer({ id: "fire-lines", data: lines, @@ -93,12 +109,17 @@ export function buildFireLineLayer(lines: LineData[]): Layer { getWidth: (d) => d.width, widthUnits: "pixels", pickable: false, + updateTriggers: { + getSourcePosition: revision, + getTargetPosition: revision, + getColor: revision, + }, }); } // --------------- Briefing layers --------------- -export function buildBriefingPolygonLayer(polygons: BriefingPolygonData[]): Layer { +export function buildBriefingPolygonLayer(polygons: BriefingPolygonData[], revision: number): Layer { return new PolygonLayer({ id: "briefing-polygons", data: polygons, @@ -110,10 +131,15 @@ export function buildBriefingPolygonLayer(polygons: BriefingPolygonData[]): Laye stroked: true, filled: true, pickable: false, + updateTriggers: { + getPolygon: revision, + getFillColor: revision, + getLineColor: revision, + }, }); } -export function buildBriefingPathLayer(paths: BriefingPathData[]): Layer { +export function buildBriefingPathLayer(paths: BriefingPathData[], revision: number): Layer { return new PathLayer({ id: "briefing-paths", data: paths, @@ -122,11 +148,14 @@ export function buildBriefingPathLayer(paths: BriefingPathData[]): Layer { getWidth: (d) => d.width, widthUnits: "pixels", pickable: false, + updateTriggers: { + getPath: revision, + getColor: revision, + }, }); } -export function buildBriefingIconLayer(icons: BriefingIconData[]): Layer { - // Each briefing icon has a unique URL, so use individual icon atlases +export function buildBriefingIconLayer(icons: BriefingIconData[], revision: number): Layer { return new IconLayer({ id: "briefing-icons", data: icons, @@ -143,12 +172,18 @@ export function buildBriefingIconLayer(icons: BriefingIconData[]): Layer { sizeUnits: "pixels", billboard: true, pickable: false, + updateTriggers: { + getPosition: revision, + getIcon: revision, + getAngle: revision, + getColor: revision, + }, }); } // --------------- Pulse effects --------------- -export function buildPulseLayer(pulses: PulseData[]): Layer { +export function buildPulseLayer(pulses: PulseData[], revision: number): Layer { return new ScatterplotLayer({ id: "pulse-effects", data: pulses, @@ -162,5 +197,9 @@ export function buildPulseLayer(pulses: PulseData[]): Layer { stroked: true, filled: true, pickable: false, + updateTriggers: { + getRadius: revision, + getFillColor: revision, + }, }); } diff --git a/static-next/src/renderers/deckgl/deckgl-renderer.ts b/static-next/src/renderers/deckgl/deckgl-renderer.ts index e5c27f7f..abeb796e 100644 --- a/static-next/src/renderers/deckgl/deckgl-renderer.ts +++ b/static-next/src/renderers/deckgl/deckgl-renderer.ts @@ -142,6 +142,14 @@ export class DeckGLRenderer implements MapRenderer { } }; + // Pad the world bounds so the user can't pan into empty space, + // and MapLibre doesn't load/render tiles outside the map area. + const pad = worldSizeDeg * 0.1; + const maxBounds: maplibregl.LngLatBoundsLike = [ + [-pad, -pad], + [worldSizeDeg + pad, worldSizeDeg + pad], + ]; + // Create MapLibre map (standalone, no Leaflet) this.map = new maplibregl.Map({ container, @@ -154,6 +162,7 @@ export class DeckGLRenderer implements MapRenderer { zoom: 12, minZoom: 10, maxZoom: 20, + maxBounds, transformRequest, attributionControl: {}, // Disable rotation/pitch: 2D icons have no height axis, so tilting @@ -161,6 +170,14 @@ export class DeckGLRenderer implements MapRenderer { dragRotate: false, pitchWithRotate: false, touchPitch: false, + // Don't render repeated world copies — our map is a small area at the equator + renderWorldCopies: false, + // Performance: skip tile cross-fade to reduce GPU draw calls per frame + fadeDuration: 0, + // Performance: cache more parsed tiles to avoid re-parsing on pan/zoom + maxTileCacheSize: 256, + // Performance: skip Resource Timing API entries for tile requests + collectResourceTiming: false, }); // Initialize state and overlay @@ -171,10 +188,11 @@ export class DeckGLRenderer implements MapRenderer { this.overlay = new MapboxOverlay({ layers: [], - interleaved: false, + interleaved: true, }); this.map.addControl(this.overlay as any); + this.map.addControl(new maplibregl.ScaleControl({ unit: "metric" }), "bottom-left"); // Build icon atlas async, then trigger first render void buildIconAtlas().then((atlas) => { @@ -231,33 +249,35 @@ export class DeckGLRenderer implements MapRenderer { const s = this.state; if (s.enabledLayers.has("entities") && this.iconAtlas) { - const entities = Array.from(s.entities.values()); + const visible = s.getVisibleEntityArray(); + const rev = s.entityRevision; const transitions = this.smoothingEnabled ? { getPosition: { duration: getTransitionDuration(this.smoothingSpeed) * 1000 } } : undefined; - layers.push(buildEntityIconLayer(entities, this.iconAtlas, transitions)); - layers.push(buildEntityLabelLayer(entities, this.nameDisplayMode)); + layers.push(buildEntityIconLayer(visible, this.iconAtlas, rev, transitions)); + layers.push(buildEntityLabelLayer(visible, this.nameDisplayMode, rev)); } if (s.enabledLayers.has("projectileMarkers")) { - const lines = Array.from(s.lines.values()); + const lines = s.getLineArray(); if (lines.length > 0) { - layers.push(buildFireLineLayer(lines)); + layers.push(buildFireLineLayer(lines, s.lineRevision)); } } if (s.enabledLayers.has("briefingMarkers")) { - const polygons = Array.from(s.briefingPolygons.values()); - const paths = Array.from(s.briefingPaths.values()); - const icons = Array.from(s.briefingIcons.values()); - if (polygons.length > 0) layers.push(buildBriefingPolygonLayer(polygons)); - if (paths.length > 0) layers.push(buildBriefingPathLayer(paths)); - if (icons.length > 0) layers.push(buildBriefingIconLayer(icons)); + const polygons = s.getBriefingPolygonArray(); + const paths = s.getBriefingPathArray(); + const icons = s.getBriefingIconArray(); + const rev = s.briefingRevision; + if (polygons.length > 0) layers.push(buildBriefingPolygonLayer(polygons, rev)); + if (paths.length > 0) layers.push(buildBriefingPathLayer(paths, rev)); + if (icons.length > 0) layers.push(buildBriefingIconLayer(icons, rev)); } - const pulses = Array.from(s.pulses.values()); + const pulses = s.getPulseArray(); if (pulses.length > 0) { - layers.push(buildPulseLayer(pulses)); + layers.push(buildPulseLayer(pulses, s.pulseRevision)); } return layers; @@ -318,7 +338,7 @@ export class DeckGLRenderer implements MapRenderer { }; this.state.entities.set(id, entity); - this.state.markDirty(); + this.state.dirtyEntities(); return wrapMarker({ id, lastDirection: 0 }); } @@ -342,13 +362,13 @@ export class DeckGLRenderer implements MapRenderer { entity.isPlayer = state.isPlayer; entity.visible = !state.isInVehicle; - this.state.markDirty(); + this.state.dirtyEntities(); } removeEntityMarker(handle: MarkerHandle): void { const internal = unwrapMarker(handle); this.state.entities.delete(internal.id); - this.state.markDirty(); + this.state.dirtyEntities(); } // ==================== Briefing markers ==================== @@ -365,6 +385,7 @@ export class DeckGLRenderer implements MapRenderer { width: 2, }; this.state.briefingPaths.set(id, pathData); + this.state.dirtyBriefing(); } else if (def.shape === "ELLIPSE" || def.shape === "RECTANGLE") { const fillAlpha = this.getBrushFillAlpha(def.brush); const polyData: BriefingPolygonData = { @@ -375,6 +396,7 @@ export class DeckGLRenderer implements MapRenderer { stroke: this.getBrushStroke(def.brush), }; this.state.briefingPolygons.set(id, polyData); + this.state.dirtyBriefing(); } else { // ICON const isMagIcon = def.type.indexOf("magIcons") > -1; @@ -397,9 +419,9 @@ export class DeckGLRenderer implements MapRenderer { opacity: 1, }; this.state.briefingIcons.set(id, iconData); + this.state.dirtyBriefing(); } - this.state.markDirty(); return wrapBriefing({ id, shape: def.shape, size: def.size }); } @@ -464,7 +486,7 @@ export class DeckGLRenderer implements MapRenderer { path.color[3] = Math.round(state.alpha * 255); } - this.state.markDirty(); + this.state.dirtyBriefing(); } removeBriefingMarker(handle: BriefingMarkerHandle): void { @@ -472,7 +494,7 @@ export class DeckGLRenderer implements MapRenderer { this.state.briefingPolygons.delete(internal.id); this.state.briefingPaths.delete(internal.id); this.state.briefingIcons.delete(internal.id); - this.state.markDirty(); + this.state.dirtyBriefing(); } // ==================== Briefing helpers ==================== @@ -507,14 +529,14 @@ export class DeckGLRenderer implements MapRenderer { color: hexToRGBA(opts.color, opts.opacity), width: opts.weight, }); - this.state.markDirty(); + this.state.dirtyLines(); return wrapLine({ id }); } removeLine(handle: LineHandle): void { const internal = unwrapLine(handle); this.state.lines.delete(internal.id); - this.state.markDirty(); + this.state.dirtyLines(); } // ==================== Pulses ==================== @@ -545,18 +567,18 @@ export class DeckGLRenderer implements MapRenderer { if (iteration >= iterations) { this.state.pulses.delete(id); - this.state.markDirty(); + this.state.dirtyPulses(); return; } pulse.radius = cycleProgress * maxRadius; pulse.fillColor[3] = Math.round((1 - cycleProgress) * 128); - this.state.markDirty(); + this.state.dirtyPulses(); pulse.animFrameId = requestAnimationFrame(animate); }; pulse.animFrameId = requestAnimationFrame(animate); - this.state.markDirty(); + this.state.dirtyPulses(); return wrapPulse({ id }); } @@ -565,7 +587,7 @@ export class DeckGLRenderer implements MapRenderer { const pulse = this.state.pulses.get(internal.id); if (pulse?.animFrameId) cancelAnimationFrame(pulse.animFrameId); this.state.pulses.delete(internal.id); - this.state.markDirty(); + this.state.dirtyPulses(); } // ==================== Layer visibility ==================== @@ -586,12 +608,12 @@ export class DeckGLRenderer implements MapRenderer { if (speed !== undefined) { this.smoothingSpeed = speed; } - this.state.markDirty(); + this.state.dirtyEntities(); } setNameDisplayMode(mode: "players" | "all" | "none"): void { this.nameDisplayMode = mode; - this.state.markDirty(); + this.state.dirtyEntities(); } // ==================== Events ==================== diff --git a/static-next/src/renderers/deckgl/deckgl-state.ts b/static-next/src/renderers/deckgl/deckgl-state.ts index df4bb393..723cf1e5 100644 --- a/static-next/src/renderers/deckgl/deckgl-state.ts +++ b/static-next/src/renderers/deckgl/deckgl-state.ts @@ -98,6 +98,31 @@ export class DeckState { "projectileMarkers", ]); + // Per-collection revision counters. Bumped when data changes; + // used as deck.gl updateTriggers so it only diffs when needed. + entityRevision = 0; + lineRevision = 0; + briefingRevision = 0; + pulseRevision = 0; + + // Cached data arrays — only rebuilt when the collection is dirty. + // deck.gl compares data by reference; reusing the same array avoids + // a full re-upload to the GPU. + private _entityArray: EntityData[] = []; + private _entityArrayDirty = true; + private _visibleEntityArray: EntityData[] = []; + + private _lineArray: LineData[] = []; + private _lineArrayDirty = true; + + private _briefingPolygonArray: BriefingPolygonData[] = []; + private _briefingPathArray: BriefingPathData[] = []; + private _briefingIconArray: BriefingIconData[] = []; + private _briefingArrayDirty = true; + + private _pulseArray: PulseData[] = []; + private _pulseArrayDirty = true; + private dirty = false; private scheduled = false; private flushCallback: FlushCallback; @@ -108,7 +133,36 @@ export class DeckState { this.flushCallback = flushCallback; } - markDirty(): void { + /** Mark entity data as changed. */ + dirtyEntities(): void { + this._entityArrayDirty = true; + this.entityRevision++; + this.scheduleDirty(); + } + + /** Mark line data as changed. */ + dirtyLines(): void { + this._lineArrayDirty = true; + this.lineRevision++; + this.scheduleDirty(); + } + + /** Mark briefing data as changed. */ + dirtyBriefing(): void { + this._briefingArrayDirty = true; + this.briefingRevision++; + this.scheduleDirty(); + } + + /** Mark pulse data as changed. */ + dirtyPulses(): void { + this._pulseArrayDirty = true; + this.pulseRevision++; + this.scheduleDirty(); + } + + /** Schedule a flush on the next animation frame. */ + private scheduleDirty(): void { this.dirty = true; if (!this.scheduled) { this.scheduled = true; @@ -122,13 +176,87 @@ export class DeckState { } } + /** @deprecated Use dirtyEntities/dirtyLines/etc. for granular invalidation. */ + markDirty(): void { + this.scheduleDirty(); + } + /** Force an immediate flush (e.g. after layer visibility toggle). */ flushNow(): void { this.dirty = false; this.scheduled = false; + // Invalidate all caches so buildLayers picks up current state + this._entityArrayDirty = true; + this._lineArrayDirty = true; + this._briefingArrayDirty = true; + this._pulseArrayDirty = true; this.flushCallback(this.buildLayersFn()); } + // --------------- Cached array accessors --------------- + + getEntityArray(): EntityData[] { + if (this._entityArrayDirty) { + this._entityArray = Array.from(this.entities.values()); + this._visibleEntityArray = this._entityArray.filter((e) => e.visible); + this._entityArrayDirty = false; + } + return this._entityArray; + } + + getVisibleEntityArray(): EntityData[] { + if (this._entityArrayDirty) { + this.getEntityArray(); // rebuilds both + } + return this._visibleEntityArray; + } + + getLineArray(): LineData[] { + if (this._lineArrayDirty) { + this._lineArray = Array.from(this.lines.values()); + this._lineArrayDirty = false; + } + return this._lineArray; + } + + getBriefingPolygonArray(): BriefingPolygonData[] { + if (this._briefingArrayDirty) { + this._rebuildBriefingArrays(); + } + return this._briefingPolygonArray; + } + + getBriefingPathArray(): BriefingPathData[] { + if (this._briefingArrayDirty) { + this._rebuildBriefingArrays(); + } + return this._briefingPathArray; + } + + getBriefingIconArray(): BriefingIconData[] { + if (this._briefingArrayDirty) { + this._rebuildBriefingArrays(); + } + return this._briefingIconArray; + } + + private _rebuildBriefingArrays(): void { + this._briefingPolygonArray = Array.from(this.briefingPolygons.values()); + this._briefingPathArray = Array.from(this.briefingPaths.values()); + this._briefingIconArray = Array.from(this.briefingIcons.values()); + this._briefingArrayDirty = false; + } + + getPulseArray(): PulseData[] { + if (this._pulseArrayDirty) { + this._pulseArray = Array.from(this.pulses.values()); + this._pulseArrayDirty = false; + } + return this._pulseArray; + } + + // --------------- ID allocators --------------- + private nextLineId = 0; allocLineId(): number { return this.nextLineId++; diff --git a/static-next/src/ui/styles/global.css b/static-next/src/ui/styles/global.css index 9e653a2e..cc128100 100644 --- a/static-next/src/ui/styles/global.css +++ b/static-next/src/ui/styles/global.css @@ -42,3 +42,33 @@ cursor: pointer; } +/* ---------- MapLibre GL control positioning (match Leaflet offsets) ---------- */ + +.maplibregl-ctrl-bottom-left { + left: 360px; + bottom: 65px; +} + +.maplibregl-ctrl-bottom-right { + bottom: 65px; +} + +.maplibregl-ctrl-top-left { + left: 360px; + top: 50px; +} + +.maplibregl-ctrl-top-right { + top: 50px; +} + +/* Scale ruler dark theme (MapLibre) */ +.maplibregl-ctrl-scale { + background: var(--bg-modal); + border-color: rgba(255, 255, 255, 0.5); + color: #fff; + font-size: 11px; + line-height: 1.2; + padding: 2px 5px; +} + From 9374021e22ec41a389a73695585022a32d966cfc Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Wed, 11 Feb 2026 01:18:40 +0100 Subject: [PATCH 3/3] perf: replace MapLibre with pure deck.gl MVTLayer basemap renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminate MapLibre GL entirely from the deck.gl renderer path — vector tiles are now rendered directly via deck.gl TileLayer + MVTLoader, and raster tiles via TileLayer + BitmapLayer. New files: - deckgl-expressions.ts: compile MapLibre GL style expressions to JS fns - deckgl-style-parser.ts: parse topo.json into compiled layer definitions - deckgl-basemap.ts: MVTLayer, raster, background, sprite atlas builders - deckgl-scale-control.ts: HTML scale bar (deck.gl has no built-in one) Performance optimizations: - Batch vector sub-layers by geometry type (~6 per tile instead of ~30) - Stable getTileData reference preserves tile cache across zoom changes - RAF-batched flush on zoom change instead of synchronous flushNow() Deps: +@deck.gl/geo-layers, -@deck.gl/mapbox --- static-next/package-lock.json | 789 +++++++++++++++++- static-next/package.json | 2 +- .../src/renderers/deckgl/deckgl-basemap.ts | 464 ++++++++++ .../renderers/deckgl/deckgl-expressions.ts | 292 +++++++ .../src/renderers/deckgl/deckgl-renderer.ts | 313 ++++--- .../renderers/deckgl/deckgl-scale-control.ts | 59 ++ .../renderers/deckgl/deckgl-style-parser.ts | 114 +++ 7 files changed, 1909 insertions(+), 124 deletions(-) create mode 100644 static-next/src/renderers/deckgl/deckgl-basemap.ts create mode 100644 static-next/src/renderers/deckgl/deckgl-expressions.ts create mode 100644 static-next/src/renderers/deckgl/deckgl-scale-control.ts create mode 100644 static-next/src/renderers/deckgl/deckgl-style-parser.ts diff --git a/static-next/package-lock.json b/static-next/package-lock.json index d03d2be2..7bfdc774 100644 --- a/static-next/package-lock.json +++ b/static-next/package-lock.json @@ -10,8 +10,8 @@ "dependencies": { "@bufbuild/protobuf": "^2.11.0", "@deck.gl/core": "^9.2.6", + "@deck.gl/geo-layers": "^9.2.6", "@deck.gl/layers": "^9.2.6", - "@deck.gl/mapbox": "^9.2.6", "@maplibre/maplibre-gl-leaflet": "^0.0.22", "leaflet": "^1.9.0", "leaflet-rotatedmarker": "^0.2.0", @@ -540,6 +540,57 @@ "mjolnir.js": "^3.0.0" } }, + "node_modules/@deck.gl/extensions": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@deck.gl/extensions/-/extensions-9.2.6.tgz", + "integrity": "sha512-HNuzo76mD6Ykc/xMEyCMH+to6/Xi+7ehG3VYToSm+R3196Ki5p58pyRHzvq9CrBDvFd3SLMe9QqRm2GTg3wn/w==", + "license": "MIT", + "peer": true, + "dependencies": { + "@luma.gl/constants": "^9.2.6", + "@luma.gl/shadertools": "^9.2.6", + "@math.gl/core": "^4.1.0" + }, + "peerDependencies": { + "@deck.gl/core": "~9.2.0", + "@luma.gl/core": "~9.2.6", + "@luma.gl/engine": "~9.2.6" + } + }, + "node_modules/@deck.gl/geo-layers": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@deck.gl/geo-layers/-/geo-layers-9.2.6.tgz", + "integrity": "sha512-Js42GcAlzH5vHWHdg/eKSmFvx1TWlhW+d6p8Y+67/iHpcCXmx/CBmpsr1ZsQ8XYc+GY8NDAmkHe5KECDJsJiDg==", + "license": "MIT", + "dependencies": { + "@loaders.gl/3d-tiles": "^4.2.0", + "@loaders.gl/gis": "^4.2.0", + "@loaders.gl/loader-utils": "^4.2.0", + "@loaders.gl/mvt": "^4.2.0", + "@loaders.gl/schema": "^4.2.0", + "@loaders.gl/terrain": "^4.2.0", + "@loaders.gl/tiles": "^4.2.0", + "@loaders.gl/wms": "^4.2.0", + "@luma.gl/gltf": "^9.2.6", + "@luma.gl/shadertools": "^9.2.6", + "@math.gl/core": "^4.1.0", + "@math.gl/culling": "^4.1.0", + "@math.gl/web-mercator": "^4.1.0", + "@types/geojson": "^7946.0.8", + "a5-js": "^0.5.0", + "h3-js": "^4.1.0", + "long": "^3.2.0" + }, + "peerDependencies": { + "@deck.gl/core": "~9.2.0", + "@deck.gl/extensions": "~9.2.0", + "@deck.gl/layers": "~9.2.0", + "@deck.gl/mesh-layers": "~9.2.0", + "@loaders.gl/core": "^4.2.0", + "@luma.gl/core": "~9.2.6", + "@luma.gl/engine": "~9.2.6" + } + }, "node_modules/@deck.gl/layers": { "version": "9.2.6", "resolved": "https://registry.npmjs.org/@deck.gl/layers/-/layers-9.2.6.tgz", @@ -568,20 +619,24 @@ "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==", "license": "ISC" }, - "node_modules/@deck.gl/mapbox": { + "node_modules/@deck.gl/mesh-layers": { "version": "9.2.6", - "resolved": "https://registry.npmjs.org/@deck.gl/mapbox/-/mapbox-9.2.6.tgz", - "integrity": "sha512-gyqCHZwiZS8LOYY6LILQQp5YCCf++VFk/wRoGskZvhb/kdEPX2Onv8iV8pXe0h9UyMLO6Mj0wl3HlJWg2ILkrg==", + "resolved": "https://registry.npmjs.org/@deck.gl/mesh-layers/-/mesh-layers-9.2.6.tgz", + "integrity": "sha512-/KjhjoQJRb9lUcDE6pZlHvcto9H5iBCJtUb1/uCb8fahzEAcZBDubAn4RUWjfRyOSmzJfQHrWdNAjflNkL87Yg==", "license": "MIT", + "peer": true, "dependencies": { - "@luma.gl/constants": "^9.2.6", - "@math.gl/web-mercator": "^4.1.0" + "@loaders.gl/gltf": "^4.2.0", + "@loaders.gl/schema": "^4.2.0", + "@luma.gl/gltf": "^9.2.6", + "@luma.gl/shadertools": "^9.2.6" }, "peerDependencies": { "@deck.gl/core": "~9.2.0", - "@luma.gl/constants": "~9.2.6", "@luma.gl/core": "~9.2.6", - "@math.gl/web-mercator": "^4.1.0" + "@luma.gl/engine": "~9.2.6", + "@luma.gl/gltf": "~9.2.6", + "@luma.gl/shadertools": "~9.2.6" } }, "node_modules/@esbuild/aix-ppc64": { @@ -1094,6 +1149,67 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@loaders.gl/3d-tiles": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/3d-tiles/-/3d-tiles-4.3.4.tgz", + "integrity": "sha512-JQ3y3p/KlZP7lfobwON5t7H9WinXEYTvuo3SRQM8TBKhM+koEYZhvI2GwzoXx54MbBbY+s3fm1dq5UAAmaTsZw==", + "license": "MIT", + "dependencies": { + "@loaders.gl/compression": "4.3.4", + "@loaders.gl/crypto": "4.3.4", + "@loaders.gl/draco": "4.3.4", + "@loaders.gl/gltf": "4.3.4", + "@loaders.gl/images": "4.3.4", + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/math": "4.3.4", + "@loaders.gl/tiles": "4.3.4", + "@loaders.gl/zip": "4.3.4", + "@math.gl/core": "^4.1.0", + "@math.gl/culling": "^4.1.0", + "@math.gl/geospatial": "^4.1.0", + "@probe.gl/log": "^4.0.4", + "long": "^5.2.1" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/3d-tiles/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/@loaders.gl/compression": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/compression/-/compression-4.3.4.tgz", + "integrity": "sha512-+o+5JqL9Sx8UCwdc2MTtjQiUHYQGJALHbYY/3CT+b9g/Emzwzez2Ggk9U9waRfdHiBCzEgRBivpWZEOAtkimXQ==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/worker-utils": "4.3.4", + "@types/brotli": "^1.3.0", + "@types/pako": "^1.0.1", + "fflate": "0.7.4", + "lzo-wasm": "^0.0.4", + "pako": "1.0.11", + "snappyjs": "^0.6.1" + }, + "optionalDependencies": { + "brotli": "^1.3.2", + "lz4js": "^0.2.0", + "zstd-codec": "^0.1" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/compression/node_modules/fflate": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.4.tgz", + "integrity": "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==", + "license": "MIT" + }, "node_modules/@loaders.gl/core": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/@loaders.gl/core/-/core-4.3.4.tgz", @@ -1106,6 +1222,68 @@ "@probe.gl/log": "^4.0.2" } }, + "node_modules/@loaders.gl/crypto": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/crypto/-/crypto-4.3.4.tgz", + "integrity": "sha512-3VS5FgB44nLOlAB9Q82VOQnT1IltwfRa1miE0mpHCe1prYu1M/dMnEyynusbrsp+eDs3EKbxpguIS9HUsFu5dQ==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/worker-utils": "4.3.4", + "@types/crypto-js": "^4.0.2" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/draco": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/draco/-/draco-4.3.4.tgz", + "integrity": "sha512-4Lx0rKmYENGspvcgV5XDpFD9o+NamXoazSSl9Oa3pjVVjo+HJuzCgrxTQYD/3JvRrolW/QRehZeWD/L/cEC6mw==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "@loaders.gl/worker-utils": "4.3.4", + "draco3d": "1.5.7" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/gis": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/gis/-/gis-4.3.4.tgz", + "integrity": "sha512-8xub38lSWW7+ZXWuUcggk7agRHJUy6RdipLNKZ90eE0ZzLNGDstGD1qiBwkvqH0AkG+uz4B7Kkiptyl7w2Oa6g==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "@mapbox/vector-tile": "^1.3.1", + "@math.gl/polygon": "^4.1.0", + "pbf": "^3.2.1" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/gltf": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/gltf/-/gltf-4.3.4.tgz", + "integrity": "sha512-EiUTiLGMfukLd9W98wMpKmw+hVRhQ0dJ37wdlXK98XPeGGB+zTQxCcQY+/BaMhsSpYt/OOJleHhTfwNr8RgzRg==", + "license": "MIT", + "dependencies": { + "@loaders.gl/draco": "4.3.4", + "@loaders.gl/images": "4.3.4", + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "@loaders.gl/textures": "4.3.4", + "@math.gl/core": "^4.1.0" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, "node_modules/@loaders.gl/images": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/@loaders.gl/images/-/images-4.3.4.tgz", @@ -1133,6 +1311,38 @@ "@loaders.gl/core": "^4.3.0" } }, + "node_modules/@loaders.gl/math": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/math/-/math-4.3.4.tgz", + "integrity": "sha512-UJrlHys1fp9EUO4UMnqTCqvKvUjJVCbYZ2qAKD7tdGzHJYT8w/nsP7f/ZOYFc//JlfC3nq+5ogvmdpq2pyu3TA==", + "license": "MIT", + "dependencies": { + "@loaders.gl/images": "4.3.4", + "@loaders.gl/loader-utils": "4.3.4", + "@math.gl/core": "^4.1.0" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/mvt": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/mvt/-/mvt-4.3.4.tgz", + "integrity": "sha512-9DrJX8RQf14htNtxsPIYvTso5dUce9WaJCWCIY/79KYE80Be6dhcEYMknxBS4w3+PAuImaAe66S5xo9B7Erm5A==", + "license": "MIT", + "dependencies": { + "@loaders.gl/gis": "4.3.4", + "@loaders.gl/images": "4.3.4", + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "@math.gl/polygon": "^4.1.0", + "@probe.gl/stats": "^4.0.0", + "pbf": "^3.2.1" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, "node_modules/@loaders.gl/schema": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/@loaders.gl/schema/-/schema-4.3.4.tgz", @@ -1145,6 +1355,74 @@ "@loaders.gl/core": "^4.3.0" } }, + "node_modules/@loaders.gl/terrain": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/terrain/-/terrain-4.3.4.tgz", + "integrity": "sha512-JszbRJGnxL5Fh82uA2U8HgjlsIpzYoCNNjy3cFsgCaxi4/dvjz3BkLlBilR7JlbX8Ka+zlb4GAbDDChiXLMJ/g==", + "license": "MIT", + "dependencies": { + "@loaders.gl/images": "4.3.4", + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "@mapbox/martini": "^0.2.0" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/textures": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/textures/-/textures-4.3.4.tgz", + "integrity": "sha512-arWIDjlE7JaDS6v9by7juLfxPGGnjT9JjleaXx3wq/PTp+psLOpGUywHXm38BNECos3MFEQK3/GFShWI+/dWPw==", + "license": "MIT", + "dependencies": { + "@loaders.gl/images": "4.3.4", + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "@loaders.gl/worker-utils": "4.3.4", + "@math.gl/types": "^4.1.0", + "ktx-parse": "^0.7.0", + "texture-compressor": "^1.0.2" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/tiles": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/tiles/-/tiles-4.3.4.tgz", + "integrity": "sha512-oC0zJfyvGox6Ag9ABF8fxOkx9yEFVyzTa9ryHXl2BqLiQoR1v3p+0tIJcEbh5cnzHfoTZzUis1TEAZluPRsHBQ==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/math": "4.3.4", + "@math.gl/core": "^4.1.0", + "@math.gl/culling": "^4.1.0", + "@math.gl/geospatial": "^4.1.0", + "@math.gl/web-mercator": "^4.1.0", + "@probe.gl/stats": "^4.0.2" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/wms": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/wms/-/wms-4.3.4.tgz", + "integrity": "sha512-yXF0wuYzJUdzAJQrhLIua6DnjOiBJusaY1j8gpvuH1VYs3mzvWlIRuZKeUd9mduQZKK88H2IzHZbj2RGOauq4w==", + "license": "MIT", + "dependencies": { + "@loaders.gl/images": "4.3.4", + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "@loaders.gl/xml": "4.3.4", + "@turf/rewind": "^5.1.5", + "deep-strict-equal": "^0.2.0" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, "node_modules/@loaders.gl/worker-utils": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/@loaders.gl/worker-utils/-/worker-utils-4.3.4.tgz", @@ -1154,6 +1432,36 @@ "@loaders.gl/core": "^4.3.0" } }, + "node_modules/@loaders.gl/xml": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/xml/-/xml-4.3.4.tgz", + "integrity": "sha512-p+y/KskajsvyM3a01BwUgjons/j/dUhniqd5y1p6keLOuwoHlY/TfTKd+XluqfyP14vFrdAHCZTnFCWLblN10w==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "fast-xml-parser": "^4.2.5" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/zip": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/zip/-/zip-4.3.4.tgz", + "integrity": "sha512-bHY4XdKYJm3vl9087GMoxnUqSURwTxPPh6DlAGOmz6X9Mp3JyWuA2gk3tQ1UIuInfjXKph3WAUfGe6XRIs1sfw==", + "license": "MIT", + "dependencies": { + "@loaders.gl/compression": "4.3.4", + "@loaders.gl/crypto": "4.3.4", + "@loaders.gl/loader-utils": "4.3.4", + "jszip": "^3.1.5", + "md5": "^2.3.0" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, "node_modules/@luma.gl/constants": { "version": "9.2.6", "resolved": "https://registry.npmjs.org/@luma.gl/constants/-/constants-9.2.6.tgz", @@ -1189,6 +1497,24 @@ "@luma.gl/shadertools": "~9.2.0" } }, + "node_modules/@luma.gl/gltf": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@luma.gl/gltf/-/gltf-9.2.6.tgz", + "integrity": "sha512-is3YkiGsWqWTmwldMz6PRaIUleufQfUKYjJTKpsF5RS1OnN+xdAO0mJq5qJTtOQpppWAU0VrmDFEVZ6R3qvm0A==", + "license": "MIT", + "dependencies": { + "@loaders.gl/core": "^4.2.0", + "@loaders.gl/gltf": "^4.2.0", + "@loaders.gl/textures": "^4.2.0", + "@math.gl/core": "^4.1.0" + }, + "peerDependencies": { + "@luma.gl/constants": "~9.2.0", + "@luma.gl/core": "~9.2.0", + "@luma.gl/engine": "~9.2.0", + "@luma.gl/shadertools": "~9.2.0" + } + }, "node_modules/@luma.gl/shadertools": { "version": "9.2.6", "resolved": "https://registry.npmjs.org/@luma.gl/shadertools/-/shadertools-9.2.6.tgz", @@ -1238,6 +1564,12 @@ "node": ">= 0.6" } }, + "node_modules/@mapbox/martini": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@mapbox/martini/-/martini-0.2.0.tgz", + "integrity": "sha512-7hFhtkb0KTLEls+TRw/rWayq5EeHtTaErgm/NskVoXmtgAQu/9D299aeyj6mzAR/6XUnYRp2lU+4IcrYRFjVsQ==", + "license": "ISC" + }, "node_modules/@mapbox/point-geometry": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz", @@ -1320,6 +1652,26 @@ "@math.gl/types": "4.1.0" } }, + "node_modules/@math.gl/culling": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@math.gl/culling/-/culling-4.1.0.tgz", + "integrity": "sha512-jFmjFEACnP9kVl8qhZxFNhCyd47qPfSVmSvvjR0/dIL6R9oD5zhR1ub2gN16eKDO/UM7JF9OHKU3EBIfeR7gtg==", + "license": "MIT", + "dependencies": { + "@math.gl/core": "4.1.0", + "@math.gl/types": "4.1.0" + } + }, + "node_modules/@math.gl/geospatial": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@math.gl/geospatial/-/geospatial-4.1.0.tgz", + "integrity": "sha512-BzsUhpVvnmleyYF6qdqJIip6FtIzJmnWuPTGhlBuPzh7VBHLonCFSPtQpbkRuoyAlbSyaGXcVt6p6lm9eK2vtg==", + "license": "MIT", + "dependencies": { + "@math.gl/core": "4.1.0", + "@math.gl/types": "4.1.0" + } + }, "node_modules/@math.gl/polygon": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/@math.gl/polygon/-/polygon-4.1.0.tgz", @@ -1790,6 +2142,62 @@ "dev": true, "license": "MIT" }, + "node_modules/@turf/boolean-clockwise": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@turf/boolean-clockwise/-/boolean-clockwise-5.1.5.tgz", + "integrity": "sha512-FqbmEEOJ4rU4/2t7FKx0HUWmjFEVqR+NJrFP7ymGSjja2SQ7Q91nnBihGuT+yuHHl6ElMjQ3ttsB/eTmyCycxA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^5.1.5", + "@turf/invariant": "^5.1.5" + } + }, + "node_modules/@turf/clone": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@turf/clone/-/clone-5.1.5.tgz", + "integrity": "sha512-//pITsQ8xUdcQ9pVb4JqXiSqG4dos5Q9N4sYFoWghX21tfOV2dhc5TGqYOhnHrQS7RiKQL1vQ48kIK34gQ5oRg==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^5.1.5" + } + }, + "node_modules/@turf/helpers": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-5.1.5.tgz", + "integrity": "sha512-/lF+JR+qNDHZ8bF9d+Cp58nxtZWJ3sqFe6n3u3Vpj+/0cqkjk4nXKYBSY0azm+GIYB5mWKxUXvuP/m0ZnKj1bw==", + "license": "MIT" + }, + "node_modules/@turf/invariant": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-5.2.0.tgz", + "integrity": "sha512-28RCBGvCYsajVkw2EydpzLdcYyhSA77LovuOvgCJplJWaNVyJYH6BOR3HR9w50MEkPqb/Vc/jdo6I6ermlRtQA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^5.1.5" + } + }, + "node_modules/@turf/meta": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@turf/meta/-/meta-5.2.0.tgz", + "integrity": "sha512-ZjQ3Ii62X9FjnK4hhdsbT+64AYRpaI8XMBMcyftEOGSmPMUVnkbvuv3C9geuElAXfQU7Zk1oWGOcrGOD9zr78Q==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^5.1.5" + } + }, + "node_modules/@turf/rewind": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@turf/rewind/-/rewind-5.1.5.tgz", + "integrity": "sha512-Gdem7JXNu+G4hMllQHXRFRihJl3+pNl7qY+l4qhQFxq+hiU1cQoVFnyoleIqWKIrdK/i2YubaSwc3SCM7N5mMw==", + "license": "MIT", + "dependencies": { + "@turf/boolean-clockwise": "^5.1.5", + "@turf/clone": "^5.1.5", + "@turf/helpers": "^5.1.5", + "@turf/invariant": "^5.1.5", + "@turf/meta": "^5.1.5" + } + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -1842,6 +2250,15 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/brotli": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@types/brotli/-/brotli-1.3.4.tgz", + "integrity": "sha512-cKYjgaS2DMdCKF7R0F5cgx1nfBYObN2ihIuPGQ4/dlIY6RpV7OWNwe9L8V4tTVKL2eZqOkNM9FM/rgTvLf4oXw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -1853,6 +2270,12 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/crypto-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz", + "integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==", + "license": "MIT" + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -1912,7 +2335,6 @@ "version": "25.2.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.2.tgz", "integrity": "sha512-BkmoP5/FhRYek5izySdkOneRyXYN35I860MFAGupTdebyE66uZaR+bXLHq8k4DirE5DwQi3NuhvRU1jqTVwUrQ==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -1924,6 +2346,12 @@ "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", "license": "MIT" }, + "node_modules/@types/pako": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-1.0.7.tgz", + "integrity": "sha512-YBtzT2ztNF6R/9+UXj2wTGFnC9NklAnASt3sC0h2m1bbH7G6FyBIkt4AN8ThZpNfxUo1b2iMVO0UawiJymEt8A==", + "license": "MIT" + }, "node_modules/@types/pbf": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz", @@ -2054,6 +2482,15 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/a5-js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/a5-js/-/a5-js-0.5.0.tgz", + "integrity": "sha512-VAw19sWdYadhdovb0ViOIi1SdKx6H6LwcGMRFKwMfgL5gcmL/1fKJHfgsNgNaJ7xC/eEyjs6VK+VVd4N0a+peg==", + "license": "Apache-2.0", + "dependencies": { + "gl-matrix": "^3.4.3" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -2087,6 +2524,15 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/aria-query": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", @@ -2156,6 +2602,27 @@ } } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, "node_modules/baseline-browser-mapping": { "version": "2.9.19", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", @@ -2176,6 +2643,16 @@ "require-from-string": "^2.0.2" } }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.1.2" + } + }, "node_modules/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -2210,6 +2687,15 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buf-compare": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buf-compare/-/buf-compare-1.0.1.tgz", + "integrity": "sha512-Bvx4xH00qweepGc43xFvMs5BKASXTbHaHm6+kDYIK9p/4iFwjATQkmPKHQSgJZzKbAymhztRbXUf1Nqhzl73/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -2271,6 +2757,15 @@ "node": ">=18" } }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, "node_modules/check-error": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", @@ -2288,6 +2783,34 @@ "dev": true, "license": "MIT" }, + "node_modules/core-assert": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/core-assert/-/core-assert-0.2.1.tgz", + "integrity": "sha512-IG97qShIP+nrJCXMCgkNZgH7jZQ4n8RpPyPeXX++T6avR/KhLhgLiHKoEn5Rc1KjfycSfA9DMa6m+4C4eguHhw==", + "license": "MIT", + "dependencies": { + "buf-compare": "^1.0.0", + "is-error": "^2.2.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, "node_modules/css-tree": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", @@ -2390,6 +2913,18 @@ "node": ">=6" } }, + "node_modules/deep-strict-equal": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/deep-strict-equal/-/deep-strict-equal-0.2.0.tgz", + "integrity": "sha512-3daSWyvZ/zwJvuMGlzG1O+Ow0YSadGfb3jsh9xoCutv2tWyB9dA4YvR9L9/fSdDZa2dByYQe+TqapSGUrjnkoA==", + "license": "MIT", + "dependencies": { + "core-assert": "^0.2.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -2430,6 +2965,12 @@ "detect-libc": "^1.0.3" } }, + "node_modules/draco3d": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", + "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==", + "license": "Apache-2.0" + }, "node_modules/earcut": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", @@ -2535,6 +3076,24 @@ "node": ">=12.0.0" } }, + "node_modules/fast-xml-parser": { + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz", + "integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^1.1.1" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2622,6 +3181,17 @@ "node": ">=16" } }, + "node_modules/h3-js": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/h3-js/-/h3-js-4.4.0.tgz", + "integrity": "sha512-DvJh07MhGgY2KcC4OeZc8SSyA+ZXpdvoh6uCzGpoKvWtZxJB+g6VXXC1+eWYkaMIsLz7J/ErhOalHCpcs1KYog==", + "license": "Apache-2.0", + "engines": { + "node": ">=4", + "npm": ">=3", + "yarn": ">=1.3.0" + } + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -2690,6 +3260,24 @@ ], "license": "BSD-3-Clause" }, + "node_modules/image-size": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.7.5.tgz", + "integrity": "sha512-Hiyv+mXHfFEP7LzUL/llg9RwFxxY+o9N3JVLIeG5E7iFIFAalxvRU9UZthBdYDEVnzHMgjnKJPPpay5BWf1g9g==", + "license": "MIT", + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", @@ -2700,6 +3288,12 @@ "node": ">=8" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/ini": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", @@ -2709,6 +3303,18 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "license": "MIT" + }, + "node_modules/is-error": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-error/-/is-error-2.2.2.tgz", + "integrity": "sha512-IOQqts/aHWbiisY5DuPJQ0gcbvaLFCa7fBa9xoLfxBZvQ+ZI/Zh9xoI7Gk+G64N0FdK4AbibytHht2tWgpJWLg==", + "license": "MIT" + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -2729,6 +3335,12 @@ "url": "https://github.com/sponsors/mesqueeb" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.4.tgz", @@ -2830,6 +3442,18 @@ "node": ">=6" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "node_modules/kdbush": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", @@ -2845,6 +3469,12 @@ "node": ">=0.10.0" } }, + "node_modules/ktx-parse": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/ktx-parse/-/ktx-parse-0.7.1.tgz", + "integrity": "sha512-FeA3g56ksdFNwjXJJsc1CCc7co+AJYDp6ipIp878zZ2bU8kWROatLYf39TQEd4/XRSUvBXovQ8gaVKWPXsCLEQ==", + "license": "MIT" + }, "node_modules/leaflet": { "version": "1.9.4", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", @@ -2857,6 +3487,24 @@ "integrity": "sha512-yc97gxLXwbZa+Gk9VCcqI0CkvIBC9oNTTjFsHqq4EQvANrvaboib4UdeQLyTnEqDpaXHCqzwwVIDHtvz2mUiDg==", "license": "MIT" }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/long": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/long/-/long-3.2.0.tgz", + "integrity": "sha512-ZYvPPOMqUwPoDsbJaR10iQJYnMuZhRTvHYl62ErLIEX7RgFlziSBUUvrt3OVfc47QlHHpzPZYP17g3Fv7oeJkg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.6" + } + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -2884,6 +3532,19 @@ "lz-string": "bin/bin.js" } }, + "node_modules/lz4js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/lz4js/-/lz4js-0.2.0.tgz", + "integrity": "sha512-gY2Ia9Lm7Ep8qMiuGRhvUq0Q7qUereeldZPP1PMEJxPtEWHJLqw9pgX68oHajBH0nzJK4MaZEA/YNV3jT8u8Bg==", + "license": "ISC", + "optional": true + }, + "node_modules/lzo-wasm": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/lzo-wasm/-/lzo-wasm-0.0.4.tgz", + "integrity": "sha512-VKlnoJRFrB8SdJhlVKvW5vI1gGwcZ+mvChEXcSX6r2xDNc/Q2FD9esfBmGCuPZdrJ1feO+YcVFd2PTk0c137Gw==", + "license": "BSD-2-Clause" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2935,6 +3596,17 @@ "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" } }, + "node_modules/md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "license": "BSD-3-Clause", + "dependencies": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, "node_modules/mdn-data": { "version": "2.12.2", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", @@ -3022,6 +3694,12 @@ "dev": true, "license": "MIT" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -3144,6 +3822,12 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/protocol-buffers-schema": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", @@ -3173,6 +3857,21 @@ "dev": true, "license": "MIT" }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -3257,6 +3956,12 @@ "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", "license": "BSD-3-Clause" }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -3301,6 +4006,12 @@ "seroval": "^1.0" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -3308,6 +4019,12 @@ "dev": true, "license": "ISC" }, + "node_modules/snappyjs": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/snappyjs/-/snappyjs-0.6.1.tgz", + "integrity": "sha512-YIK6I2lsH072UE0aOFxxY1dPDCS43I5ktqHpeAsuLNYWkE5pGxRGWfDM4/vSUfNzXjC1Ivzt3qx31PCLmc9yqg==", + "license": "MIT" + }, "node_modules/solid-js": { "version": "1.9.11", "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.11.tgz", @@ -3344,6 +4061,12 @@ "node": ">=0.10.0" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -3358,6 +4081,15 @@ "dev": true, "license": "MIT" }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -3391,6 +4123,18 @@ "dev": true, "license": "MIT" }, + "node_modules/strnum": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz", + "integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/supercluster": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", @@ -3407,6 +4151,19 @@ "dev": true, "license": "MIT" }, + "node_modules/texture-compressor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/texture-compressor/-/texture-compressor-1.0.2.tgz", + "integrity": "sha512-dStVgoaQ11mA5htJ+RzZ51ZxIZqNOgWKAIvtjLrW1AliQQLCmrDqNzQZ8Jh91YealQ95DXt4MEduLzJmbs6lig==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.10", + "image-size": "^0.7.4" + }, + "bin": { + "texture-compressor": "bin/texture-compressor.js" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -3584,7 +4341,6 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, "license": "MIT" }, "node_modules/update-browserslist-db": { @@ -3618,6 +4374,12 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/vite": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", @@ -3954,6 +4716,13 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" + }, + "node_modules/zstd-codec": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/zstd-codec/-/zstd-codec-0.1.5.tgz", + "integrity": "sha512-v3fyjpK8S/dpY/X5WxqTK3IoCnp/ZOLxn144GZVlNUjtwAchzrVo03h+oMATFhCIiJ5KTr4V3vDQQYz4RU684g==", + "license": "MIT", + "optional": true } } } diff --git a/static-next/package.json b/static-next/package.json index 36816fa6..c4aa0a5f 100644 --- a/static-next/package.json +++ b/static-next/package.json @@ -13,8 +13,8 @@ "dependencies": { "@bufbuild/protobuf": "^2.11.0", "@deck.gl/core": "^9.2.6", + "@deck.gl/geo-layers": "^9.2.6", "@deck.gl/layers": "^9.2.6", - "@deck.gl/mapbox": "^9.2.6", "@maplibre/maplibre-gl-leaflet": "^0.0.22", "leaflet": "^1.9.0", "leaflet-rotatedmarker": "^0.2.0", diff --git a/static-next/src/renderers/deckgl/deckgl-basemap.ts b/static-next/src/renderers/deckgl/deckgl-basemap.ts new file mode 100644 index 00000000..56bf0467 --- /dev/null +++ b/static-next/src/renderers/deckgl/deckgl-basemap.ts @@ -0,0 +1,464 @@ +/** + * Build deck.gl basemap layers from a compiled MapLibre style. + * + * Renders vector tiles via MVTLayer and raster tiles via TileLayer+BitmapLayer, + * with a SolidPolygonLayer background. + */ + +import { GeoJsonLayer, IconLayer, TextLayer, ScatterplotLayer, SolidPolygonLayer, BitmapLayer } from "@deck.gl/layers"; +import { TileLayer } from "@deck.gl/geo-layers"; +import { parse } from "@loaders.gl/core"; +import { MVTLoader } from "@loaders.gl/mvt"; +import type { Layer } from "@deck.gl/core"; +import type { PMTiles } from "pmtiles"; +import { parseCssColor } from "./deckgl-expressions"; +import type { ExprFn } from "./deckgl-expressions"; +import type { CompiledStyle, CompiledLayer, SourceDef } from "./deckgl-style-parser"; + +// --------------- Types --------------- + +export interface SpriteAtlas { + image: ImageBitmap | HTMLImageElement; + url: string; + mapping: Record; +} + +// --------------- Sprite atlas loader --------------- + +export async function loadSpriteAtlas(spriteUrl: string): Promise { + if (!spriteUrl) return null; + + try { + // Prefer @2x if available, fallback to @1x + const [jsonResp, imgResp] = await Promise.all([ + fetch(`${spriteUrl}.json`), + fetch(`${spriteUrl}.png`), + ]); + + if (!jsonResp.ok || !imgResp.ok) return null; + + const spriteJson = await jsonResp.json(); + const blob = await imgResp.blob(); + const image = await createImageBitmap(blob); + + // Convert MapLibre sprite format to deck.gl iconMapping + const mapping: SpriteAtlas["mapping"] = {}; + for (const [name, def] of Object.entries(spriteJson)) { + mapping[name] = { + x: def.x, + y: def.y, + width: def.width, + height: def.height, + anchorY: def.height / 2, + }; + } + + return { image, url: `${spriteUrl}.png`, mapping }; + } catch { + return null; + } +} + +// --------------- Color helpers --------------- + +function evalColor(expr: ExprFn | undefined, props: Record, zoom: number, fallback: string): [number, number, number, number] { + if (!expr) return parseCssColor(fallback); + const val = expr(props, zoom); + if (typeof val === "string") return parseCssColor(val); + return parseCssColor(fallback); +} + +function evalNumber(expr: ExprFn | undefined, props: Record, zoom: number, fallback: number): number { + if (!expr) return fallback; + const val = expr(props, zoom); + return typeof val === "number" ? val : fallback; +} + +// --------------- Vector sub-layers (batched by geometry type) --------------- + +/** + * Build sub-layers for a single vector tile, batching features by geometry type. + * + * Instead of creating one deck.gl layer per style layer (~30 per tile), + * we pre-compute style values on each feature and batch into ~6 typed layers: + * fills → lines → extrusions → circles → icons → texts. + * This reduces draw calls from ~600 to ~120 across all visible tiles. + */ +function buildVectorSubLayers( + tileProps: any, + compiledLayers: CompiledLayer[], + zoom: number, + spriteAtlas: SpriteAtlas | null, +): Layer[] { + const { data, id: tileId } = tileProps; + if (!data || !Array.isArray(data) || data.length === 0) return []; + + // Group features by source layer name (set by MVT loader as `layerName`) + const bySourceLayer = new Map(); + for (const feature of data) { + const name = feature.properties?.layerName ?? ""; + let arr = bySourceLayer.get(name); + if (!arr) { + arr = []; + bySourceLayer.set(name, arr); + } + arr.push(feature); + } + + // Typed batches — features are pushed in style-layer order to preserve + // cartographic ordering within each batch. + const fills: any[] = []; + const lines: any[] = []; + const extrusions: any[] = []; + const circles: any[] = []; + const icons: any[] = []; + const texts: any[] = []; + + for (const layer of compiledLayers) { + if (zoom < layer.minZoom || zoom > layer.maxZoom) continue; + + const features = bySourceLayer.get(layer.sourceLayer); + if (!features || features.length === 0) continue; + + let filtered = features; + if (layer.filter) { + const filterFn = layer.filter; + filtered = features.filter((f) => filterFn(f.properties || {}, zoom)); + } + if (filtered.length === 0) continue; + + switch (layer.type) { + case "fill": + for (const f of filtered) { + const c = evalColor(layer.paint["fill-color"], f.properties, zoom, "#888"); + const opacity = evalNumber(layer.paint["fill-opacity"], f.properties, zoom, 1); + fills.push({ + type: f.type, geometry: f.geometry, properties: f.properties, + _color: [c[0], c[1], c[2], Math.round(c[3] * opacity)], + }); + } + break; + + case "line": + for (const f of filtered) { + const c = evalColor(layer.paint["line-color"], f.properties, zoom, "#000"); + const opacity = evalNumber(layer.paint["line-opacity"], f.properties, zoom, 1); + lines.push({ + type: f.type, geometry: f.geometry, properties: f.properties, + _color: [c[0], c[1], c[2], Math.round(c[3] * opacity)], + _width: evalNumber(layer.paint["line-width"], f.properties, zoom, 1), + }); + } + break; + + case "fill-extrusion": + for (const f of filtered) { + const c = evalColor(layer.paint["fill-extrusion-color"], f.properties, zoom, "#888"); + const opacity = evalNumber(layer.paint["fill-extrusion-opacity"], f.properties, zoom, 1); + extrusions.push({ + type: f.type, geometry: f.geometry, properties: f.properties, + _color: [c[0], c[1], c[2], Math.round(c[3] * opacity)], + _height: evalNumber(layer.paint["fill-extrusion-height"], f.properties, zoom, 0), + }); + } + break; + + case "circle": + for (const f of filtered) { + const c = evalColor(layer.paint["circle-color"], f.properties, zoom, "#000"); + const opacity = evalNumber(layer.paint["circle-opacity"], f.properties, zoom, 1); + circles.push({ + position: f.geometry?.coordinates ?? [0, 0], + _color: [c[0], c[1], c[2], Math.round(c[3] * opacity)], + _radius: evalNumber(layer.paint["circle-radius"], f.properties, zoom, 5), + }); + } + break; + + case "symbol": { + const iconImage = layer.layout["icon-image"]; + const textField = layer.layout["text-field"]; + const placement = layer.layout["symbol-placement"]; + const placementVal = placement ? placement({}, zoom) : "point"; + + if (iconImage && spriteAtlas) { + const iconSize = layer.layout["icon-size"]; + for (const f of filtered) { + const name = iconImage(f.properties, zoom); + if (name && spriteAtlas.mapping[name]) { + const scale = iconSize ? evalNumber(iconSize, f.properties, zoom, 1) : 1; + icons.push({ + position: f.geometry?.coordinates ?? [0, 0], + _icon: name, + _size: 64 * scale, + }); + } + } + } + + if (textField && placementVal !== "line") { + const textSize = layer.layout["text-size"]; + const textOffset = layer.layout["text-offset"]; + const textAnchor = layer.layout["text-anchor"]; + const anchor = textAnchor ? textAnchor({}, zoom) : "center"; + const mappedAnchor = anchor === "left" ? "start" : anchor === "right" ? "end" : "middle"; + let pixelOffset: [number, number] = [0, 0]; + if (textOffset) { + const off = textOffset({}, zoom); + if (Array.isArray(off)) pixelOffset = [off[0] * 16, off[1] * 16]; + } + + for (const f of filtered) { + const text = textField(f.properties, zoom); + if (text != null && String(text) !== "") { + texts.push({ + position: f.geometry?.coordinates ?? [0, 0], + _text: String(text), + _size: evalNumber(textSize, f.properties, zoom, 14), + _color: evalColor(layer.paint["text-color"], f.properties, zoom, "#000"), + _anchor: mappedAnchor, + _pixelOffset: pixelOffset, + }); + } + } + } + break; + } + } + } + + // Build one deck.gl layer per non-empty batch + const subLayers: Layer[] = []; + + if (fills.length > 0) { + subLayers.push(new GeoJsonLayer({ + id: `${tileId}-fills`, + data: fills, + filled: true, + stroked: false, + getFillColor: (d: any) => d._color, + pickable: false, + })); + } + + if (lines.length > 0) { + subLayers.push(new GeoJsonLayer({ + id: `${tileId}-lines`, + data: lines, + filled: false, + stroked: true, + getLineColor: (d: any) => d._color, + getLineWidth: (d: any) => d._width, + lineWidthUnits: "pixels", + lineWidthMinPixels: 0.5, + pickable: false, + })); + } + + if (extrusions.length > 0) { + subLayers.push(new GeoJsonLayer({ + id: `${tileId}-extrusions`, + data: extrusions, + filled: true, + stroked: false, + extruded: true, + getFillColor: (d: any) => d._color, + getElevation: (d: any) => d._height, + pickable: false, + })); + } + + if (circles.length > 0) { + subLayers.push(new ScatterplotLayer({ + id: `${tileId}-circles`, + data: circles, + getPosition: (d: any) => d.position, + getRadius: (d: any) => d._radius, + getFillColor: (d: any) => d._color, + radiusUnits: "pixels", + pickable: false, + })); + } + + if (icons.length > 0 && spriteAtlas) { + subLayers.push(new IconLayer({ + id: `${tileId}-icons`, + data: icons, + iconAtlas: spriteAtlas.url, + iconMapping: spriteAtlas.mapping, + getIcon: (d: any) => d._icon, + getPosition: (d: any) => d.position, + getSize: (d: any) => d._size, + sizeUnits: "pixels", + billboard: false, + pickable: false, + })); + } + + if (texts.length > 0) { + subLayers.push(new TextLayer({ + id: `${tileId}-texts`, + data: texts, + getPosition: (d: any) => d.position, + getText: (d: any) => d._text, + getSize: (d: any) => d._size, + getColor: (d: any) => d._color, + getTextAnchor: (d: any) => d._anchor, + getPixelOffset: (d: any) => d._pixelOffset, + sizeUnits: "pixels", + billboard: false, + fontFamily: "Arial, sans-serif", + pickable: false, + })); + } + + return subLayers; +} + +// --------------- Raster tile layer --------------- + +function buildRasterTileLayer( + id: string, + pmtiles: PMTiles, + _source: SourceDef, +): Layer { + return new TileLayer({ + id, + getTileData: async ({ index: { z, x, y } }: any) => { + try { + const result = await pmtiles.getZxy(z, x, y); + if (!result || !result.data) return null; + return createImageBitmap(new Blob([result.data])); + } catch { + return null; + } + }, + renderSubLayers: (props: any) => { + if (!props.data) return null; + const { west, south, east, north } = props.tile.bbox; + return new BitmapLayer({ + ...props, + image: props.data, + bounds: [west, south, east, north], + }); + }, + minZoom: 0, + maxZoom: 22, + tileSize: _source.tileSize ?? 256, + }) as unknown as Layer; +} + +// --------------- Background layer --------------- + +function buildBackgroundLayer( + bgColor: ExprFn, + zoom: number, + worldSizeDeg: number, +): Layer { + const color = parseCssColor(bgColor({}, zoom)); + // World-covering polygon + return new SolidPolygonLayer({ + id: "basemap-background", + data: [{ + polygon: [ + [-1, -1], + [worldSizeDeg + 1, -1], + [worldSizeDeg + 1, worldSizeDeg + 1], + [-1, worldSizeDeg + 1], + ], + }], + getPolygon: (d: any) => d.polygon, + getFillColor: color, + pickable: false, + }) as unknown as Layer; +} + +// --------------- Stable tile data fetcher --------------- + +/** + * Create a stable getTileData function for a PMTiles vector source. + * Call once per PMTiles instance and reuse across basemap rebuilds + * so deck.gl's TileLayer keeps its tile cache instead of refetching. + */ +export function createVectorTileDataFetcher(pmtiles: PMTiles) { + return async ({ index: { z, x, y } }: any) => { + try { + const result = await pmtiles.getZxy(z, x, y); + if (!result?.data) return null; + return await parse(result.data, MVTLoader, { + mvt: { coordinates: "wgs84", tileIndex: { x, y, z } }, + }); + } catch { + return null; + } + }; +} + +// --------------- Assembly --------------- + +export interface BasemapConfig { + compiledStyle: CompiledStyle; + zoom: number; + worldSizeDeg: number; + spriteAtlas: SpriteAtlas | null; + vectorPMTiles?: PMTiles; + vectorMaxZoom?: number; + /** Stable getTileData function — created once via createVectorTileDataFetcher. */ + vectorGetTileData?: (opts: any) => Promise; + rasterPMTiles?: Map; +} + +/** + * Build all basemap layers from a compiled style. + * Returns layers in render order: background, raster, vector. + */ +export function buildBasemapLayers(config: BasemapConfig): Layer[] { + const { compiledStyle, zoom, worldSizeDeg, spriteAtlas, vectorPMTiles, vectorMaxZoom, vectorGetTileData, rasterPMTiles } = config; + const layers: Layer[] = []; + + // 1. Background + layers.push(buildBackgroundLayer(compiledStyle.background, zoom, worldSizeDeg)); + + // 2. Raster tile layers + if (rasterPMTiles) { + for (const [name, pmtiles] of rasterPMTiles) { + const source = compiledStyle.sources[name]; + if (source && source.type === "raster") { + layers.push(buildRasterTileLayer(`basemap-raster-${name}`, pmtiles, source)); + } + } + } + + // 3. Vector tile layer (TileLayer + MVT parsing) + if (vectorPMTiles) { + // Filter to layers from vector sources only + const vectorSourceNames = new Set( + Object.entries(compiledStyle.sources) + .filter(([_, s]) => s.type === "vector") + .map(([name]) => name), + ); + const vectorLayers = compiledStyle.layers.filter((l) => vectorSourceNames.has(l.source)); + + if (vectorLayers.length > 0) { + const intZoom = Math.floor(zoom); + // Use stable getTileData reference if provided (avoids tile cache + // invalidation on zoom change). Fall back to creating one on the fly. + const getTileData = vectorGetTileData ?? createVectorTileDataFetcher(vectorPMTiles); + layers.push( + new TileLayer({ + id: "basemap-vector", + getTileData, + renderSubLayers: (props: any) => + buildVectorSubLayers(props, vectorLayers, zoom, spriteAtlas), + updateTriggers: { + renderSubLayers: intZoom, + }, + minZoom: 0, + maxZoom: vectorMaxZoom ?? 14, + }) as unknown as Layer, + ); + } + } + + return layers; +} diff --git a/static-next/src/renderers/deckgl/deckgl-expressions.ts b/static-next/src/renderers/deckgl/deckgl-expressions.ts new file mode 100644 index 00000000..bf62f0ec --- /dev/null +++ b/static-next/src/renderers/deckgl/deckgl-expressions.ts @@ -0,0 +1,292 @@ +/** + * Compile MapLibre GL style expressions into JS accessor functions. + * + * Each compiled expression is a function (properties, zoom) => value. + * Only the operators actually used by styles.go are implemented. + */ + +export type ExprFn = (properties: Record, zoom: number) => any; + +/** + * Parse a CSS color string to an [r, g, b, a] array (0-255). + * Handles #hex (3/6/8 digit), rgb(), rgba(). + */ +export function parseCssColor(value: string): [number, number, number, number] { + if (typeof value !== "string") return [0, 0, 0, 255]; + + // #RGB, #RRGGBB, #RRGGBBAA + if (value.startsWith("#")) { + const h = value.slice(1); + if (h.length === 3) { + return [ + parseInt(h[0] + h[0], 16), + parseInt(h[1] + h[1], 16), + parseInt(h[2] + h[2], 16), + 255, + ]; + } + if (h.length >= 6) { + const r = parseInt(h.slice(0, 2), 16); + const g = parseInt(h.slice(2, 4), 16); + const b = parseInt(h.slice(4, 6), 16); + const a = h.length >= 8 ? parseInt(h.slice(6, 8), 16) : 255; + return [r, g, b, a]; + } + } + + // rgb(r, g, b) or rgba(r, g, b, a) + const m = value.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+))?\s*\)/); + if (m) { + return [ + parseInt(m[1], 10), + parseInt(m[2], 10), + parseInt(m[3], 10), + m[4] !== undefined ? Math.round(parseFloat(m[4]) * 255) : 255, + ]; + } + + return [0, 0, 0, 255]; +} + +/** + * Compile a MapLibre expression (or literal) to a JS function. + */ +export function compileExpression(expr: any): ExprFn { + // null/undefined → identity + if (expr === null || expr === undefined) { + return () => undefined; + } + + // Literal values (string, number, boolean) + if (typeof expr !== "object" || !Array.isArray(expr)) { + return () => expr; + } + + const [op, ...args] = expr; + + switch (op) { + case "literal": + return () => args[0]; + + case "get": + return (props) => props[args[0]]; + + case "zoom": + return (_props, zoom) => zoom; + + case "case": { + // ["case", cond1, val1, cond2, val2, ..., fallback] + const pairs: Array<{ cond: ExprFn; val: ExprFn }> = []; + let i = 0; + while (i < args.length - 1) { + pairs.push({ + cond: compileExpression(args[i]), + val: compileExpression(args[i + 1]), + }); + i += 2; + } + const fallback = compileExpression(args[args.length - 1]); + return (props, zoom) => { + for (const pair of pairs) { + if (pair.cond(props, zoom)) return pair.val(props, zoom); + } + return fallback(props, zoom); + }; + } + + // Comparisons + case "<": { + const a = compileExpression(args[0]); + const b = compileExpression(args[1]); + return (props, zoom) => a(props, zoom) < b(props, zoom); + } + case "<=": { + const a = compileExpression(args[0]); + const b = compileExpression(args[1]); + return (props, zoom) => a(props, zoom) <= b(props, zoom); + } + case ">": { + const a = compileExpression(args[0]); + const b = compileExpression(args[1]); + return (props, zoom) => a(props, zoom) > b(props, zoom); + } + case ">=": { + const a = compileExpression(args[0]); + const b = compileExpression(args[1]); + return (props, zoom) => a(props, zoom) >= b(props, zoom); + } + case "==": { + const a = compileExpression(args[0]); + const b = compileExpression(args[1]); + return (props, zoom) => a(props, zoom) == b(props, zoom); + } + case "!=": { + const a = compileExpression(args[0]); + const b = compileExpression(args[1]); + return (props, zoom) => a(props, zoom) != b(props, zoom); + } + + // Interpolation + case "interpolate": { + const interpType = args[0]; // ["linear"] or ["exponential", base] + const input = compileExpression(args[1]); + const stops: Array<{ z: number; val: ExprFn }> = []; + for (let i = 2; i < args.length; i += 2) { + stops.push({ z: args[i] as number, val: compileExpression(args[i + 1]) }); + } + + let base = 1; + if (Array.isArray(interpType) && interpType[0] === "exponential") { + base = interpType[1] ?? 1; + } + + return (props, zoom) => { + const t = input(props, zoom); + if (stops.length === 0) return 0; + if (t <= stops[0].z) return stops[0].val(props, zoom); + if (t >= stops[stops.length - 1].z) return stops[stops.length - 1].val(props, zoom); + + // Find the two surrounding stops + let lo = 0; + for (let i = 1; i < stops.length; i++) { + if (t < stops[i].z) { lo = i - 1; break; } + lo = i; + } + const hi = lo + 1; + if (hi >= stops.length) return stops[lo].val(props, zoom); + + const zLo = stops[lo].z; + const zHi = stops[hi].z; + const range = zHi - zLo; + + let frac: number; + if (base === 1) { + frac = (t - zLo) / range; + } else { + frac = (Math.pow(base, t - zLo) - 1) / (Math.pow(base, range) - 1); + } + + const vLo = stops[lo].val(props, zoom); + const vHi = stops[hi].val(props, zoom); + + if (typeof vLo === "number" && typeof vHi === "number") { + return vLo + frac * (vHi - vLo); + } + // For non-numeric stops, snap to lower + return frac < 0.5 ? vLo : vHi; + }; + } + + // Arithmetic + case "*": { + const operands = args.map(compileExpression); + return (props, zoom) => + operands.reduce((acc, fn) => acc * fn(props, zoom), 1); + } + case "/": { + const a = compileExpression(args[0]); + const b = compileExpression(args[1]); + return (props, zoom) => { + const denom = b(props, zoom); + return denom === 0 ? 0 : a(props, zoom) / denom; + }; + } + case "+": { + const operands = args.map(compileExpression); + return (props, zoom) => + operands.reduce((acc, fn) => acc + fn(props, zoom), 0); + } + case "-": { + const a = compileExpression(args[0]); + const b = args.length > 1 ? compileExpression(args[1]) : null; + return (props, zoom) => + b ? a(props, zoom) - b(props, zoom) : -a(props, zoom); + } + + // String + case "concat": { + const parts = args.map(compileExpression); + return (props, zoom) => parts.map((fn) => fn(props, zoom)).join(""); + } + case "to-string": { + const val = compileExpression(args[0]); + return (props, zoom) => String(val(props, zoom) ?? ""); + } + + // Math + case "round": { + const val = compileExpression(args[0]); + return (props, zoom) => Math.round(val(props, zoom)); + } + + // Decision + case "coalesce": { + const fns = args.map(compileExpression); + return (props, zoom) => { + for (const fn of fns) { + const v = fn(props, zoom); + if (v !== null && v !== undefined) return v; + } + return null; + }; + } + + default: + // Unknown expression — return undefined + return () => undefined; + } +} + +/** + * Compile a MapLibre filter expression. + * + * Handles both new expression syntax (["op", ...]) and legacy filter syntax + * like ["==", "type", "minor"] where bare strings are property names. + */ +export function compileFilter(filter: any): ExprFn | undefined { + if (!filter || !Array.isArray(filter)) return undefined; + + const op = filter[0]; + + // Logical combinators + if (op === "all") { + const fns = filter.slice(1).map(compileFilter).filter(Boolean) as ExprFn[]; + return (props, zoom) => fns.every((fn) => fn(props, zoom)); + } + if (op === "any") { + const fns = filter.slice(1).map(compileFilter).filter(Boolean) as ExprFn[]; + return (props, zoom) => fns.some((fn) => fn(props, zoom)); + } + if (op === "none") { + const fns = filter.slice(1).map(compileFilter).filter(Boolean) as ExprFn[]; + return (props, zoom) => !fns.some((fn) => fn(props, zoom)); + } + + // Legacy filter format: ["==", "propertyName", value] + // Distinguished from expression format by second argument being a bare string + if ( + (op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=") && + filter.length === 3 && + typeof filter[1] === "string" && + !Array.isArray(filter[1]) + ) { + // Check if second arg looks like an expression array — if not, it's a legacy prop name + const propOrExpr = filter[1]; + const value = filter[2]; + // If the second argument is a plain string (not "get"/"zoom" etc.), treat as legacy + if (!Array.isArray(propOrExpr)) { + const compiledValue = compileExpression(value); + switch (op) { + case "==": return (props, zoom) => props[propOrExpr] == compiledValue(props, zoom); + case "!=": return (props, zoom) => props[propOrExpr] != compiledValue(props, zoom); + case "<": return (props, zoom) => props[propOrExpr] < compiledValue(props, zoom); + case "<=": return (props, zoom) => props[propOrExpr] <= compiledValue(props, zoom); + case ">": return (props, zoom) => props[propOrExpr] > compiledValue(props, zoom); + case ">=": return (props, zoom) => props[propOrExpr] >= compiledValue(props, zoom); + } + } + } + + // New expression-based filter — compile normally + return compileExpression(filter); +} diff --git a/static-next/src/renderers/deckgl/deckgl-renderer.ts b/static-next/src/renderers/deckgl/deckgl-renderer.ts index abeb796e..a9f7e0a1 100644 --- a/static-next/src/renderers/deckgl/deckgl-renderer.ts +++ b/static-next/src/renderers/deckgl/deckgl-renderer.ts @@ -1,10 +1,10 @@ -import maplibregl from "maplibre-gl"; -import { MapboxOverlay } from "@deck.gl/mapbox"; -import type { Layer } from "@deck.gl/core"; +import { Deck, FlyToInterpolator, WebMercatorViewport } from "@deck.gl/core"; +import type { Layer, MapViewState } from "@deck.gl/core"; +import { PMTiles } from "pmtiles"; import type { ArmaCoord } from "../../utils/coordinates"; import { METERS_PER_DEGREE } from "../../utils/coordinates"; import { closestEquivalentAngle } from "../../utils/math"; -import type { WorldConfig, Side, AliveState } from "../../data/types"; +import type { WorldConfig } from "../../data/types"; import type { MapRenderer } from "../renderer.interface"; import type { MarkerHandle, @@ -21,7 +21,6 @@ import type { RendererEvent, RendererControls, } from "../renderer.types"; -import { SIDE_CLASS } from "../../config/side-colors"; import { DeckState, hexToRGBA, @@ -45,6 +44,11 @@ import { buildPulseLayer, } from "./deckgl-layers"; import { getTransitionDuration } from "../shared/transitions"; +import { parseStyleDocument } from "./deckgl-style-parser"; +import type { CompiledStyle } from "./deckgl-style-parser"; +import { buildBasemapLayers, loadSpriteAtlas, createVectorTileDataFetcher } from "./deckgl-basemap"; +import type { SpriteAtlas } from "./deckgl-basemap"; +import { ScaleControl } from "./deckgl-scale-control"; // --------------- Internal handle types --------------- @@ -105,10 +109,23 @@ function lngLatToArma(lngLat: [number, number]): ArmaCoord { // --------------- Renderer --------------- export class DeckGLRenderer implements MapRenderer { - private map!: maplibregl.Map; - private overlay!: MapboxOverlay; + private deck!: Deck; + private container!: HTMLElement; private state!: DeckState; private iconAtlas!: IconAtlas; + private scaleControl!: ScaleControl; + + private viewState!: MapViewState; + private worldSizeDeg = 0; + private lastIntZoom = 0; + + // Basemap + private compiledStyle: CompiledStyle | null = null; + private spriteAtlas: SpriteAtlas | null = null; + private vectorPMTiles: PMTiles | null = null; + private vectorMaxZoom = 14; + private vectorGetTileData: ((opts: any) => Promise) | null = null; + private basemapLayers: Layer[] = []; private nameDisplayMode: "players" | "all" | "none" = "players"; private smoothingEnabled = false; @@ -119,80 +136,71 @@ export class DeckGLRenderer implements MapRenderer { // ==================== Lifecycle ==================== init(container: HTMLElement, world: WorldConfig): void { - const worldSizeDeg = world.worldSize / METERS_PER_DEGREE; - - // Register PMTiles protocol (idempotent) - this.registerPMTiles(); - - // Resolve font glyph base URL - const fontsBaseURL = new URL("images/maps/fonts/", window.location.href).href; + this.container = container; + this.worldSizeDeg = world.worldSize / METERS_PER_DEGREE; - // Build style URL - const styleUrl = world.tileBaseUrl - ? `${world.tileBaseUrl}/styles/topo.json` - : undefined; + const centerLng = this.worldSizeDeg / 2; + const centerLat = this.worldSizeDeg / 2; - // transformRequest: rewrite glyph requests to Go server's font endpoint - const transformRequest = (url: string, resourceType?: string) => { - if (resourceType === "Glyphs") { - const match = url.match(/([^/]+)\/(\d+-\d+\.pbf)(?:\?|$)/); - if (match) { - return { url: fontsBaseURL + match[1] + "/" + match[2] }; - } - } - }; - - // Pad the world bounds so the user can't pan into empty space, - // and MapLibre doesn't load/render tiles outside the map area. - const pad = worldSizeDeg * 0.1; - const maxBounds: maplibregl.LngLatBoundsLike = [ - [-pad, -pad], - [worldSizeDeg + pad, worldSizeDeg + pad], - ]; - - // Create MapLibre map (standalone, no Leaflet) - this.map = new maplibregl.Map({ - container, - style: styleUrl ?? { - version: 8, - sources: {}, - layers: [{ id: "background", type: "background", paint: { "background-color": "#1a1a2e" } }], - }, - center: [worldSizeDeg / 2, worldSizeDeg / 2], + this.viewState = { + longitude: centerLng, + latitude: centerLat, zoom: 12, - minZoom: 10, - maxZoom: 20, - maxBounds, - transformRequest, - attributionControl: {}, - // Disable rotation/pitch: 2D icons have no height axis, so tilting - // the camera causes them to clip into the ground or get cut off. - dragRotate: false, - pitchWithRotate: false, - touchPitch: false, - // Don't render repeated world copies — our map is a small area at the equator - renderWorldCopies: false, - // Performance: skip tile cross-fade to reduce GPU draw calls per frame - fadeDuration: 0, - // Performance: cache more parsed tiles to avoid re-parsing on pan/zoom - maxTileCacheSize: 256, - // Performance: skip Resource Timing API entries for tile requests - collectResourceTiming: false, - }); + pitch: 0, + bearing: 0, + }; + this.lastIntZoom = 12; - // Initialize state and overlay + // Initialize state (callbacks wired after deck creation) this.state = new DeckState( () => this.buildLayers(), - (layers) => this.overlay.setProps({ layers }), + (layers) => this.deck.setProps({ layers }), ); - this.overlay = new MapboxOverlay({ + // Create standalone Deck + this.deck = new Deck({ + parent: container as HTMLDivElement, + initialViewState: this.viewState, + controller: { + dragRotate: false, + touchRotate: false, + keyboard: { moveSpeed: 100 }, + minZoom: 10, + maxZoom: 20, + }, + onViewStateChange: ({ viewState }: { viewState: MapViewState }) => { + this.viewState = viewState; + + // Rebuild basemap layers on integer zoom change. + // Use RAF-batched markDirty() instead of synchronous flushNow() + // to avoid blocking the zoom gesture handler. + const intZoom = Math.floor(viewState.zoom); + if (intZoom !== this.lastIntZoom) { + this.lastIntZoom = intZoom; + this.rebuildBasemap(); + this.state.markDirty(); + this.fireEvent("zoom", viewState.zoom); + } + + this.scaleControl?.update(viewState.zoom); + }, + onDragStart: () => { + this.fireEvent("dragstart"); + }, + onClick: (info: any) => { + if (info.coordinate) { + this.fireEvent("click", lngLatToArma([info.coordinate[0], info.coordinate[1]])); + } + }, layers: [], - interleaved: true, + // Use WebGL2 for better performance + useDevicePixels: true, + _animate: true, }); - this.map.addControl(this.overlay as any); - this.map.addControl(new maplibregl.ScaleControl({ unit: "metric" }), "bottom-left"); + // Scale control + this.scaleControl = new ScaleControl(container); + this.scaleControl.update(this.viewState.zoom); // Build icon atlas async, then trigger first render void buildIconAtlas().then((atlas) => { @@ -200,36 +208,70 @@ export class DeckGLRenderer implements MapRenderer { this.state.flushNow(); }); - // Forward map events - this.map.on("zoomend", () => { - this.fireEvent("zoom", this.map.getZoom()); - }); - this.map.on("dragstart", () => { - this.fireEvent("dragstart"); - }); - this.map.on("click", (e) => { - this.fireEvent("click", lngLatToArma([e.lngLat.lng, e.lngLat.lat])); - }); + // Load style and basemap async + this.loadStyle(world); // Fit to world bounds - this.map.fitBounds( - [[0, 0], [worldSizeDeg, worldSizeDeg]] as maplibregl.LngLatBoundsLike, - { animate: false }, + this.fitBoundsInternal( + [0, 0], + [this.worldSizeDeg, this.worldSizeDeg], + false, ); } - private registerPMTiles(): void { - if ((window as any)._pmtilesRegistered) return; - void (async () => { - try { - const { Protocol } = await import("pmtiles"); - const protocol = new Protocol(); - maplibregl.addProtocol("pmtiles", protocol.tile); - (window as any)._pmtilesRegistered = true; - } catch { - // PMTiles not available + private async loadStyle(world: WorldConfig): Promise { + if (!world.tileBaseUrl) return; + + const styleUrl = `${world.tileBaseUrl}/styles/topo.json`; + try { + const resp = await fetch(styleUrl); + if (!resp.ok) return; + const doc = await resp.json(); + this.compiledStyle = parseStyleDocument(doc); + + // Load sprite atlas + if (this.compiledStyle.spriteUrl) { + this.spriteAtlas = await loadSpriteAtlas(this.compiledStyle.spriteUrl); } - })(); + + // Open PMTiles archives for each source and read metadata + for (const [name, source] of Object.entries(this.compiledStyle.sources)) { + if (source.type === "vector" && source.url) { + this.vectorPMTiles = new PMTiles(source.url); + // Create stable getTileData reference once — reused across all + // basemap rebuilds so TileLayer keeps its tile cache. + this.vectorGetTileData = createVectorTileDataFetcher(this.vectorPMTiles); + try { + const header = await this.vectorPMTiles.getHeader(); + this.vectorMaxZoom = header.maxZoom; + } catch { + // Fallback already set + } + } + } + + this.rebuildBasemap(); + this.state.flushNow(); + } catch { + // Style not available — render with empty basemap + } + } + + private rebuildBasemap(): void { + if (!this.compiledStyle) { + this.basemapLayers = []; + return; + } + + this.basemapLayers = buildBasemapLayers({ + compiledStyle: this.compiledStyle, + zoom: this.viewState.zoom, + worldSizeDeg: this.worldSizeDeg, + spriteAtlas: this.spriteAtlas, + vectorPMTiles: this.vectorPMTiles ?? undefined, + vectorMaxZoom: this.vectorMaxZoom, + vectorGetTileData: this.vectorGetTileData ?? undefined, + }); } dispose(): void { @@ -237,15 +279,18 @@ export class DeckGLRenderer implements MapRenderer { this.state.dispose(); } this.listeners.clear(); - if (this.map) { - this.map.remove(); + if (this.scaleControl) { + this.scaleControl.dispose(); + } + if (this.deck) { + this.deck.finalize(); } } // ==================== Layer building ==================== private buildLayers(): Layer[] { - const layers: Layer[] = []; + const layers: Layer[] = [...this.basemapLayers]; const s = this.state; if (s.enabledLayers.has("entities") && this.iconAtlas) { @@ -286,36 +331,78 @@ export class DeckGLRenderer implements MapRenderer { // ==================== Camera ==================== getZoom(): number { - return this.map.getZoom(); + return this.viewState.zoom; } setView(armaPos: ArmaCoord, zoom?: number, animate?: boolean): void { - const center = armaToLngLat(armaPos); + const [lng, lat] = armaToLngLat(armaPos); + const targetZoom = zoom ?? this.viewState.zoom; + if (animate ?? true) { - this.map.flyTo({ - center: center as maplibregl.LngLatLike, - zoom: zoom ?? this.map.getZoom(), - duration: 500, + this.deck.setProps({ + initialViewState: { + ...this.viewState, + longitude: lng, + latitude: lat, + zoom: targetZoom, + transitionDuration: 500, + transitionInterpolator: new FlyToInterpolator(), + }, }); } else { - this.map.jumpTo({ - center: center as maplibregl.LngLatLike, - zoom: zoom ?? this.map.getZoom(), - }); + this.viewState = { ...this.viewState, longitude: lng, latitude: lat, zoom: targetZoom }; + this.deck.setProps({ initialViewState: { ...this.viewState } }); } } fitBounds(sw: ArmaCoord, ne: ArmaCoord): void { const swLngLat = armaToLngLat(sw); const neLngLat = armaToLngLat(ne); - this.map.fitBounds( - [swLngLat, neLngLat] as maplibregl.LngLatBoundsLike, - ); + this.fitBoundsInternal(swLngLat, neLngLat, true); + } + + private fitBoundsInternal( + sw: [number, number], + ne: [number, number], + animate: boolean, + ): void { + const { width, height } = this.getContainerSize(); + if (width === 0 || height === 0) return; + + const viewport = new WebMercatorViewport({ width, height }); + const fitted = viewport.fitBounds([sw, ne], { padding: 20 }); + + if (animate) { + this.deck.setProps({ + initialViewState: { + ...this.viewState, + longitude: fitted.longitude, + latitude: fitted.latitude, + zoom: fitted.zoom, + transitionDuration: 500, + transitionInterpolator: new FlyToInterpolator(), + }, + }); + } else { + this.viewState = { + ...this.viewState, + longitude: fitted.longitude, + latitude: fitted.latitude, + zoom: fitted.zoom, + }; + this.deck.setProps({ initialViewState: { ...this.viewState } }); + } + } + + private getContainerSize(): { width: number; height: number } { + return { + width: this.container?.clientWidth ?? 0, + height: this.container?.clientHeight ?? 0, + }; } getCenter(): ArmaCoord { - const c = this.map.getCenter(); - return lngLatToArma([c.lng, c.lat]); + return lngLatToArma([this.viewState.longitude, this.viewState.latitude]); } // ==================== Entity markers ==================== @@ -644,7 +731,7 @@ export class DeckGLRenderer implements MapRenderer { getControls(): RendererControls { return { - container: this.map?.getContainer(), + container: this.container, }; } } diff --git a/static-next/src/renderers/deckgl/deckgl-scale-control.ts b/static-next/src/renderers/deckgl/deckgl-scale-control.ts new file mode 100644 index 00000000..0c40ceea --- /dev/null +++ b/static-next/src/renderers/deckgl/deckgl-scale-control.ts @@ -0,0 +1,59 @@ +/** + * Minimal HTML scale bar for standalone deck.gl (which has no built-in scale control). + * + * Positioned bottom-left, styled to match the existing dark theme. + */ + +const NICE_DISTANCES = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000]; +const MAX_BAR_WIDTH = 100; // pixels + +export class ScaleControl { + private container: HTMLDivElement; + private bar: HTMLDivElement; + private label: HTMLSpanElement; + + constructor(parent: HTMLElement) { + this.container = document.createElement("div"); + this.container.style.cssText = + "position:absolute;bottom:8px;left:8px;pointer-events:none;z-index:1;"; + + this.bar = document.createElement("div"); + this.bar.style.cssText = + "border:2px solid rgba(255,255,255,0.8);border-top:none;height:6px;background:rgba(0,0,0,0.3);"; + + this.label = document.createElement("span"); + this.label.style.cssText = + "display:block;font:11px/1.2 Arial,sans-serif;color:rgba(255,255,255,0.9);text-shadow:0 0 3px rgba(0,0,0,0.8);margin-bottom:2px;white-space:nowrap;"; + + this.container.appendChild(this.label); + this.container.appendChild(this.bar); + parent.appendChild(this.container); + } + + /** Recompute the scale bar for the given zoom level. */ + update(zoom: number): void { + // At the equator (lat=0), meters per pixel = (circumference) / (2^zoom * tileSize) + const metersPerPixel = (40075016.686) / (Math.pow(2, zoom) * 256); + const maxMeters = metersPerPixel * MAX_BAR_WIDTH; + + // Find the largest "nice" distance that fits + let distance = NICE_DISTANCES[0]; + for (const d of NICE_DISTANCES) { + if (d <= maxMeters) distance = d; + else break; + } + + const barWidth = distance / metersPerPixel; + this.bar.style.width = `${Math.round(barWidth)}px`; + + if (distance >= 1000) { + this.label.textContent = `${distance / 1000} km`; + } else { + this.label.textContent = `${distance} m`; + } + } + + dispose(): void { + this.container.remove(); + } +} diff --git a/static-next/src/renderers/deckgl/deckgl-style-parser.ts b/static-next/src/renderers/deckgl/deckgl-style-parser.ts new file mode 100644 index 00000000..d6dd4042 --- /dev/null +++ b/static-next/src/renderers/deckgl/deckgl-style-parser.ts @@ -0,0 +1,114 @@ +/** + * Parse a MapLibre GL style v8 document into compiled layer definitions + * that can be evaluated by the deck.gl basemap renderer. + */ + +import { compileExpression, compileFilter } from "./deckgl-expressions"; +import type { ExprFn } from "./deckgl-expressions"; + +// --------------- Types --------------- + +export interface CompiledStyle { + background: ExprFn; + sources: Record; + layers: CompiledLayer[]; + spriteUrl: string; +} + +export interface SourceDef { + type: string; + url: string; + tileSize?: number; +} + +export interface CompiledLayer { + id: string; + type: "fill" | "line" | "circle" | "symbol" | "fill-extrusion" | "raster" | "background"; + source: string; + sourceLayer: string; + minZoom: number; + maxZoom: number; + filter?: ExprFn; + paint: Record; + layout: Record; +} + +// --------------- Parser --------------- + +/** + * Parse a MapLibre style document and compile all expressions. + */ +export function parseStyleDocument(doc: any): CompiledStyle { + let background: ExprFn = () => "#DFDFDF"; + + // Sources: strip pmtiles:// prefix, resolve relative URLs + const sources: Record = {}; + if (doc.sources) { + for (const [name, src] of Object.entries(doc.sources)) { + let url = src.url || ""; + if (url.startsWith("pmtiles://")) { + url = url.slice("pmtiles://".length); + } + // Resolve relative URLs to page origin + if (url && !url.startsWith("http")) { + url = new URL(url, window.location.href).href; + } + sources[name] = { + type: src.type, + url, + tileSize: src.tileSize, + }; + } + } + + // Layers: compile in cartographic order (as given) + const layers: CompiledLayer[] = []; + + if (doc.layers) { + for (const layer of doc.layers) { + if (layer.type === "background") { + background = compileExpression(layer.paint?.["background-color"] ?? "#DFDFDF"); + continue; + } + + // Skip layers without a source (e.g. background) + if (!layer.source) continue; + + // Compile paint properties + const paint: Record = {}; + if (layer.paint) { + for (const [key, val] of Object.entries(layer.paint)) { + paint[key] = compileExpression(val); + } + } + + // Compile layout properties + const layout: Record = {}; + if (layer.layout) { + for (const [key, val] of Object.entries(layer.layout)) { + layout[key] = compileExpression(val); + } + } + + layers.push({ + id: layer.id, + type: layer.type, + source: layer.source, + sourceLayer: layer["source-layer"] || "", + minZoom: layer.minzoom ?? 0, + maxZoom: layer.maxzoom ?? 24, + filter: compileFilter(layer.filter), + paint, + layout, + }); + } + } + + // Sprite URL + let spriteUrl = doc.sprite || ""; + if (spriteUrl && !spriteUrl.startsWith("http")) { + spriteUrl = new URL(spriteUrl, window.location.href).href; + } + + return { background, sources, layers, spriteUrl }; +}