From 22ef7bfad7987ba44abd4eec01ca15badf995751 Mon Sep 17 00:00:00 2001 From: Rin <58572875+TurtIeSocks@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:50:07 -0400 Subject: [PATCH 01/24] docs: plan the 2.0 map engine Seven tasks: the source interface and its fixtures, the atlas pipeline, MapLibre for basemap and camera, the deck.gl overlay interleaved so markers sit under street labels, picking with a single popup, clustering with the forcedLimit bug fixed, and WebGL context loss. Five judgement calls are recorded rather than left implicit. Data comes from fixtures behind an interface, since the 2.0 client has no data source at all and transport belongs to a later session. Two layers rather than five, because pokemon is the count problem this rewrite exists for and gyms proves the pattern generalises, while the rest need entity shapes that session has not defined yet. The basemap defaults to a keyless vector source and raster keeps working, so no self-hoster has to go and get an API key. Nothing in the 1.0 client moves. And no per-marker timers get ported, because moving text into layer data removes the choppiness structurally rather than optimising it. Co-Authored-By: Claude Opus 5 --- .../plans/2026-08-24-map-engine.md | 330 ++++++++++++++++++ 1 file changed, 330 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-24-map-engine.md diff --git a/docs/superpowers/plans/2026-08-24-map-engine.md b/docs/superpowers/plans/2026-08-24-map-engine.md new file mode 100644 index 000000000..4d0778678 --- /dev/null +++ b/docs/superpowers/plans/2026-08-24-map-engine.md @@ -0,0 +1,330 @@ +# Map Engine Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the placeholder at `/map` with a real map: MapLibre owning the basemap and camera, deck.gl riding it in interleaved mode, an atlas pipeline that composites marker icons off the main thread, and text rendered as layer data rather than as thousands of React components. + +**Architecture:** MapLibre GL owns the canvas. deck.gl attaches through `MapboxOverlay` in interleaved mode so markers sit beneath street labels. Marker icons are composited on an `OffscreenCanvas`, keyed by the combination that determines their appearance, and cached under an LRU bound. Layer data arrives through a source interface that this plan backs with fixtures; session 3 implements the real transport behind the same interface. + +**Tech Stack:** maplibre-gl 6.5.0, deck.gl 9.3.10, supercluster 8.0.1 (already present), React 19, TypeScript strict. + +## Assumptions + +Five calls made while writing this plan, each stated because a reader could expect otherwise. + +1. **Data comes from fixtures behind an interface, not from a server.** The 2.0 client has no marker data source today; nothing in `app/` fetches map data at all. The spec places transport in session 3 and says plans 1 to 4 do not depend on it, which can only mean the engine is built against an interface and fed fixtures. This plan defines that interface. Session 3 implements it. Getting the interface shape wrong is the main risk here, so it stays deliberately small. +2. **Two layers, not five.** Pokémon, because thousands per viewport is the actual hard problem and the reason for this rewrite, and gyms as a plain `IconLayer` to prove the pattern generalises without the text and clustering machinery. Routes, S2 cells and interaction ranges follow the same shape and land with the features that need them, against entity shapes session 3 defines rather than shapes invented here. +3. **The basemap defaults to a keyless vector source and raster keeps working.** Requiring every self-hoster to get a MapTiler key would be a regression from today's configurable raster `tileServers`. MapLibre renders raster natively, so existing entries keep working through the same config field. +4. **1.0's map is untouched.** `src/` keeps Leaflet, react-leaflet and every marker component. Nothing here changes what current users are served, and the per-user shell flag stays off. +5. **No timers are ported.** The measured "reallllly choppy" behaviour is thousands of React components each re-rendering on a 1 Hz interval. Moving text into `TextLayer` data eliminates that structurally rather than optimising it, so there is no per-marker timer component to port and none should be written. + +## Global Constraints + +- Nothing under `src/` or `server/` changes. +- `app/` is strict TypeScript with `exactOptionalPropertyTypes`. The vendored shadcn components are patched to satisfy it; new code must satisfy it too. +- Exactly ONE dark mechanism, the `prefers-color-scheme` media query. No `.dark` class variant; it has been stripped from this branch three times. +- Map entity colours come from `app/tokens/data-palette.css` and never from the brand accent. That file is a language about map data and a test enforces its separation, including by literal value. +- Any new colour pair needs its WCAG contrast computed in both themes. No gate in this repo can see contrast; a green suite proves nothing about it. +- Component tests scope queries to their own render container and clean up after each test. +- Do not add a `preload` to `bunfig.toml`. It is process-wide and broke three Node-side suites. +- Prose in commits carries no em dashes, no bold, no inline bulleted headers, and never refers to the maintainer by name. +- The pre-commit hook runs `biome check` and `tsc -p tsconfig.app.json --noEmit` and blocks on either. + +--- + +## File Structure + +``` +app/ + map/ + types.ts the source interface, entity shapes, viewport + source.ts fixture-backed implementation of that interface + fixtures.ts generated entities, enough to exercise the count problem + atlas.ts composite key, OffscreenCanvas draw, LRU cache + atlas.test.ts keying and eviction, the silent-failure surface + useMapLibre.ts map instance, camera, deep-link sync + layers.ts deck.gl layer construction from source data + MapCanvas.tsx the map itself, overlay attached + Popup.tsx the single React popup, positioned from a picked coordinate + pages/ + MapPage.tsx renders MapCanvas instead of a placeholder +``` + +--- + +## Task 1: The source interface and its fixtures + +**Files:** +- Create: `app/map/types.ts`, `app/map/fixtures.ts`, `app/map/source.ts` +- Test: `app/map/source.test.ts` + +**Interfaces:** +- Produces: `MapSource`, consumed by every later task and implemented for real in session 3. + +This comes first because everything else depends on its shape, and because getting it wrong is the expensive mistake in this plan. + +- [ ] **Step 1: Write the failing test** + +Create `app/map/source.test.ts`: + +```ts +import { expect, test } from 'bun:test' +import { createFixtureSource } from './source' + +test('returns only entities inside the requested bounds', async () => { + const source = createFixtureSource() + const inside = await source.query({ + kind: 'pokemon', + bounds: { west: -0.1, south: 51.5, east: 0.1, north: 51.6 }, + zoom: 15, + }) + expect(inside.length).toBeGreaterThan(0) + for (const entity of inside) { + expect(entity.lon).toBeGreaterThanOrEqual(-0.1) + expect(entity.lon).toBeLessThanOrEqual(0.1) + expect(entity.lat).toBeGreaterThanOrEqual(51.5) + expect(entity.lat).toBeLessThanOrEqual(51.6) + } +}) + +test('produces enough pokemon to exercise the count problem', async () => { + const source = createFixtureSource() + const all = await source.query({ + kind: 'pokemon', + bounds: { west: -1, south: 51, east: 1, north: 52 }, + zoom: 12, + }) + expect(all.length).toBeGreaterThanOrEqual(3000) +}) +``` + +- [ ] **Step 2: Run it to verify it fails** + +```bash +bun test app/map/source.test.ts +``` + +Expected: FAIL, cannot find `./source`. + +- [ ] **Step 3: Define the types** + +`app/map/types.ts` holds a `Bounds`, a `MapQuery` carrying kind, bounds and zoom, an entity union, and: + +```ts +export interface MapSource { + query(request: MapQuery): Promise +} +``` + +The pokemon entity needs whatever the atlas keys on: id, form, costume, gender, plus lat, lon, an expiry timestamp, and optional iv, level and size. The gym entity needs id, lat, lon, team and an in-battle flag. Keep both minimal. Every field added here is a field session 3 has to honour. + +- [ ] **Step 4: Write the fixtures and the source** + +Generate deterministically, seeded, so tests and visual checks are reproducible. Three thousand pokemon is the floor the test asserts, since the whole point of this engine is the count problem and a fixture set of thirty proves nothing. + +- [ ] **Step 5: Run the tests to verify they pass** + +Expected: PASS, 2 tests. + +- [ ] **Step 6: Commit** + +```bash +git add app/map/ && git commit -m "feat(map): define the source interface and its fixtures" +``` + +--- + +## Task 2: The atlas pipeline + +**Files:** +- Create: `app/map/atlas.ts` +- Test: `app/map/atlas.test.ts` + +**Interfaces:** +- Consumes: the pokemon entity from Task 1. +- Produces: `getIconFor(entity)` returning what `IconLayer`'s `getIcon` needs. + +The spec calls this out as one of four things that genuinely need tests, because both failure modes are silent: a colliding key renders the wrong icon, an over-specific key destroys the hit rate. + +- [ ] **Step 1: Write the failing test** + +Cover the two silent failures directly. Two entities differing in any appearance-determining field must produce different keys. Two entities differing only in a field that does not affect appearance, such as position or expiry, must produce the SAME key. Then assert the cache evicts under its bound rather than growing without limit. + +- [ ] **Step 2: Run it to verify it fails** + +Expected: FAIL, cannot find `./atlas`. + +- [ ] **Step 3: Implement the key and the cache** + +The key is the combination the spec names: pokemon id, form, costume, gender, badges, background, weather. The cache is LRU with an explicit bound. `OffscreenCanvas` is not available in the test environment, so structure the module so the key function and the cache are testable without it and the drawing is injected. That separation is the point: keying is what has silent failure modes, and drawing is what needs a browser. + +- [ ] **Step 4: Run the tests to verify they pass** + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add app/map/ && git commit -m "feat(map): composite marker icons behind an lru cache" +``` + +--- + +## Task 3: MapLibre basemap and camera + +**Files:** +- Create: `app/map/useMapLibre.ts`, `app/map/MapCanvas.tsx` +- Modify: `app/pages/MapPage.tsx`, `package.json` + +- [ ] **Step 1: Install** + +```bash +bun add maplibre-gl +``` + +Verified at 6.5.0. Note `[install] exact = true`; do not add carets by hand. + +- [ ] **Step 2: Render a map** + +`MapCanvas` mounts a MapLibre instance into a container sized to the viewport minus the bottom nav. Default to a keyless vector style. Import MapLibre's stylesheet; without it the canvas renders but every control is unstyled and positioning breaks. + +- [ ] **Step 3: Wire the deep link** + +`/@/:lat/:lon/:zoom` already exists in 1.0 and those links are in the wild. The 2.0 route table owns `/map`; decide deliberately how a deep link reaches it and say what you chose. Camera changes should update the URL without pushing a history entry per frame. + +- [ ] **Step 4: Confirm it builds and the route still lazy-loads** + +```bash +bun run build && ls dist/ | grep -c MapPage +``` + +Expected: non-zero, and MapLibre must land in the map route's chunk or a shared vendor chunk, NOT in the entry. The whole point of lazy routes is that a visitor to the hub does not download a mapping library. Check which chunk grew. + +- [ ] **Step 5: Commit** + +```bash +git add app/ package.json bun.lock && git commit -m "feat(map): render the basemap and sync the camera" +``` + +--- + +## Task 4: The deck.gl overlay, interleaved + +**Files:** +- Create: `app/map/layers.ts` +- Modify: `app/map/MapCanvas.tsx`, `package.json` + +- [ ] **Step 1: Install** + +```bash +bun add deck.gl @deck.gl/mapbox @deck.gl/layers @deck.gl/core +``` + +All verified at 9.3.10. + +- [ ] **Step 2: Attach the overlay** + +`MapboxOverlay` with `interleaved: true`. Interleaved is not cosmetic: it is what puts markers beneath street labels instead of over them, and it is the reason this rides MapLibre rather than replacing it. + +- [ ] **Step 3: Build the pokemon and gym layers** + +`IconLayer` for both, `TextLayer` for pokemon labels. Icons come from Task 2's atlas. Gym colours come from the data palette's team tokens, read at runtime; those tokens ship only because their block is marked static, and a value read here that resolves to nothing renders a colourless marker without erroring. + +- [ ] **Step 4: Verify the interleaving actually happened** + +A screenshot is not available, so assert on the overlay's configuration and say plainly that visual confirmation is outstanding. Do not claim markers sit under labels without having seen it. + +- [ ] **Step 5: Run everything and commit** + +```bash +bun test && bun run typecheck && bun run lint && bun run build +``` + +--- + +## Task 5: Picking and the single popup + +**Files:** +- Create: `app/map/Popup.tsx` +- Modify: `app/map/MapCanvas.tsx` + +- [ ] **Step 1: Wire picking** + +deck.gl's picking replaces marker refs and Leaflet's popup events. One popup exists at a time, positioned by projecting the picked coordinate through the camera. + +- [ ] **Step 2: Keep it correct while the camera moves** + +A popup anchored to a coordinate must follow that coordinate on pan and zoom. Decide whether it reprojects per frame or rides a MapLibre marker, and say which and why. + +- [ ] **Step 3: Test what is testable without a GPU** + +Picking needs a real WebGL context. Test the popup component itself with a fixed projected position, and the selection state transitions. State plainly what needs a browser. + +- [ ] **Step 4: Commit** + +--- + +## Task 6: Clustering, and fix `forcedLimit` + +**Files:** +- Modify: `app/map/layers.ts` +- Test: `app/map/clustering.test.ts` + +- [ ] **Step 1: Write the failing test** + +The spec records a real bug: `forcedLimit` is supposed to bound how many markers render, and today it builds a clusterer that declines to cluster above `maxZoom`, so the bound does not bind. Read the 1.0 implementation in `src/` first to understand what it does, then write a test asserting the count actually stays under the limit at high zoom with more entities than the limit. + +- [ ] **Step 2: Run it to verify it fails against the ported behaviour** + +If it passes immediately, you have ported the bug rather than the behaviour, or the test does not reach the path. Investigate before proceeding. + +- [ ] **Step 3: Fix it and make the test pass** + +- [ ] **Step 4: Commit** + +--- + +## Task 7: WebGL context loss + +**Files:** +- Modify: `app/map/MapCanvas.tsx`, `app/map/atlas.ts` + +New surface, most likely to bite on iOS under memory pressure, and invisible until it happens to a user. + +- [ ] **Step 1: Handle the events** + +`webglcontextlost` and `webglcontextrestored`. On loss, show a visible restoring state rather than a blank canvas. On restore, rebuild layers and re-warm the atlas cache. + +- [ ] **Step 2: Test the state machine, not the GPU** + +Context loss can be simulated by dispatching the events. Assert the restoring state appears and clears, and that the atlas is asked to rebuild. Do not attempt to actually lose a real context in a test. + +- [ ] **Step 3: Run everything and commit** + +--- + +## Done criteria + +```bash +bun test +bun run typecheck +bun run lint +bun run build +``` + +All four succeed. Then: + +- `/map` renders a real basemap with markers drawn from fixtures. +- MapLibre and deck.gl are in the map route's chunk or a vendor chunk, not the entry chunk. +- The atlas produces the same key for entities that look the same and different keys for entities that do not, and evicts under its bound. +- Clustering keeps the rendered count under `forcedLimit` at high zoom. +- `dist/index.html` and `dist/app.html` still load different stylesheets with `@layer base` absent from the 1.0 one. +- Nothing under `src/` or `server/` changed. + +## What this plan does not do + +- No real data. Every entity comes from fixtures behind the source interface. Session 3 implements the transport. +- Only pokemon and gyms. Routes, S2 cells, scan areas, submission rings and interaction ranges land with their features. +- No filters. Which entities are requested is Plan 5's question, and the rules model behind it is session 2's. +- No 1.0 changes. Leaflet stays exactly where it is. +- No visual confirmation. Nothing here has been seen rendered; every claim is structural until someone opens it in a browser. From 55b2000c8e14e59b428f610f84c52f1a42f49bb4 Mon Sep 17 00:00:00 2001 From: Rin <58572875+TurtIeSocks@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:51:51 -0400 Subject: [PATCH 02/24] feat(map): define the source interface and its fixtures --- app/map/fixtures.ts | 97 ++++++++++++++++++++++++++++++++++++++++++ app/map/source.test.ts | 28 ++++++++++++ app/map/source.ts | 28 ++++++++++++ app/map/types.ts | 61 ++++++++++++++++++++++++++ 4 files changed, 214 insertions(+) create mode 100644 app/map/fixtures.ts create mode 100644 app/map/source.test.ts create mode 100644 app/map/source.ts create mode 100644 app/map/types.ts diff --git a/app/map/fixtures.ts b/app/map/fixtures.ts new file mode 100644 index 000000000..3886e2e09 --- /dev/null +++ b/app/map/fixtures.ts @@ -0,0 +1,97 @@ +import type { GymEntity, PokemonEntity } from './types' + +/** + * The area every fixture is scattered across. Chosen to comfortably + * contain both the wide and narrow bounding boxes exercised by + * source.test.ts. + */ +const FIXTURE_AREA = { west: -1, south: 51, east: 1, north: 52 } + +/** + * 3000 is the floor the test asserts: the whole reason for this engine + * is that rendering past three thousand markers goes choppy, so the + * fixture set has to clear that bar with room to spare rather than + * exactly hit it. + */ +const POKEMON_COUNT = 5000 +const GYM_COUNT = 100 + +const SEED = 20260824 + +/** Deterministic PRNG (mulberry32) so fixtures reproduce across runs. */ +function createRng(seed: number) { + let state = seed + return () => { + state = (state + 0x6d2b79f5) | 0 + let t = state + t = Math.imul(t ^ (t >>> 15), t | 1) + t ^= t + Math.imul(t ^ (t >>> 7), t | 61) + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +function randomInRange(rng: () => number, min: number, max: number): number { + return min + rng() * (max - min) +} + +function randomInt(rng: () => number, min: number, max: number): number { + return Math.floor(randomInRange(rng, min, max + 1)) +} + +function buildPokemon(rng: () => number, index: number): PokemonEntity { + const now = Date.now() + const entity: PokemonEntity = { + kind: 'pokemon', + id: `pokemon-${index}`, + pokemonId: randomInt(rng, 1, 493), + form: randomInt(rng, 0, 3), + costume: randomInt(rng, 0, 2), + gender: randomInt(rng, 1, 3), + lat: randomInRange(rng, FIXTURE_AREA.south, FIXTURE_AREA.north), + lon: randomInRange(rng, FIXTURE_AREA.west, FIXTURE_AREA.east), + expiresAt: now + randomInt(rng, 60, 1800) * 1000, + } + if (rng() < 0.05) entity.badge = randomInt(rng, 1, 3) + if (rng() < 0.1) entity.background = randomInt(rng, 1, 5) + if (rng() < 0.15) entity.weather = randomInt(rng, 1, 7) + if (rng() < 0.6) entity.iv = randomInt(rng, 0, 100) + if (rng() < 0.6) entity.level = randomInt(rng, 1, 35) + if (rng() < 0.4) entity.size = randomInt(rng, 1, 5) + return entity +} + +function buildGym(rng: () => number, index: number): GymEntity { + return { + kind: 'gym', + id: `gym-${index}`, + lat: randomInRange(rng, FIXTURE_AREA.south, FIXTURE_AREA.north), + lon: randomInRange(rng, FIXTURE_AREA.west, FIXTURE_AREA.east), + team: randomInt(rng, 0, 3), + inBattle: rng() < 0.1, + } +} + +let cachedPokemon: PokemonEntity[] | undefined +let cachedGyms: GymEntity[] | undefined + +export function getFixturePokemon(): PokemonEntity[] { + if (!cachedPokemon) { + const rng = createRng(SEED) + cachedPokemon = Array.from({ length: POKEMON_COUNT }, (_, index) => + buildPokemon(rng, index), + ) + } + return cachedPokemon +} + +export function getFixtureGyms(): GymEntity[] { + if (!cachedGyms) { + // Separate seed offset so gym placement doesn't consume the same + // PRNG stream as pokemon and shift its sequence. + const rng = createRng(SEED + 1) + cachedGyms = Array.from({ length: GYM_COUNT }, (_, index) => + buildGym(rng, index), + ) + } + return cachedGyms +} diff --git a/app/map/source.test.ts b/app/map/source.test.ts new file mode 100644 index 000000000..6b73b1e2f --- /dev/null +++ b/app/map/source.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from 'bun:test' +import { createFixtureSource } from './source' + +test('returns only entities inside the requested bounds', async () => { + const source = createFixtureSource() + const inside = await source.query({ + kind: 'pokemon', + bounds: { west: -0.1, south: 51.5, east: 0.1, north: 51.6 }, + zoom: 15, + }) + expect(inside.length).toBeGreaterThan(0) + for (const entity of inside) { + expect(entity.lon).toBeGreaterThanOrEqual(-0.1) + expect(entity.lon).toBeLessThanOrEqual(0.1) + expect(entity.lat).toBeGreaterThanOrEqual(51.5) + expect(entity.lat).toBeLessThanOrEqual(51.6) + } +}) + +test('produces enough pokemon to exercise the count problem', async () => { + const source = createFixtureSource() + const all = await source.query({ + kind: 'pokemon', + bounds: { west: -1, south: 51, east: 1, north: 52 }, + zoom: 12, + }) + expect(all.length).toBeGreaterThanOrEqual(3000) +}) diff --git a/app/map/source.ts b/app/map/source.ts new file mode 100644 index 000000000..8c1d5f355 --- /dev/null +++ b/app/map/source.ts @@ -0,0 +1,28 @@ +import { getFixtureGyms, getFixturePokemon } from './fixtures' +import type { Bounds, MapEntity, MapQuery, MapSource } from './types' + +function isInside(bounds: Bounds, lat: number, lon: number): boolean { + return ( + lat >= bounds.south && + lat <= bounds.north && + lon >= bounds.west && + lon <= bounds.east + ) +} + +/** + * A MapSource backed by deterministic in-memory fixtures. Stands in for + * the real WebSocket-backed source a later session implements against + * the same interface. + */ +export function createFixtureSource(): MapSource { + return { + async query(request: MapQuery): Promise { + const entities = + request.kind === 'pokemon' ? getFixturePokemon() : getFixtureGyms() + return entities.filter((entity) => + isInside(request.bounds, entity.lat, entity.lon), + ) + }, + } +} diff --git a/app/map/types.ts b/app/map/types.ts new file mode 100644 index 000000000..19905dc2d --- /dev/null +++ b/app/map/types.ts @@ -0,0 +1,61 @@ +/** + * The map source interface. A later session implements this for real + * against a WebSocket transport; every task in this plan is written + * against fixtures behind the same shape. + */ + +export interface Bounds { + west: number + south: number + east: number + north: number +} + +export type MapEntityKind = 'pokemon' | 'gym' + +export interface MapQuery { + kind: MapEntityKind + bounds: Bounds + zoom: number +} + +/** + * Fields carried here are exactly what determines the icon (id, form, + * costume, gender, badges, background, weather), what's needed to place + * and expire the marker (lat, lon, expiresAt), and the optional stats + * rendered as marker text (iv, level, size). Nothing speculative: a + * field added later is cheap, one removed after something depends on it + * is not. + */ +export interface PokemonEntity { + kind: 'pokemon' + id: string + pokemonId: number + form: number + costume: number + gender: number + badge?: number + background?: number + weather?: number + lat: number + lon: number + expiresAt: number + iv?: number + level?: number + size?: number +} + +export interface GymEntity { + kind: 'gym' + id: string + lat: number + lon: number + team: number + inBattle: boolean +} + +export type MapEntity = PokemonEntity | GymEntity + +export interface MapSource { + query(request: MapQuery): Promise +} From 0eb934c8cf9289b22b8e0c23ef414c783e13b235 Mon Sep 17 00:00:00 2001 From: Rin <58572875+TurtIeSocks@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:57:37 -0400 Subject: [PATCH 03/24] docs: name the right id field in the atlas task The task said to key the icon cache on pokemon id, while the entity type carries both an id that is unique per spawn and a pokemonId that is the species. Reaching for the first gives a cache entry per marker, so it never hits, the atlas repacks constantly, and the map is as choppy as the version this replaces. Every test would still pass. Co-Authored-By: Claude Opus 5 --- docs/superpowers/plans/2026-08-24-map-engine.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-08-24-map-engine.md b/docs/superpowers/plans/2026-08-24-map-engine.md index 4d0778678..0d4fbece5 100644 --- a/docs/superpowers/plans/2026-08-24-map-engine.md +++ b/docs/superpowers/plans/2026-08-24-map-engine.md @@ -156,7 +156,13 @@ Expected: FAIL, cannot find `./atlas`. - [ ] **Step 3: Implement the key and the cache** -The key is the combination the spec names: pokemon id, form, costume, gender, badges, background, weather. The cache is LRU with an explicit bound. `OffscreenCanvas` is not available in the test environment, so structure the module so the key function and the cache are testable without it and the drawing is injected. That separation is the point: keying is what has silent failure modes, and drawing is what needs a browser. +The key is the combination that determines appearance: **`pokemonId` (the species number), NOT `id`**, plus form, costume, gender, badge, background and weather. + +That distinction is the whole task. `PokemonEntity` carries both an `id`, which is unique per spawn, +and a `pokemonId`, which is the species. Keying on `id` produces a cache entry per marker, so the +cache never hits once, the atlas repacks constantly, and the rendering goes exactly as choppy as the +Leaflet version this rewrite exists to replace. Every test would still pass. Read the field names in +`app/map/types.ts` rather than trusting this sentence. The cache is LRU with an explicit bound. `OffscreenCanvas` is not available in the test environment, so structure the module so the key function and the cache are testable without it and the drawing is injected. That separation is the point: keying is what has silent failure modes, and drawing is what needs a browser. - [ ] **Step 4: Run the tests to verify they pass** From a4dc45f67af51fad1b0ec0435acd68010e598948 Mon Sep 17 00:00:00 2001 From: Rin <58572875+TurtIeSocks@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:00:49 -0400 Subject: [PATCH 04/24] refactor(map): make subscription the primary map source operation The real transport a later session builds pushes incremental change rather than answering snapshot questions, so MapSource now exposes subscribe(request, onChange) returning an unsubscribe. Every consumer built on top of this is written against push semantics from the start and has somewhere to receive updates without being rewritten. query() is gone from the interface and lives as queryOnce() in query.ts, which reads the push stream once. One method to implement means one code path to get wrong. No change detection, diff or delta type is introduced here; the wire format belongs to the session that owns the transport. --- app/map/query.ts | 29 +++++++++++++++++++++++++ app/map/source.test.ts | 48 ++++++++++++++++++++++++++++++++++++++++-- app/map/source.ts | 32 ++++++++++++++++++++++------ app/map/types.ts | 14 +++++++++++- 4 files changed, 113 insertions(+), 10 deletions(-) create mode 100644 app/map/query.ts diff --git a/app/map/query.ts b/app/map/query.ts new file mode 100644 index 000000000..30c894487 --- /dev/null +++ b/app/map/query.ts @@ -0,0 +1,29 @@ +import type { MapEntity, MapQuery, MapSource, Unsubscribe } from './types' + +/** + * Reads a source once: takes the first delivered set and unsubscribes. + * + * A source may deliver that first set synchronously from inside + * `subscribe`, before it has returned the unsubscribe function, so the + * handler cannot always call it. The flag covers that case by + * unsubscribing once the function is in hand. + */ +export function queryOnce( + source: MapSource, + request: MapQuery, +): Promise { + return new Promise((resolve) => { + let settled = false + let unsubscribe: Unsubscribe | undefined + + const stop = source.subscribe(request, (entities) => { + if (settled) return + settled = true + resolve(entities) + unsubscribe?.() + }) + + unsubscribe = stop + if (settled) stop() + }) +} diff --git a/app/map/source.test.ts b/app/map/source.test.ts index 6b73b1e2f..652546133 100644 --- a/app/map/source.test.ts +++ b/app/map/source.test.ts @@ -1,9 +1,11 @@ import { expect, test } from 'bun:test' +import { queryOnce } from './query' import { createFixtureSource } from './source' +import type { MapChangeHandler, MapEntity, MapSource } from './types' test('returns only entities inside the requested bounds', async () => { const source = createFixtureSource() - const inside = await source.query({ + const inside = await queryOnce(source, { kind: 'pokemon', bounds: { west: -0.1, south: 51.5, east: 0.1, north: 51.6 }, zoom: 15, @@ -19,10 +21,52 @@ test('returns only entities inside the requested bounds', async () => { test('produces enough pokemon to exercise the count problem', async () => { const source = createFixtureSource() - const all = await source.query({ + const all = await queryOnce(source, { kind: 'pokemon', bounds: { west: -1, south: 51, east: 1, north: 52 }, zoom: 12, }) expect(all.length).toBeGreaterThanOrEqual(3000) }) + +test('subscribe delivers the current set and returns an unsubscribe', () => { + const source = createFixtureSource() + const deliveries: MapEntity[][] = [] + const unsubscribe = source.subscribe( + { + kind: 'gym', + bounds: { west: -1, south: 51, east: 1, north: 52 }, + zoom: 12, + }, + (entities) => deliveries.push(entities), + ) + + expect(deliveries).toHaveLength(1) + expect(deliveries[0]?.length).toBeGreaterThan(0) + expect(typeof unsubscribe).toBe('function') + expect(() => unsubscribe()).not.toThrow() +}) + +test('queryOnce stops listening after the first delivery', async () => { + let deliveries = 0 + let stopped = false + const source: MapSource = { + subscribe(_request, onChange: MapChangeHandler) { + deliveries += 1 + onChange([]) + return () => { + stopped = true + } + }, + } + + const result = await queryOnce(source, { + kind: 'pokemon', + bounds: { west: -1, south: 51, east: 1, north: 52 }, + zoom: 12, + }) + + expect(result).toEqual([]) + expect(deliveries).toBe(1) + expect(stopped).toBe(true) +}) diff --git a/app/map/source.ts b/app/map/source.ts index 8c1d5f355..f0d09344c 100644 --- a/app/map/source.ts +++ b/app/map/source.ts @@ -1,5 +1,12 @@ import { getFixtureGyms, getFixturePokemon } from './fixtures' -import type { Bounds, MapEntity, MapQuery, MapSource } from './types' +import type { + Bounds, + MapChangeHandler, + MapEntity, + MapQuery, + MapSource, + Unsubscribe, +} from './types' function isInside(bounds: Bounds, lat: number, lon: number): boolean { return ( @@ -10,19 +17,30 @@ function isInside(bounds: Bounds, lat: number, lon: number): boolean { ) } +function matching(request: MapQuery): MapEntity[] { + const entities = + request.kind === 'pokemon' ? getFixturePokemon() : getFixtureGyms() + return entities.filter((entity) => + isInside(request.bounds, entity.lat, entity.lon), + ) +} + /** * A MapSource backed by deterministic in-memory fixtures. Stands in for * the real WebSocket-backed source a later session implements against * the same interface. + * + * Fixtures never change, so `subscribe` delivers the matching set once + * and hands back an unsubscribe that has nothing to undo. That is the + * point: consumers are written against push semantics from the start, + * so the real transport has somewhere to deliver updates without any + * of them being rewritten. */ export function createFixtureSource(): MapSource { return { - async query(request: MapQuery): Promise { - const entities = - request.kind === 'pokemon' ? getFixturePokemon() : getFixtureGyms() - return entities.filter((entity) => - isInside(request.bounds, entity.lat, entity.lon), - ) + subscribe(request: MapQuery, onChange: MapChangeHandler): Unsubscribe { + onChange(matching(request)) + return () => undefined }, } } diff --git a/app/map/types.ts b/app/map/types.ts index 19905dc2d..b13edfbea 100644 --- a/app/map/types.ts +++ b/app/map/types.ts @@ -56,6 +56,18 @@ export interface GymEntity { export type MapEntity = PokemonEntity | GymEntity +/** Called with the full current set, then again whenever it changes. */ +export type MapChangeHandler = (entities: MapEntity[]) => void + +/** Returned by subscribe; calling it stops delivery. */ +export type Unsubscribe = () => void + +/** + * Subscription is the only operation because the real transport pushes. + * A caller that wants a single answer uses `queryOnce` from ./query, + * which is that push stream read once, so there is one code path to + * implement and one to get wrong. + */ export interface MapSource { - query(request: MapQuery): Promise + subscribe(request: MapQuery, onChange: MapChangeHandler): Unsubscribe } From 43395b38df466951197296f3f4df318cd597352b Mon Sep 17 00:00:00 2001 From: Rin <58572875+TurtIeSocks@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:01:19 -0400 Subject: [PATCH 05/24] refactor(map): rename entity ids so species cannot be misread PokemonEntity carried both id, unique per spawn, and pokemonId, the species. Anything caching by appearance has to key on the species, and keying on the per-spawn id instead gives one cache entry per marker, never hits, and leaves every test passing while the map is as choppy as the version this replaces. The names were one word apart. id is now spawnId, and GymEntity.id is gymId for the same reason: no field named plainly "id" is left for a reader to guess at. A test pins the distinction, asserting spawnId is unique per entity while the species count stays far below the spawn count. --- app/map/fixtures.ts | 4 ++-- app/map/source.test.ts | 18 ++++++++++++++++++ app/map/types.ts | 20 ++++++++++++-------- 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/app/map/fixtures.ts b/app/map/fixtures.ts index 3886e2e09..5b4e56e06 100644 --- a/app/map/fixtures.ts +++ b/app/map/fixtures.ts @@ -42,7 +42,7 @@ function buildPokemon(rng: () => number, index: number): PokemonEntity { const now = Date.now() const entity: PokemonEntity = { kind: 'pokemon', - id: `pokemon-${index}`, + spawnId: `pokemon-${index}`, pokemonId: randomInt(rng, 1, 493), form: randomInt(rng, 0, 3), costume: randomInt(rng, 0, 2), @@ -63,7 +63,7 @@ function buildPokemon(rng: () => number, index: number): PokemonEntity { function buildGym(rng: () => number, index: number): GymEntity { return { kind: 'gym', - id: `gym-${index}`, + gymId: `gym-${index}`, lat: randomInRange(rng, FIXTURE_AREA.south, FIXTURE_AREA.north), lon: randomInRange(rng, FIXTURE_AREA.west, FIXTURE_AREA.east), team: randomInt(rng, 0, 3), diff --git a/app/map/source.test.ts b/app/map/source.test.ts index 652546133..01e03d17b 100644 --- a/app/map/source.test.ts +++ b/app/map/source.test.ts @@ -70,3 +70,21 @@ test('queryOnce stops listening after the first delivery', async () => { expect(deliveries).toBe(1) expect(stopped).toBe(true) }) + +test('spawnId is unique per entity while pokemonId repeats across species', async () => { + const source = createFixtureSource() + const all = await queryOnce(source, { + kind: 'pokemon', + bounds: { west: -1, south: 51, east: 1, north: 52 }, + zoom: 12, + }) + const pokemon = all.filter((entity) => entity.kind === 'pokemon') + + const spawnIds = new Set(pokemon.map((entity) => entity.spawnId)) + const speciesIds = new Set(pokemon.map((entity) => entity.pokemonId)) + + expect(spawnIds.size).toBe(pokemon.length) + // Anything caching by appearance must key on pokemonId: there are far + // fewer species than spawns, which is the whole point of the cache. + expect(speciesIds.size).toBeLessThan(pokemon.length / 2) +}) diff --git a/app/map/types.ts b/app/map/types.ts index b13edfbea..0a6ddf2ad 100644 --- a/app/map/types.ts +++ b/app/map/types.ts @@ -20,16 +20,20 @@ export interface MapQuery { } /** - * Fields carried here are exactly what determines the icon (id, form, - * costume, gender, badges, background, weather), what's needed to place - * and expire the marker (lat, lon, expiresAt), and the optional stats - * rendered as marker text (iv, level, size). Nothing speculative: a - * field added later is cheap, one removed after something depends on it - * is not. + * Fields carried here are exactly what determines the icon (pokemonId, + * form, costume, gender, badges, background, weather), what's needed to + * place and expire the marker (lat, lon, expiresAt), and the optional + * stats rendered as marker text (iv, level, size). Nothing speculative: + * a field added later is cheap, one removed after something depends on + * it is not. + * + * `spawnId` identifies one encounter and changes every time the pokemon + * respawns, so it is useless as a cache key. `pokemonId` is the species + * and is what anything keyed on appearance must use. */ export interface PokemonEntity { kind: 'pokemon' - id: string + spawnId: string pokemonId: number form: number costume: number @@ -47,7 +51,7 @@ export interface PokemonEntity { export interface GymEntity { kind: 'gym' - id: string + gymId: string lat: number lon: number team: number From 073975df0f7ed838961b651b37f615506648721c Mon Sep 17 00:00:00 2001 From: Rin <58572875+TurtIeSocks@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:01:52 -0400 Subject: [PATCH 06/24] fix(map): derive fixture expiry from a fixed epoch, not the clock buildPokemon called Date.now(), so expiresAt differed on every entity between runs: measured 5000 of 5000 pokemon differing across two processes, on that field alone. Nothing asserts on expiresAt today, so the suite said nothing, but a countdown that changes on every reload is exactly what seeded fixtures exist to prevent. Expiry is now offset from a fixed epoch, so a seed fully determines the output. The cached getters return the same array identity, which would make a determinism test vacuous, so generateFixturePokemon and generateFixtureGyms build a fresh set and the new tests compare two independent generations rather than trusting the seed is honoured. --- app/map/fixtures.test.ts | 29 +++++++++++++++++++++++++ app/map/fixtures.ts | 46 ++++++++++++++++++++++++++-------------- 2 files changed, 59 insertions(+), 16 deletions(-) create mode 100644 app/map/fixtures.test.ts diff --git a/app/map/fixtures.test.ts b/app/map/fixtures.test.ts new file mode 100644 index 000000000..d9948ae03 --- /dev/null +++ b/app/map/fixtures.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from 'bun:test' +import { generateFixtureGyms, generateFixturePokemon } from './fixtures' + +test('regenerating pokemon fixtures produces byte-identical entities', () => { + const first = generateFixturePokemon() + const second = generateFixturePokemon() + + expect(first).not.toBe(second) + expect(second).toEqual(first) +}) + +test('regenerating gym fixtures produces byte-identical entities', () => { + const first = generateFixtureGyms() + const second = generateFixtureGyms() + + expect(first).not.toBe(second) + expect(second).toEqual(first) +}) + +test('expiry timestamps do not move with wall-clock time', async () => { + const first = generateFixturePokemon() + await new Promise((resolve) => setTimeout(resolve, 25)) + const second = generateFixturePokemon() + + const moved = first.filter( + (entity, index) => entity.expiresAt !== second[index]?.expiresAt, + ) + expect(moved).toHaveLength(0) +}) diff --git a/app/map/fixtures.ts b/app/map/fixtures.ts index 5b4e56e06..7e44cd5a7 100644 --- a/app/map/fixtures.ts +++ b/app/map/fixtures.ts @@ -18,6 +18,14 @@ const GYM_COUNT = 100 const SEED = 20260824 +/** + * A fixed point in time that expiry is measured from. Date.now() here + * would make every expiresAt differ between runs, which defeats the + * reason these fixtures are seeded at all: a countdown that changes on + * every reload cannot be compared against a screenshot from yesterday. + */ +const FIXTURE_EPOCH = Date.UTC(2026, 7, 24) + /** Deterministic PRNG (mulberry32) so fixtures reproduce across runs. */ function createRng(seed: number) { let state = seed @@ -39,7 +47,6 @@ function randomInt(rng: () => number, min: number, max: number): number { } function buildPokemon(rng: () => number, index: number): PokemonEntity { - const now = Date.now() const entity: PokemonEntity = { kind: 'pokemon', spawnId: `pokemon-${index}`, @@ -49,7 +56,7 @@ function buildPokemon(rng: () => number, index: number): PokemonEntity { gender: randomInt(rng, 1, 3), lat: randomInRange(rng, FIXTURE_AREA.south, FIXTURE_AREA.north), lon: randomInRange(rng, FIXTURE_AREA.west, FIXTURE_AREA.east), - expiresAt: now + randomInt(rng, 60, 1800) * 1000, + expiresAt: FIXTURE_EPOCH + randomInt(rng, 60, 1800) * 1000, } if (rng() < 0.05) entity.badge = randomInt(rng, 1, 3) if (rng() < 0.1) entity.background = randomInt(rng, 1, 5) @@ -71,27 +78,34 @@ function buildGym(rng: () => number, index: number): GymEntity { } } +/** + * Builds a fresh set every call. The cached getters below return the + * same array identity, so a determinism test written against them + * would pass without proving anything; these are what that test needs. + */ +export function generateFixturePokemon(): PokemonEntity[] { + const rng = createRng(SEED) + return Array.from({ length: POKEMON_COUNT }, (_, index) => + buildPokemon(rng, index), + ) +} + +export function generateFixtureGyms(): GymEntity[] { + // Separate seed offset so gym placement doesn't consume the same + // PRNG stream as pokemon and shift its sequence. + const rng = createRng(SEED + 1) + return Array.from({ length: GYM_COUNT }, (_, index) => buildGym(rng, index)) +} + let cachedPokemon: PokemonEntity[] | undefined let cachedGyms: GymEntity[] | undefined export function getFixturePokemon(): PokemonEntity[] { - if (!cachedPokemon) { - const rng = createRng(SEED) - cachedPokemon = Array.from({ length: POKEMON_COUNT }, (_, index) => - buildPokemon(rng, index), - ) - } + if (!cachedPokemon) cachedPokemon = generateFixturePokemon() return cachedPokemon } export function getFixtureGyms(): GymEntity[] { - if (!cachedGyms) { - // Separate seed offset so gym placement doesn't consume the same - // PRNG stream as pokemon and shift its sequence. - const rng = createRng(SEED + 1) - cachedGyms = Array.from({ length: GYM_COUNT }, (_, index) => - buildGym(rng, index), - ) - } + if (!cachedGyms) cachedGyms = generateFixtureGyms() return cachedGyms } From 6557b2f83ebfae5775a177c3bef49141211deb70 Mon Sep 17 00:00:00 2001 From: Rin <58572875+TurtIeSocks@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:02:31 -0400 Subject: [PATCH 07/24] refactor(map): narrow team and gender to their closed sets Both were bare numbers, so team: 99 compiled. They are small fixed enumerations, so they are unions now, and the fixtures pick from a literal list rather than a numeric range. Generated values are unchanged: the picks consume the same amount of the PRNG stream and map to the same numbers, verified against a dump taken before this. form and costume stay open numbers. Their valid ranges vary per species, so a union there would be wrong rather than merely tighter. Also corrects the interface comment, which said badges above a singular optional badge. --- app/map/fixtures.ts | 13 ++++++++++--- app/map/types.ts | 13 ++++++++++--- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/app/map/fixtures.ts b/app/map/fixtures.ts index 7e44cd5a7..29aeb42a2 100644 --- a/app/map/fixtures.ts +++ b/app/map/fixtures.ts @@ -1,4 +1,4 @@ -import type { GymEntity, PokemonEntity } from './types' +import type { Gender, GymEntity, PokemonEntity, Team } from './types' /** * The area every fixture is scattered across. Chosen to comfortably @@ -46,6 +46,13 @@ function randomInt(rng: () => number, min: number, max: number): number { return Math.floor(randomInRange(rng, min, max + 1)) } +function pick(rng: () => number, values: readonly [T, ...T[]]): T { + return values[randomInt(rng, 0, values.length - 1)] as T +} + +const GENDERS: readonly [Gender, ...Gender[]] = [1, 2, 3] +const TEAMS: readonly [Team, ...Team[]] = [0, 1, 2, 3] + function buildPokemon(rng: () => number, index: number): PokemonEntity { const entity: PokemonEntity = { kind: 'pokemon', @@ -53,7 +60,7 @@ function buildPokemon(rng: () => number, index: number): PokemonEntity { pokemonId: randomInt(rng, 1, 493), form: randomInt(rng, 0, 3), costume: randomInt(rng, 0, 2), - gender: randomInt(rng, 1, 3), + gender: pick(rng, GENDERS), lat: randomInRange(rng, FIXTURE_AREA.south, FIXTURE_AREA.north), lon: randomInRange(rng, FIXTURE_AREA.west, FIXTURE_AREA.east), expiresAt: FIXTURE_EPOCH + randomInt(rng, 60, 1800) * 1000, @@ -73,7 +80,7 @@ function buildGym(rng: () => number, index: number): GymEntity { gymId: `gym-${index}`, lat: randomInRange(rng, FIXTURE_AREA.south, FIXTURE_AREA.north), lon: randomInRange(rng, FIXTURE_AREA.west, FIXTURE_AREA.east), - team: randomInt(rng, 0, 3), + team: pick(rng, TEAMS), inBattle: rng() < 0.1, } } diff --git a/app/map/types.ts b/app/map/types.ts index 0a6ddf2ad..35cc5e941 100644 --- a/app/map/types.ts +++ b/app/map/types.ts @@ -19,9 +19,15 @@ export interface MapQuery { zoom: number } +/** 0 unset, 1 male, 2 female, 3 genderless. */ +export type Gender = 0 | 1 | 2 | 3 + +/** 0 uncontested, 1 Mystic, 2 Valor, 3 Instinct. */ +export type Team = 0 | 1 | 2 | 3 + /** * Fields carried here are exactly what determines the icon (pokemonId, - * form, costume, gender, badges, background, weather), what's needed to + * form, costume, gender, badge, background, weather), what's needed to * place and expire the marker (lat, lon, expiresAt), and the optional * stats rendered as marker text (iv, level, size). Nothing speculative: * a field added later is cheap, one removed after something depends on @@ -35,9 +41,10 @@ export interface PokemonEntity { kind: 'pokemon' spawnId: string pokemonId: number + /** Left as open numbers: valid values vary per species. */ form: number costume: number - gender: number + gender: Gender badge?: number background?: number weather?: number @@ -54,7 +61,7 @@ export interface GymEntity { gymId: string lat: number lon: number - team: number + team: Team inBattle: boolean } From ad87569e2dab69877f655e3e6a8774acd03df821 Mon Sep 17 00:00:00 2001 From: Rin <58572875+TurtIeSocks@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:10:04 -0400 Subject: [PATCH 08/24] feat(map): composite marker icons behind an lru cache --- app/map/atlas.test.ts | 167 ++++++++++++++++++++++++++++++++++++++++++ app/map/atlas.ts | 123 +++++++++++++++++++++++++++++++ 2 files changed, 290 insertions(+) create mode 100644 app/map/atlas.test.ts create mode 100644 app/map/atlas.ts diff --git a/app/map/atlas.test.ts b/app/map/atlas.test.ts new file mode 100644 index 000000000..70d2e88fb --- /dev/null +++ b/app/map/atlas.test.ts @@ -0,0 +1,167 @@ +import { expect, test } from 'bun:test' +import { createAtlas, type IconDescriptor, iconKeyFor, LruCache } from './atlas' +import { getFixturePokemon } from './fixtures' +import type { PokemonEntity } from './types' + +const BASE: PokemonEntity = { + kind: 'pokemon', + spawnId: 'spawn-a', + pokemonId: 6, + form: 0, + costume: 0, + gender: 1, + lat: 51.5, + lon: 0, + expiresAt: 1_000, +} + +test('two entities identical in appearance but differing in position, expiry, spawnId and stats share a key', () => { + const a: PokemonEntity = { ...BASE } + const b: PokemonEntity = { + ...BASE, + spawnId: 'spawn-b', + lat: 40, + lon: 90, + expiresAt: 999_999, + iv: 100, + level: 35, + size: 5, + } + expect(iconKeyFor(a)).toBe(iconKeyFor(b)) +}) + +test('a difference in pokemonId changes the key', () => { + expect(iconKeyFor(BASE)).not.toBe(iconKeyFor({ ...BASE, pokemonId: 7 })) +}) + +test('a difference in form changes the key', () => { + expect(iconKeyFor(BASE)).not.toBe(iconKeyFor({ ...BASE, form: 1 })) +}) + +test('a difference in costume changes the key', () => { + expect(iconKeyFor(BASE)).not.toBe(iconKeyFor({ ...BASE, costume: 1 })) +}) + +test('a difference in gender changes the key', () => { + expect(iconKeyFor(BASE)).not.toBe(iconKeyFor({ ...BASE, gender: 2 })) +}) + +test('an entity carrying a badge produces a different key than one without one', () => { + expect(iconKeyFor(BASE)).not.toBe(iconKeyFor({ ...BASE, badge: 1 })) +}) + +test('an entity carrying a background produces a different key than one without one', () => { + expect(iconKeyFor(BASE)).not.toBe(iconKeyFor({ ...BASE, background: 1 })) +}) + +test('an entity carrying weather produces a different key than one without it', () => { + expect(iconKeyFor(BASE)).not.toBe(iconKeyFor({ ...BASE, weather: 1 })) +}) + +test('an absent optional field cannot collide with a present one sharing its numeric value', () => { + // badge=1 vs no badge must differ, even though '1' vs '' would tempt a + // naive string join into looking equal after trimming. + const withBadge: PokemonEntity = { ...BASE, badge: 1 } + const withoutBadge: PokemonEntity = { ...BASE } + expect(iconKeyFor(withBadge)).not.toBe(iconKeyFor(withoutBadge)) +}) + +test('keying on spawnId instead of pokemonId would fail this: 5000 fixture pokemon collapse into far fewer appearance keys', () => { + const pokemon = getFixturePokemon() + const keys = new Set(pokemon.map(iconKeyFor)) + const spawnIds = new Set(pokemon.map((entity) => entity.spawnId)) + // Every fixture has a distinct spawnId (spawnIds.size === pokemon.length), + // so a key that incorporated spawnId would produce exactly that many + // distinct keys too, and this cache would never hit. The appearance key + // must collapse below that: several fixtures share a species with the + // same form/costume/gender/badge/background/weather combination. + expect(pokemon.length).toBeGreaterThanOrEqual(3000) + expect(spawnIds.size).toBe(pokemon.length) + expect(keys.size).toBeLessThan(spawnIds.size) +}) + +test('LruCache returns what was set', () => { + const cache = new LruCache(3) + cache.set('a', 1) + expect(cache.get('a')).toBe(1) + expect(cache.get('missing')).toBeUndefined() +}) + +test('LruCache evicts the least recently used entry once over capacity', () => { + const cache = new LruCache(2) + cache.set('a', 1) + cache.set('b', 2) + cache.set('c', 3) // capacity 2: 'a' is oldest, evicted + expect(cache.has('a')).toBe(false) + expect(cache.has('b')).toBe(true) + expect(cache.has('c')).toBe(true) + expect(cache.size).toBe(2) +}) + +test('LruCache reading a key refreshes its recency, so it survives the next eviction', () => { + const cache = new LruCache(2) + cache.set('a', 1) + cache.set('b', 2) + cache.get('a') // touch 'a': 'b' is now the oldest + cache.set('c', 3) + expect(cache.has('a')).toBe(true) + expect(cache.has('b')).toBe(false) + expect(cache.has('c')).toBe(true) +}) + +test('LruCache never exceeds its bound across many insertions', () => { + const cache = new LruCache(50) + for (let i = 0; i < 5000; i++) { + cache.set(i, i) + expect(cache.size).toBeLessThanOrEqual(50) + } + expect(cache.size).toBe(50) +}) + +test('rejects a non-positive capacity', () => { + expect(() => new LruCache(0)).toThrow() +}) + +test('getIconFor draws once per distinct key and reuses the cached descriptor on repeats', () => { + let drawCount = 0 + const seenKeys: string[] = [] + const atlas = createAtlas({ + capacity: 10, + draw: (_entity, key): IconDescriptor => { + drawCount += 1 + seenKeys.push(key) + return { id: key, url: `data:${key}`, width: 32, height: 32 } + }, + }) + + const a: PokemonEntity = { ...BASE } + const sameAppearance: PokemonEntity = { ...BASE, spawnId: 'spawn-other' } + const differentAppearance: PokemonEntity = { ...BASE, pokemonId: 999 } + + const first = atlas.getIconFor(a) + const second = atlas.getIconFor(sameAppearance) + const third = atlas.getIconFor(differentAppearance) + + expect(drawCount).toBe(2) + expect(second).toEqual(first) + expect(third).not.toEqual(first) + expect(seenKeys).toEqual([iconKeyFor(a), iconKeyFor(differentAppearance)]) +}) + +test('getIconFor respects the configured capacity via the exposed cache', () => { + const atlas = createAtlas({ + capacity: 4, + draw: (_entity, key): IconDescriptor => ({ + id: key, + url: `data:${key}`, + width: 32, + height: 32, + }), + }) + + const pokemon = getFixturePokemon().slice(0, 200) + for (const entity of pokemon) { + atlas.getIconFor(entity) + expect(atlas.cache.size).toBeLessThanOrEqual(4) + } +}) diff --git a/app/map/atlas.ts b/app/map/atlas.ts new file mode 100644 index 000000000..e90a70a56 --- /dev/null +++ b/app/map/atlas.ts @@ -0,0 +1,123 @@ +import type { PokemonEntity } from './types' + +/** + * Everything that determines what a pokemon marker looks like. Deliberately + * NOT `spawnId`: that field is unique per encounter and would give the cache + * one entry per marker, defeating it entirely. `pokemonId` is the species; + * `id` does not exist on `PokemonEntity` (see types.ts) so there is nothing + * to misread here even by typo. + * + * `form`, `costume` and `gender` are always present. `badge`, `background` + * and `weather` are optional appearance modifiers; each is folded in with a + * sentinel so an absent modifier can never collide with a present one that + * happens to share a numeric value. + */ +export function iconKeyFor(entity: PokemonEntity): string { + return [ + entity.pokemonId, + entity.form, + entity.costume, + entity.gender, + entity.badge ?? 'none', + entity.background ?? 'none', + entity.weather ?? 'none', + ].join(':') +} + +/** + * Fixed-capacity least-recently-used cache. Plain `Map` for storage: Map + * iterates in insertion order, and re-inserting a key on every `get`/`set` + * keeps that order equal to recency, so the first key in iteration order is + * always the one to evict. + */ +export class LruCache { + #capacity: number + #store = new Map() + + constructor(capacity: number) { + if (capacity < 1) { + throw new RangeError('LruCache capacity must be at least 1') + } + this.#capacity = capacity + } + + get size(): number { + return this.#store.size + } + + has(key: K): boolean { + return this.#store.has(key) + } + + get(key: K): V | undefined { + const value = this.#store.get(key) + if (value === undefined) return undefined + // Refresh recency: delete then re-insert moves this key to the end. + this.#store.delete(key) + this.#store.set(key, value) + return value + } + + set(key: K, value: V): void { + this.#store.delete(key) + this.#store.set(key, value) + if (this.#store.size > this.#capacity) { + const oldest = this.#store.keys().next().value + if (oldest !== undefined) this.#store.delete(oldest) + } + } +} + +/** What `IconLayer`'s `getIcon` accessor needs for one marker. */ +export interface IconDescriptor { + id: string + url: string + width: number + height: number +} + +/** + * Composites one marker's pixels and hands back everything `IconLayer` + * needs to place it. Takes an already-computed key rather than an entity so + * the drawer never has to re-derive or second-guess what determines + * appearance; that decision lives solely in `iconKeyFor`. Uses + * `OffscreenCanvas`, so it only runs in a browser, never in this module's + * tests. + */ +export type IconDrawer = (entity: PokemonEntity, key: string) => IconDescriptor + +const DEFAULT_CAPACITY = 512 + +export interface AtlasOptions { + draw: IconDrawer + capacity?: number +} + +/** + * The pipeline: key the entity, serve the cached descriptor on a hit, draw + * and cache on a miss. The cache and the key are plain data-in data-out + * functions with no canvas dependency, so they're exercised directly in + * tests; `draw` is the only piece that needs a real browser, and it's + * injected rather than imported, so nothing here reaches for + * `OffscreenCanvas` itself. + */ +export function createAtlas({ + draw, + capacity = DEFAULT_CAPACITY, +}: AtlasOptions): { + getIconFor: (entity: PokemonEntity) => IconDescriptor + cache: LruCache +} { + const cache = new LruCache(capacity) + + function getIconFor(entity: PokemonEntity): IconDescriptor { + const key = iconKeyFor(entity) + const cached = cache.get(key) + if (cached) return cached + const drawn = draw(entity, key) + cache.set(key, drawn) + return drawn + } + + return { getIconFor, cache } +} From fd96392a47864ddb72da61c18f1e53677de60f03 Mon Sep 17 00:00:00 2001 From: Rin <58572875+TurtIeSocks@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:20:57 -0400 Subject: [PATCH 09/24] fix(map): draw fixture appearance from a realistic distribution The generator drew pokemonId uniformly across all 493 species, form uniformly 0-3 and costume uniformly 0-2, which is roughly 5900 appearance combinations before gender, badge, background and weather multiply it further. At 5000 entities that produced 4649 distinct icon keys, 93 percent unique, and an icon cache that hit 1.4 percent of the time. The fixtures were close to the worst possible input for a cache keyed on appearance, so they could not show whether one works. A viewport does not look like that. A dozen commons dominate what is on screen, alternate forms belong to a minority of species and mostly do not spawn even for those, costumes are event-limited, and gender follows the species rather than being rolled per spawn. Distinct keys over the full set fall from 4649 to 1140 and the hit rate at capacity 512 rises from 1.4 percent to 73.2 percent. Over a viewport-sized batch of 280 it rises from 1.1 percent to 42.5 percent. Badge, background and weather are left alone. Measured separately, a weather model that assigns one condition per geographic cell rather than per spawn moved the viewport hit rate by under two points, which does not pay for the extra machinery. Every drawn value shifts because the PRNG stream moves, which is expected; the determinism tests are what prove reproducibility survived. --- app/map/fixtures.ts | 50 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/app/map/fixtures.ts b/app/map/fixtures.ts index 29aeb42a2..5235c7383 100644 --- a/app/map/fixtures.ts +++ b/app/map/fixtures.ts @@ -50,17 +50,57 @@ function pick(rng: () => number, values: readonly [T, ...T[]]): T { return values[randomInt(rng, 0, values.length - 1)] as T } -const GENDERS: readonly [Gender, ...Gender[]] = [1, 2, 3] const TEAMS: readonly [Team, ...Team[]] = [0, 1, 2, 3] +/** + * A viewport does not contain a uniform sample of the species list. A + * dozen commons account for most of what is on screen, a wider pool of + * regulars fills in around them, and anything else shows up rarely. + * Drawing uniformly across all 493 instead made almost every fixture + * visually distinct, which is close to the worst case for a cache keyed + * on appearance and made these fixtures useless as evidence that one + * works. + */ +const COMMON_SPECIES = [16, 19, 21, 23, 41, 43, 46, 52, 60, 69, 74, 96] +const REGULAR_SPECIES = [ + 1, 4, 7, 10, 13, 25, 27, 29, 32, 35, 37, 39, 50, 54, 58, 63, 66, 72, 77, 79, + 81, 84, 90, 92, 100, 102, 104, 109, 111, 118, +] +const COMMON_SHARE = 0.65 +const REGULAR_SHARE = 0.95 +const MAX_SPECIES = 493 + +function randomSpecies(rng: () => number): number { + const roll = rng() + if (roll < COMMON_SHARE) { + return COMMON_SPECIES[ + randomInt(rng, 0, COMMON_SPECIES.length - 1) + ] as number + } + if (roll < REGULAR_SHARE) { + return REGULAR_SPECIES[ + randomInt(rng, 0, REGULAR_SPECIES.length - 1) + ] as number + } + return randomInt(rng, 1, MAX_SPECIES) +} + function buildPokemon(rng: () => number, index: number): PokemonEntity { + const pokemonId = randomSpecies(rng) + // Alternate forms belong to a minority of species, and even those spawn + // in their default form most of the time. Costumes are event-limited and + // rarer still. Gender is a property of the species: a tenth of them are + // genderless, and the rest split evenly. + const hasAlternateForms = pokemonId % 7 === 0 + const isGenderless = pokemonId % 10 === 0 + const gender: Gender = isGenderless ? 3 : rng() < 0.5 ? 1 : 2 const entity: PokemonEntity = { kind: 'pokemon', spawnId: `pokemon-${index}`, - pokemonId: randomInt(rng, 1, 493), - form: randomInt(rng, 0, 3), - costume: randomInt(rng, 0, 2), - gender: pick(rng, GENDERS), + pokemonId, + form: hasAlternateForms && rng() < 0.4 ? randomInt(rng, 1, 3) : 0, + costume: rng() < 0.02 ? randomInt(rng, 1, 2) : 0, + gender, lat: randomInRange(rng, FIXTURE_AREA.south, FIXTURE_AREA.north), lon: randomInRange(rng, FIXTURE_AREA.west, FIXTURE_AREA.east), expiresAt: FIXTURE_EPOCH + randomInt(rng, 60, 1800) * 1000, From bc85b603894a300a4691f0db12c6362ada032e37 Mon Sep 17 00:00:00 2001 From: Rin <58572875+TurtIeSocks@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:21:30 -0400 Subject: [PATCH 10/24] test(map): assert a real hit rate instead of a comparison that cannot fail The old assertion checked that the appearance key set was smaller than the spawnId set. That was true at 4649 keys out of 5000 and would still be true at 4999 out of 5000, so it could not tell a working cache from a useless one, and it reported a falsifying measurement as a confirming one. Three assertions replace it. A cold pass over a viewport-sized batch must serve at least a quarter of its markers from cache, a cold pass over the whole set at least half, and a second pass over the same viewport must draw nothing at all, which is what makes redrawing and panning cheap. All three run through createAtlas and count composites, so they measure the pipeline rather than a reimplementation of it. The floors are floors, not a record of the current numbers, which sit at 42.5 and 73.2 percent. Run against the previous fixtures the first two report 1.1 and 1.4 percent and fail. --- app/map/atlas.test.ts | 90 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 80 insertions(+), 10 deletions(-) diff --git a/app/map/atlas.test.ts b/app/map/atlas.test.ts index 70d2e88fb..79be6f3b0 100644 --- a/app/map/atlas.test.ts +++ b/app/map/atlas.test.ts @@ -66,18 +66,88 @@ test('an absent optional field cannot collide with a present one sharing its num expect(iconKeyFor(withBadge)).not.toBe(iconKeyFor(withoutBadge)) }) -test('keying on spawnId instead of pokemonId would fail this: 5000 fixture pokemon collapse into far fewer appearance keys', () => { +/** + * The capacity `createAtlas` defaults to. Duplicated rather than exported + * from atlas.ts so that moving the default is a deliberate edit here too. + */ +const DEFAULT_CAPACITY = 512 + +/** + * A box inside the fixture area holding roughly one screenful of markers. + * Viewport scale is the condition that matters: the map draws a few hundred + * markers at a time, not all five thousand. + */ +const VIEWPORT = { west: -0.12, south: 51.25, east: 0.12, north: 51.75 } + +/** + * Thresholds are a floor on usefulness, not a record of the current number. + * A cache that serves under a quarter of a cold viewport pass is not paying + * for itself, and the fixture feeding it has stopped resembling a real + * viewport. Measured against the fixtures at the time of writing: 42.5 + * percent over a viewport batch and 73.2 percent over the full set, so both + * floors have room beneath them. Uniformly random fixtures score 1.1 and 1.4 + * percent, so the gap either side of these lines is wide. + */ +const VIEWPORT_MIN_HIT_RATE = 0.25 +const FULL_SET_MIN_HIT_RATE = 0.5 + +function viewportBatch(): PokemonEntity[] { + return getFixturePokemon().filter( + (entity) => + entity.lat >= VIEWPORT.south && + entity.lat <= VIEWPORT.north && + entity.lon >= VIEWPORT.west && + entity.lon <= VIEWPORT.east, + ) +} + +/** An atlas that counts composites, so a pass reports its own hit rate. */ +function countingAtlas() { + let draws = 0 + const atlas = createAtlas({ + capacity: DEFAULT_CAPACITY, + draw: (_entity, key): IconDescriptor => { + draws += 1 + return { id: key, url: `data:${key}`, width: 32, height: 32 } + }, + }) + return { + pass(entities: PokemonEntity[]) { + const before = draws + for (const entity of entities) atlas.getIconFor(entity) + const drawn = draws - before + return { drawn, hitRate: 1 - drawn / entities.length } + }, + } +} + +test('a cold pass over a viewport-sized batch serves a quarter of its markers from cache', () => { + const batch = viewportBatch() + expect(batch.length).toBeGreaterThan(100) + expect(countingAtlas().pass(batch).hitRate).toBeGreaterThanOrEqual( + VIEWPORT_MIN_HIT_RATE, + ) +}) + +test('a cold pass over the whole fixture set serves half its markers from cache', () => { const pokemon = getFixturePokemon() - const keys = new Set(pokemon.map(iconKeyFor)) - const spawnIds = new Set(pokemon.map((entity) => entity.spawnId)) - // Every fixture has a distinct spawnId (spawnIds.size === pokemon.length), - // so a key that incorporated spawnId would produce exactly that many - // distinct keys too, and this cache would never hit. The appearance key - // must collapse below that: several fixtures share a species with the - // same form/costume/gender/badge/background/weather combination. + // The engine exists because past three thousand markers the old renderer + // goes choppy, so the fixture set has to clear that bar to be evidence. expect(pokemon.length).toBeGreaterThanOrEqual(3000) - expect(spawnIds.size).toBe(pokemon.length) - expect(keys.size).toBeLessThan(spawnIds.size) + expect(countingAtlas().pass(pokemon).hitRate).toBeGreaterThanOrEqual( + FULL_SET_MIN_HIT_RATE, + ) +}) + +test("a viewport's icons fit inside capacity, so a second pass over it draws nothing", () => { + const batch = viewportBatch() + const atlas = countingAtlas() + atlas.pass(batch) + // Nothing was evicted during the first pass, so every marker still on + // screen is already composited. This is what makes panning and redrawing + // cheap, and it fails the moment a viewport's icon variety outgrows the + // cache. + expect(atlas.pass(batch).drawn).toBe(0) }) test('LruCache returns what was set', () => { From 7a368a4cd932d82f30b0dd9a72863c28ff4f4e60 Mon Sep 17 00:00:00 2001 From: Rin <58572875+TurtIeSocks@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:35:24 -0400 Subject: [PATCH 11/24] feat(map): render the basemap and sync the camera MapCanvas mounts one MapLibre instance sized to the viewport minus the bottom nav, defaulting to OpenFreeMap's keyless vector style so a self-hoster never needs an API key out of the box. Setting VITE_BASEMAP_URL to a raster {z}/{x}/{y} template switches to a raster source built locally, matching the tile URL shape 1.0's tileServers config already uses; setting it to any other url loads that url as a vector style document directly. 1.0's /@/:lat/:lon(/:zoom) deep links redirect into /map's own camera query params rather than being reimplemented against MapLibre, so a link already in the wild still lands on the map. Camera moves update those params on moveend with history replace, so panning does not grow browser history one entry per gesture. vite.config.js's manualChunks put every node_modules dependency, including maplibre-gl, into one vendor chunk that app.html modulepreloads unconditionally, and routed maplibre-gl's own stylesheet into the chunk shared with 1.0's CSS entirely outside the lazy-loaded /map route. Both would have shipped MapLibre to every visitor regardless of whether they open the map. maplibre-gl and its stylesheet now get their own chunk, loaded only from MapPage's lazy import. Testing stops at what a DOM without WebGL can prove: the basemap resolution logic and the deep-link redirect are covered directly, and the built output is checked for which chunk MapLibre actually lands in. Rendering the canvas itself needs a real browser. Co-Authored-By: Claude Opus 5 --- app/map/MapCanvas.tsx | 36 +++++++++++++++++ app/map/basemap.test.ts | 48 ++++++++++++++++++++++ app/map/basemap.ts | 68 +++++++++++++++++++++++++++++++ app/map/env.d.ts | 15 +++++++ app/map/useMapLibre.ts | 80 +++++++++++++++++++++++++++++++++++++ app/pages/DeepLink.test.tsx | 43 ++++++++++++++++++++ app/pages/DeepLink.tsx | 25 ++++++++++++ app/pages/MapPage.tsx | 59 ++++++++++++++++++++++++--- app/routes.test.tsx | 24 ++++++++++- app/routes.tsx | 16 ++++++++ bun.lock | 45 ++++++++++++++++++++- package.json | 1 + vite.config.js | 8 ++++ 13 files changed, 459 insertions(+), 9 deletions(-) create mode 100644 app/map/MapCanvas.tsx create mode 100644 app/map/basemap.test.ts create mode 100644 app/map/basemap.ts create mode 100644 app/map/env.d.ts create mode 100644 app/map/useMapLibre.ts create mode 100644 app/pages/DeepLink.test.tsx create mode 100644 app/pages/DeepLink.tsx diff --git a/app/map/MapCanvas.tsx b/app/map/MapCanvas.tsx new file mode 100644 index 000000000..c407d029c --- /dev/null +++ b/app/map/MapCanvas.tsx @@ -0,0 +1,36 @@ +import 'maplibre-gl/dist/maplibre-gl.css' + +import type { Camera } from './useMapLibre' +import { useMapLibre } from './useMapLibre' + +export interface MapCanvasProps { + initialCamera: Camera + onCameraChange?: (camera: Camera) => void +} + +/** + * Mounts one MapLibre instance sized to the viewport minus the bottom nav + * (`Shell` reserves that with `pb-16`, 4rem, so this container claims the + * rest of the dynamic viewport height rather than the full 100dvh, or the + * map would render 4rem taller than what is actually visible above the + * nav). + * + * The stylesheet import above is load-bearing: without it the canvas still + * renders, which is exactly what makes it easy to miss, but the + * NavigationControl and attribution end up unstyled and mispositioned. + */ +export function MapCanvas({ initialCamera, onCameraChange }: MapCanvasProps) { + const { containerRef } = useMapLibre({ + initialCamera, + ...(onCameraChange ? { onCameraChange } : {}), + }) + + return ( +
+ ) +} diff --git a/app/map/basemap.test.ts b/app/map/basemap.test.ts new file mode 100644 index 000000000..b0c26b4ad --- /dev/null +++ b/app/map/basemap.test.ts @@ -0,0 +1,48 @@ +import { afterEach, expect, test } from 'bun:test' +import { resolveBasemapStyle } from './basemap' + +const ENV_KEY = 'VITE_BASEMAP_URL' +const original = process.env[ENV_KEY] + +afterEach(() => { + if (original === undefined) { + delete process.env[ENV_KEY] + } else { + process.env[ENV_KEY] = original + } +}) + +/* + * These three cover the pure decision this module makes and nothing about + * MapLibre itself: rendering a WebGL canvas needs a real browser, so that + * part is left to manual/browser verification and is not asserted here. + */ + +test('defaults to the keyless vector style when unconfigured', () => { + delete process.env[ENV_KEY] + const style = resolveBasemapStyle() + expect(style).toBe('https://tiles.openfreemap.org/styles/liberty') +}) + +test('treats a {z}/{x}/{y} template as a raster tile source, not a style url', () => { + process.env[ENV_KEY] = 'https://tile.example.com/{z}/{x}/{y}.png' + const style = resolveBasemapStyle() + expect(typeof style).not.toBe('string') + if (typeof style === 'string') throw new Error('unreachable') + expect(style.sources.basemap).toEqual({ + type: 'raster', + tiles: ['https://tile.example.com/{z}/{x}/{y}.png'], + tileSize: 256, + attribution: + '© OpenStreetMap contributors', + }) + expect(style.layers).toEqual([ + { id: 'basemap', type: 'raster', source: 'basemap' }, + ]) +}) + +test('passes a non-template url straight through as a vector style document url', () => { + process.env[ENV_KEY] = 'https://example.com/my-style.json' + const style = resolveBasemapStyle() + expect(style).toBe('https://example.com/my-style.json') +}) diff --git a/app/map/basemap.ts b/app/map/basemap.ts new file mode 100644 index 000000000..fa4bd1c00 --- /dev/null +++ b/app/map/basemap.ts @@ -0,0 +1,68 @@ +import type { StyleSpecification } from 'maplibre-gl' + +/** + * Keyless vector style, no signup and no rate-limited key required. This is + * the default every self-hoster gets with zero configuration: OpenFreeMap + * serves the full planet's OSM data as vector tiles under ODbL, funded + * separately from any single provider's key quota. + * + * https://openfreemap.org + */ +const KEYLESS_VECTOR_STYLE_URL = 'https://tiles.openfreemap.org/styles/liberty' + +/** + * `VITE_BASEMAP_URL` is the one config field a self-hoster touches to + * change the basemap, mirroring 1.0's `tileServers[].url` field in shape: a + * `{z}/{x}/{y}` template means raster tiles, anything else is treated as a + * vector style document to load directly. Unset keeps the keyless vector + * default above, so requiring an API key is never the out-of-the-box + * experience. + */ +function readConfiguredBasemapUrl(): string | undefined { + const value = import.meta.env.VITE_BASEMAP_URL + return typeof value === 'string' && value.length > 0 ? value : undefined +} + +function isRasterTemplate(url: string): boolean { + return url.includes('{z}') && url.includes('{x}') && url.includes('{y}') +} + +/** + * Builds a minimal raster-only style around one tile URL template, the same + * shape 1.0's `tileServers` entries already carry. `tileSize` is 256 because + * every server in 1.0's default `tileServers` list serves 256px tiles. + */ +function buildRasterStyle(tileUrlTemplate: string): StyleSpecification { + return { + version: 8, + sources: { + basemap: { + type: 'raster', + tiles: [tileUrlTemplate], + tileSize: 256, + attribution: + '© OpenStreetMap contributors', + }, + }, + layers: [ + { + id: 'basemap', + type: 'raster', + source: 'basemap', + }, + ], + } +} + +/** + * Resolves what `MapCanvas` should hand MapLibre as `style`: either a style + * document (raster, built locally) or a URL MapLibre fetches itself (the + * keyless vector default, or a self-hoster's own vector style document). + */ +export function resolveBasemapStyle(): StyleSpecification | string { + const configured = readConfiguredBasemapUrl() + if (!configured) return KEYLESS_VECTOR_STYLE_URL + return isRasterTemplate(configured) + ? buildRasterStyle(configured) + : configured +} diff --git a/app/map/env.d.ts b/app/map/env.d.ts new file mode 100644 index 000000000..fcb3fed0f --- /dev/null +++ b/app/map/env.d.ts @@ -0,0 +1,15 @@ +/** + * Vite does not ship `ImportMetaEnv` typings unless `vite/client` is in + * `tsconfig`'s `types`, and this project's `tsconfig.json` only lists + * `bun-types` (shared with the Node-side `server/`). Declaring the one env + * var this module reads here, rather than adding `vite/client` globally, + * avoids pulling in `vite/client`'s own DOM lib assumptions for the whole + * `app/` tree over one field. + */ +interface ImportMetaEnv { + readonly VITE_BASEMAP_URL?: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/app/map/useMapLibre.ts b/app/map/useMapLibre.ts new file mode 100644 index 000000000..99891f933 --- /dev/null +++ b/app/map/useMapLibre.ts @@ -0,0 +1,80 @@ +import { Map as MapLibreMap, NavigationControl } from 'maplibre-gl' +import { type RefObject, useEffect, useRef } from 'react' +import { resolveBasemapStyle } from './basemap' + +export interface Camera { + lat: number + lon: number + zoom: number +} + +export interface UseMapLibreOptions { + /** Camera to center on the moment the map is created. */ + initialCamera: Camera + /** + * Fired on `moveend`, i.e. once per pan/zoom gesture rather than once per + * animation frame. The caller (`MapPage`) uses this to sync the URL + * without pushing a history entry per frame; a listener that instead + * pushed on `move` would make the back button useless after one pan. + */ + onCameraChange?: (camera: Camera) => void +} + +export interface UseMapLibreResult { + /** Attach to the element MapLibre should mount into. */ + containerRef: RefObject +} + +/** + * Owns one MapLibre instance for the lifetime of the component that calls + * this hook: creates it against `containerRef`'s element on mount, tears it + * down on unmount, and re-centers it if `initialCamera` changes identity + * (the deep-link redirect case, where the map is already mounted and a new + * `/@/:lat/:lon/:zoom` link arrives at the same `/map` route). + */ +export function useMapLibre({ + initialCamera, + onCameraChange, +}: UseMapLibreOptions): UseMapLibreResult { + const containerRef = useRef(null) + const mapRef = useRef(null) + const onCameraChangeRef = useRef(onCameraChange) + onCameraChangeRef.current = onCameraChange + + useEffect(() => { + const container = containerRef.current + if (!container) return undefined + + const map = new MapLibreMap({ + container, + style: resolveBasemapStyle(), + center: [initialCamera.lon, initialCamera.lat], + zoom: initialCamera.zoom, + attributionControl: { compact: true }, + }) + map.addControl(new NavigationControl(), 'top-right') + + const handleMoveEnd = () => { + const center = map.getCenter() + onCameraChangeRef.current?.({ + lat: center.lat, + lon: center.lng, + zoom: map.getZoom(), + }) + } + map.on('moveend', handleMoveEnd) + + mapRef.current = map + return () => { + map.off('moveend', handleMoveEnd) + map.remove() + mapRef.current = null + } + // Camera is only read once, to seed the map on creation; every camera + // change after that flows through moveend and the caller's own state, + // not back into this effect. Re-running this effect on every camera + // change would tear down and recreate the whole map on every pan. + }, []) + + return { containerRef } +} diff --git a/app/pages/DeepLink.test.tsx b/app/pages/DeepLink.test.tsx new file mode 100644 index 000000000..80c3d149e --- /dev/null +++ b/app/pages/DeepLink.test.tsx @@ -0,0 +1,43 @@ +import { afterAll, afterEach, beforeAll, expect, test } from 'bun:test' +import { cleanup, render } from '@testing-library/react' +import { MemoryRouter, Route, Routes, useLocation } from 'react-router' +import { setupDom, teardownDom } from '../test-setup' +import { DeepLink } from './DeepLink' + +beforeAll(setupDom) +afterAll(teardownDom) +afterEach(cleanup) + +function LocationProbe() { + const location = useLocation() + return ( + + {location.pathname} + {location.search} + + ) +} + +function renderAt(initialEntry: string) { + return render( + + + } /> + } /> + } /> + + , + ) +} + +test('a 1.0 lat/lon/zoom deep link redirects to /map with matching query params', () => { + const { container } = renderAt('/@/40.7/-74.0/12') + const location = container.querySelector('[data-testid="location"]') + expect(location?.textContent).toBe('/map?lat=40.7&lon=-74.0&zoom=12') +}) + +test('a 1.0 lat/lon deep link without zoom redirects without a zoom param', () => { + const { container } = renderAt('/@/40.7/-74.0') + const location = container.querySelector('[data-testid="location"]') + expect(location?.textContent).toBe('/map?lat=40.7&lon=-74.0') +}) diff --git a/app/pages/DeepLink.tsx b/app/pages/DeepLink.tsx new file mode 100644 index 000000000..934948229 --- /dev/null +++ b/app/pages/DeepLink.tsx @@ -0,0 +1,25 @@ +import { Navigate, useParams } from 'react-router' + +/** + * 1.0 deep links are `/@/:lat/:lon` or `/@/:lat/:lon/:zoom`, and are in the + * wild (bookmarks, shared links). The 2.0 route table owns `/map`, so this + * component's only job is translating those params into `/map`'s own + * camera query params and handing off with a redirect. It never mounts + * MapLibre itself, and is registered directly rather than through + * `lazy()`: it has no heavy dependency to defer, and going through `/map` + * is what actually loads the map lazily, on the same terms as a visitor + * who typed `/map` directly. + * + * Missing or non-numeric params still redirect, just without a camera, so + * `/map` falls back to `MapPage`'s own default rather than this route + * needing to duplicate that fallback. + */ +export function DeepLink() { + const { lat, lon, zoom } = useParams() + const params = new URLSearchParams() + if (lat) params.set('lat', lat) + if (lon) params.set('lon', lon) + if (zoom) params.set('zoom', zoom) + const search = params.toString() + return +} diff --git a/app/pages/MapPage.tsx b/app/pages/MapPage.tsx index 70b236c54..3dc3ee3aa 100644 --- a/app/pages/MapPage.tsx +++ b/app/pages/MapPage.tsx @@ -1,10 +1,57 @@ +import { MapCanvas } from '@app/map/MapCanvas' +import type { Camera } from '@app/map/useMapLibre' +import { useCallback, useMemo } from 'react' +import { useSearchParams } from 'react-router' + +/** + * Where the 1.0 map opened by default: roughly the middle of the North + * Atlantic, a deliberately uninteresting fallback used only when the URL + * carries no camera at all. + */ +const DEFAULT_CAMERA: Camera = { lat: 0, lon: 0, zoom: 2 } + +function parseCamera(params: URLSearchParams): Camera { + const lat = Number(params.get('lat')) + const lon = Number(params.get('lon')) + const zoom = Number(params.get('zoom')) + return { + lat: Number.isFinite(lat) && params.has('lat') ? lat : DEFAULT_CAMERA.lat, + lon: Number.isFinite(lon) && params.has('lon') ? lon : DEFAULT_CAMERA.lon, + zoom: + Number.isFinite(zoom) && params.has('zoom') ? zoom : DEFAULT_CAMERA.zoom, + } +} + +/** + * `lat`/`lon`/`zoom` query params are the camera's URL representation. + * `useMapLibre` only reads `initialCamera` once (see its own comment), so + * changes made here after mount do not fight the map; they exist so a + * refresh, share, or back navigation lands on the same view, and are + * written with `replace: true` so panning does not grow history one entry + * per gesture. + */ export function MapPage() { + const [searchParams, setSearchParams] = useSearchParams() + const initialCamera = useMemo(() => parseCamera(searchParams), []) + + const handleCameraChange = useCallback( + (camera: Camera) => { + setSearchParams( + { + lat: camera.lat.toFixed(5), + lon: camera.lon.toFixed(5), + zoom: camera.zoom.toFixed(2), + }, + { replace: true }, + ) + }, + [setSearchParams], + ) + return ( -
-

Map

-

- The map arrives in a later plan. -

-
+ ) } diff --git a/app/routes.test.tsx b/app/routes.test.tsx index cad833d6a..525e9aa8f 100644 --- a/app/routes.test.tsx +++ b/app/routes.test.tsx @@ -7,6 +7,8 @@ test('every spec route is present exactly once', () => { [ '*', '/', + '/@/:lat/:lon', + '/@/:lat/:lon/:zoom', '/alerts', '/filters', '/locales', @@ -17,8 +19,28 @@ test('every spec route is present exactly once', () => { ) }) -test('every route element is lazy so it becomes its own chunk', () => { +/* + * `/@/:lat/:lon(/:zoom)` are 1.0's deep links, kept for links already in + * the wild. They redirect straight into `/map` and never mount MapLibre + * themselves, so they carry no heavy dependency worth deferring; asserting + * `lazy` on them would only prove they import `react-router`'s own + * `Navigate`, which is already in every other chunk. + */ +const DEEP_LINK_PATHS = new Set(['/@/:lat/:lon', '/@/:lat/:lon/:zoom']) + +test('every route that is not a deep-link redirect is lazy so it becomes its own chunk', () => { for (const route of ROUTES) { + if (typeof route.path === 'string' && DEEP_LINK_PATHS.has(route.path)) { + continue + } expect(typeof route.lazy).toBe('function') } }) + +test('deep-link routes redirect through a plain Component, not lazy', () => { + for (const route of ROUTES) { + if (typeof route.path === 'string' && DEEP_LINK_PATHS.has(route.path)) { + expect(typeof route.Component).toBe('function') + } + } +}) diff --git a/app/routes.tsx b/app/routes.tsx index ea495850b..cecab2368 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -1,5 +1,6 @@ import type { RouteObject } from 'react-router' import { Shell } from './layout/Shell' +import { DeepLink } from './pages/DeepLink' /* * Every route is lazy so the bundler gives each one its own chunk. The map @@ -17,6 +18,21 @@ export const ROUTES: RouteObject[] = [ Component: (await import('./pages/MapPage')).MapPage, }), }, + /* + * 1.0's deep links, `/@/:lat/:lon` and `/@/:lat/:lon/:zoom`, are in the + * wild. `DeepLink` is registered directly, not through `lazy()`: it has + * no heavy dependency of its own, it only redirects into `/map`, and + * that redirect is what actually triggers the lazy MapLibre load, on + * the same terms as a visitor who navigated to `/map` directly. + */ + { + path: '/@/:lat/:lon', + Component: DeepLink, + }, + { + path: '/@/:lat/:lon/:zoom', + Component: DeepLink, + }, { path: '/filters', lazy: async () => ({ diff --git a/bun.lock b/bun.lock index dd14f619b..e328b99d2 100644 --- a/bun.lock +++ b/bun.lock @@ -70,6 +70,7 @@ "lodash": "^4.18.1", "long": "^4.0.0", "lucide-react": "1.33.0", + "maplibre-gl": "6.5.0", "moment-timezone": "^0.6.2", "mysql2": "3.11.0", "node-cache": "^5.1.2", @@ -532,6 +533,24 @@ "@keyv/serialize": ["@keyv/serialize@1.1.1", "", {}, "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA=="], + "@mapbox/jsonlint-lines-primitives": ["@mapbox/jsonlint-lines-primitives@2.0.3", "", {}, "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg=="], + + "@mapbox/point-geometry": ["@mapbox/point-geometry@1.1.0", "", {}, "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ=="], + + "@mapbox/tiny-sdf": ["@mapbox/tiny-sdf@2.2.0", "", {}, "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg=="], + + "@mapbox/unitbezier": ["@mapbox/unitbezier@1.0.0", "", {}, "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ=="], + + "@mapbox/vector-tile": ["@mapbox/vector-tile@3.0.0", "", { "dependencies": { "@mapbox/point-geometry": "~1.1.0", "@types/geojson": "^7946.0.16", "pbf": "^5.0.0" } }, "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q=="], + + "@maplibre/geojson-vt": ["@maplibre/geojson-vt@6.1.1", "", { "dependencies": { "kdbush": "^4.1.0" } }, "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ=="], + + "@maplibre/maplibre-gl-style-spec": ["@maplibre/maplibre-gl-style-spec@26.2.1", "", { "dependencies": { "@mapbox/jsonlint-lines-primitives": "^2.0.3", "@mapbox/unitbezier": "^1.0.0", "json-stringify-pretty-compact": "^4.0.0", "minimist": "^1.2.8", "quickselect": "^3.0.0", "tinyqueue": "^3.0.0" }, "bin": { "gl-style-format": "dist/gl-style-format.mjs", "gl-style-migrate": "dist/gl-style-migrate.mjs", "gl-style-validate": "dist/gl-style-validate.mjs" } }, "sha512-QFKCXkOeSzOr8jF75jm6kySOg+dUvOehPhRi68gcOYPHb7U5JloUq0dJW0Y5/fZV8ygfT0Vp2RWodvq+fyxFWA=="], + + "@maplibre/mlt": ["@maplibre/mlt@1.2.0", "", { "dependencies": { "@mapbox/point-geometry": "^1.1.0" } }, "sha512-g45M8gEI4sMO3X9ib4K7n3ZKFf0qQOL+NMkHCnEK51lOrYFHiEssOuZncx1+miIgi1IEE2NcbRMcq8RBkL+QHA=="], + + "@maplibre/vt-pbf": ["@maplibre/vt-pbf@4.3.2", "", { "dependencies": { "@mapbox/point-geometry": "^1.1.0", "@types/geojson": "^7946.0.16", "pbf": "^5.1.0" } }, "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.30.0", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA=="], "@monaco-editor/loader": ["@monaco-editor/loader@1.7.0", "", { "dependencies": { "state-local": "^1.0.6" } }, "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA=="], @@ -1398,6 +1417,8 @@ "duplexer2": ["duplexer2@0.1.4", "", { "dependencies": { "readable-stream": "^2.0.2" } }, "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA=="], + "earcut": ["earcut@3.2.3", "", {}, "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q=="], + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], @@ -1580,6 +1601,8 @@ "git-raw-commits": ["git-raw-commits@4.0.0", "", { "dependencies": { "dargs": "^8.0.0", "meow": "^12.0.1", "split2": "^4.0.0" }, "bin": { "git-raw-commits": "cli.mjs" } }, "sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ=="], + "gl-matrix": ["gl-matrix@3.4.4", "", {}, "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ=="], + "glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -1798,6 +1821,8 @@ "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + "json-stringify-pretty-compact": ["json-stringify-pretty-compact@4.0.0", "", {}, "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q=="], + "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], @@ -1920,6 +1945,8 @@ "magic-string": ["magic-string@0.30.8", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ=="], + "maplibre-gl": ["maplibre-gl@6.5.0", "", { "dependencies": { "@mapbox/point-geometry": "^1.1.0", "@mapbox/tiny-sdf": "^2.2.0", "@mapbox/unitbezier": "^1.0.0", "@mapbox/vector-tile": "^3.0.0", "@maplibre/geojson-vt": "^6.1.1", "@maplibre/maplibre-gl-style-spec": "^26.2.1", "@maplibre/mlt": "^1.2.0", "@maplibre/vt-pbf": "^4.3.2", "@types/geojson": "^7946.0.16", "earcut": "^3.2.3", "gl-matrix": "^3.4.4", "kdbush": "^4.1.0", "murmurhash-js": "^1.0.0", "pbf": "^5.1.2", "potpack": "^2.1.0", "quickselect": "^3.0.0", "tinyqueue": "^3.0.0" } }, "sha512-kVStPz9Rw/ATjWV5tQ3iCR0tY+viz16Nh3E14iZNlBj0HloMAzFaDNtFYqPGkZSFRnv56txMh3ImjR0g6oClTw=="], + "marked": ["marked@9.1.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-jcByLnIFkd5gSXZmjNvS1TlmRhCXZjIzHYlaGkPlLIekG55JDR2Z4va9tZwCiP+/RDERiNhMOFu01xd6O5ct1Q=="], "marked-terminal": ["marked-terminal@6.3.0", "", { "dependencies": { "ansi-escapes": "^6.2.0", "chalk": "^5.3.0", "cli-highlight": "^2.1.11", "cli-table3": "^0.6.3", "node-emoji": "^2.1.3", "supports-hyperlinks": "^3.0.0" }, "peerDependencies": { "marked": ">=1 <13" } }, "sha512-icQT4cpNHOC3uxYr0DjZx8yYbFVnb40dzbvbqe9G2CRpFPnCe/RjfPXzP3xKm2vrd8sw4Sqsc3O+IsoPMzE/Iw=="], @@ -1974,6 +2001,8 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "murmurhash-js": ["murmurhash-js@1.0.0", "", {}, "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw=="], + "mute-stream": ["mute-stream@0.0.8", "", {}, "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA=="], "mysql2": ["mysql2@3.11.0", "", { "dependencies": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.6.3", "long": "^5.2.1", "lru-cache": "^8.0.0", "named-placeholders": "^1.1.3", "seq-queue": "^0.0.5", "sqlstring": "^2.3.2" } }, "sha512-J9phbsXGvTOcRVPR95YedzVSxJecpW5A5+cQ57rhHIFXteTP10HCs+VBjS7DHIKfEaI1zQ5tlVrquCd64A6YvA=="], @@ -2148,6 +2177,8 @@ "postcss-selector-parser": ["postcss-selector-parser@7.1.5", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw=="], + "potpack": ["potpack@2.1.0", "", {}, "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ=="], + "powershell-utils": ["powershell-utils@0.2.0", "", {}, "sha512-ZlsFlG7MtSFCoc5xreOvBAozCJ6Pf06opgJjh9ONEv418xpZSAzNjstD36C6+JwOnfSqOW/9uDkqKjezTdxZhw=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], @@ -2184,7 +2215,7 @@ "quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="], - "quickselect": ["quickselect@2.0.0", "", {}, "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw=="], + "quickselect": ["quickselect@3.0.0", "", {}, "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g=="], "radix-ui": ["radix-ui@1.6.7", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-accessible-icon": "1.1.15", "@radix-ui/react-accordion": "1.2.20", "@radix-ui/react-alert-dialog": "1.1.23", "@radix-ui/react-arrow": "1.1.15", "@radix-ui/react-aspect-ratio": "1.1.15", "@radix-ui/react-avatar": "1.2.6", "@radix-ui/react-checkbox": "1.3.11", "@radix-ui/react-collapsible": "1.1.20", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-context-menu": "2.3.7", "@radix-ui/react-dialog": "1.1.23", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-dropdown-menu": "2.1.24", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.1.16", "@radix-ui/react-form": "0.1.16", "@radix-ui/react-hover-card": "1.1.23", "@radix-ui/react-label": "2.1.15", "@radix-ui/react-menu": "2.1.24", "@radix-ui/react-menubar": "1.1.24", "@radix-ui/react-navigation-menu": "1.2.22", "@radix-ui/react-one-time-password-field": "0.1.16", "@radix-ui/react-password-toggle-field": "0.1.11", "@radix-ui/react-popover": "1.1.23", "@radix-ui/react-popper": "1.3.7", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-progress": "1.1.16", "@radix-ui/react-radio-group": "1.4.7", "@radix-ui/react-roving-focus": "1.1.19", "@radix-ui/react-scroll-area": "1.2.18", "@radix-ui/react-select": "2.3.7", "@radix-ui/react-separator": "1.1.15", "@radix-ui/react-slider": "1.4.7", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-switch": "1.3.7", "@radix-ui/react-tabs": "1.1.21", "@radix-ui/react-toast": "1.2.23", "@radix-ui/react-toggle": "1.1.18", "@radix-ui/react-toggle-group": "1.1.19", "@radix-ui/react-toolbar": "1.1.19", "@radix-ui/react-tooltip": "1.2.16", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-effect-event": "0.0.5", "@radix-ui/react-use-escape-keydown": "1.1.5", "@radix-ui/react-use-is-hydrated": "0.1.3", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-size": "1.1.4", "@radix-ui/react-visually-hidden": "1.2.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-QBdhh1arIEUvPC0dQ5+nwWAxt7+N+oP/9jPwjJkGFoSk/sqxg32gJtSXGtFh8frAIcS6oC9cx2Q+7KYCQLOAeA=="], @@ -2474,7 +2505,7 @@ "tinygradient": ["tinygradient@0.4.3", "", { "dependencies": { "@types/tinycolor2": "^1.4.0", "tinycolor2": "^1.0.0" } }, "sha512-tBPYQSs6eWukzzAITBSmqcOwZCKACvRa/XjPPh1mj4mnx4G3Drm51HxyCTU/TKnY8kG4hmTe5QlOh9O82aNtJQ=="], - "tinyqueue": ["tinyqueue@2.0.3", "", {}, "sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA=="], + "tinyqueue": ["tinyqueue@3.0.0", "", {}, "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g=="], "to-buffer": ["to-buffer@1.2.2", "", { "dependencies": { "isarray": "^2.0.5", "safe-buffer": "^5.2.1", "typed-array-buffer": "^1.0.3" } }, "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw=="], @@ -2714,6 +2745,10 @@ "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + "@mapbox/vector-tile/pbf": ["pbf@5.1.2", "", { "dependencies": { "resolve-protobuf-schema": "^2.1.0" }, "bin": { "pbf": "bin/pbf" } }, "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w=="], + + "@maplibre/vt-pbf/pbf": ["pbf@5.1.2", "", { "dependencies": { "resolve-protobuf-schema": "^2.1.0" }, "bin": { "pbf": "bin/pbf" } }, "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w=="], + "@modelcontextprotocol/sdk/ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], "@modelcontextprotocol/sdk/express-rate-limit": ["express-rate-limit@8.6.2", "", { "dependencies": { "debug": "^4.4.3", "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A=="], @@ -2910,6 +2945,8 @@ "log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], + "maplibre-gl/pbf": ["pbf@5.1.2", "", { "dependencies": { "resolve-protobuf-schema": "^2.1.0" }, "bin": { "pbf": "bin/pbf" } }, "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w=="], + "marked-terminal/ansi-escapes": ["ansi-escapes@6.2.1", "", {}, "sha512-4nJ3yixlEthEJ9Rk4vPcdBRkZvQZlYyu8j4/Mqz5sgIkddmEnH2Yj2ZrnP9S3tQOvSNRUIgVNF/1yPpRAGNRig=="], "marked-terminal/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], @@ -3314,6 +3351,8 @@ "protobufjs/long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + "rbush/quickselect": ["quickselect@2.0.0", "", {}, "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw=="], + "rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], "react-day-picker/date-fns": ["date-fns@4.4.0", "", {}, "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w=="], @@ -3358,6 +3397,8 @@ "supports-hyperlinks/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "sweepline-intersections/tinyqueue": ["tinyqueue@2.0.3", "", {}, "sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA=="], + "tempy/is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="], "tempy/type-fest": ["type-fest@2.19.0", "", {}, "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA=="], diff --git a/package.json b/package.json index 1e160f68d..4bd006361 100644 --- a/package.json +++ b/package.json @@ -159,6 +159,7 @@ "lodash": "^4.18.1", "long": "^4.0.0", "lucide-react": "1.33.0", + "maplibre-gl": "6.5.0", "moment-timezone": "^0.6.2", "mysql2": "3.11.0", "node-cache": "^5.1.2", diff --git a/vite.config.js b/vite.config.js index 036c33322..4c9817ab0 100644 --- a/vite.config.js +++ b/vite.config.js @@ -168,6 +168,14 @@ const viteConfig = defineConfig(({ mode }) => { ], output: { manualChunks: (id) => { + // MapLibre is only ever imported from app/map, behind the /map + // route's own lazy() call, so it needs a bucket of its own. + // Grouping it into the same 'vendor'/'index' buckets as + // everything else would put it behind app.html's unconditional + // modulepreload of vendor (every 2.0 route) or, for its CSS, + // behind index.html's stylesheet (every 1.0 route too) - + // either way a visitor who never opens /map would pay for it. + if (id.includes('node_modules/maplibre-gl')) return 'maplibre' if (id.endsWith('.css')) { return id.startsWith(appDir) ? 'app' : 'index' } From 1d24244c202180fe9be52e901b5d05af4afdc8ab Mon Sep 17 00:00:00 2001 From: Rin <58572875+TurtIeSocks@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:51:28 -0400 Subject: [PATCH 12/24] feat(map): add deck.gl overlay with pokemon and gym layers Attaches a MapboxOverlay in interleaved mode so markers render beneath MapLibre's street labels instead of over them. Adds IconLayer markers for pokemon (icons from the task 2 atlas via a new OffscreenCanvas draw function) and gyms (a shared mask icon tinted per team from the data palette's CSS tokens, read at runtime), plus a TextLayer carrying countdown and IV text as layer data rather than per-marker components. deck.gl gets its own manualChunks bucket in vite.config.js, the same treatment already given to maplibre-gl, after a first build showed it landing in the shared vendor chunk that every 2.0 route preloads unconditionally. --- app/map/MapCanvas.tsx | 83 ++++++ app/map/draw-icon.ts | 125 +++++++++ app/map/layers.test.ts | 207 ++++++++++++++ app/map/layers.ts | 205 ++++++++++++++ app/map/useMapLibre.test.ts | 30 ++ app/map/useMapLibre.ts | 48 ++++ bun.lock | 544 +++++++++++++++++++++++++++++++++++- package.json | 4 + vite.config.js | 12 + 9 files changed, 1256 insertions(+), 2 deletions(-) create mode 100644 app/map/draw-icon.ts create mode 100644 app/map/layers.test.ts create mode 100644 app/map/layers.ts create mode 100644 app/map/useMapLibre.test.ts diff --git a/app/map/MapCanvas.tsx b/app/map/MapCanvas.tsx index c407d029c..b2c688310 100644 --- a/app/map/MapCanvas.tsx +++ b/app/map/MapCanvas.tsx @@ -1,5 +1,11 @@ import 'maplibre-gl/dist/maplibre-gl.css' +import { useEffect, useMemo, useRef, useState } from 'react' +import { createAtlas } from './atlas' +import { drawGymIcon, drawPokemonIcon } from './draw-icon' +import { buildMapLayers } from './layers' +import { createFixtureSource } from './source' +import type { GymEntity, MapEntity, MapQuery, PokemonEntity } from './types' import type { Camera } from './useMapLibre' import { useMapLibre } from './useMapLibre' @@ -8,6 +14,31 @@ export interface MapCanvasProps { onCameraChange?: (camera: Camera) => void } +/** + * A source with no live transport yet has no notion of "the current + * viewport", so this queries the whole world rather than the camera's + * bounds; a later task narrows this to what `useMapLibre`'s camera state + * actually frames. + */ +const WORLD_BOUNDS = { west: -180, south: -90, east: 180, north: 90 } +const POKEMON_QUERY: MapQuery = { + kind: 'pokemon', + bounds: WORLD_BOUNDS, + zoom: 12, +} +const GYM_QUERY: MapQuery = { kind: 'gym', bounds: WORLD_BOUNDS, zoom: 12 } + +function isPokemon(entity: MapEntity): entity is PokemonEntity { + return entity.kind === 'pokemon' +} + +function isGym(entity: MapEntity): entity is GymEntity { + return entity.kind === 'gym' +} + +/** How often the countdown/IV text layer is rebuilt against a fresh clock. */ +const CLOCK_TICK_MS = 1000 + /** * Mounts one MapLibre instance sized to the viewport minus the bottom nav * (`Shell` reserves that with `pb-16`, 4rem, so this container claims the @@ -20,9 +51,61 @@ export interface MapCanvasProps { * NavigationControl and attribution end up unstyled and mispositioned. */ export function MapCanvas({ initialCamera, onCameraChange }: MapCanvasProps) { + // Lazy-initialized on the ref directly (a documented React pattern) so + // the atlas's LRU cache and the fixture source are each created exactly + // once for the component's lifetime, not rebuilt on every render. + const atlasRef = useRef | null>(null) + if (atlasRef.current === null) { + atlasRef.current = createAtlas({ draw: drawPokemonIcon }) + } + const sourceRef = useRef | null>(null) + if (sourceRef.current === null) { + sourceRef.current = createFixtureSource() + } + + const [pokemon, setPokemon] = useState([]) + const [gyms, setGyms] = useState([]) + // Countdown/IV text is layer data, not a per-marker component: this is + // the one clock this whole tree reads, and every timer-bearing layer is + // rebuilt from it on the same tick rather than each owning its own. + const [now, setNow] = useState(() => Date.now()) + + useEffect(() => { + const source = sourceRef.current + if (!source) return undefined + const unsubscribePokemon = source.subscribe(POKEMON_QUERY, (entities) => { + setPokemon(entities.filter(isPokemon)) + }) + const unsubscribeGyms = source.subscribe(GYM_QUERY, (entities) => { + setGyms(entities.filter(isGym)) + }) + return () => { + unsubscribePokemon() + unsubscribeGyms() + } + }, []) + + useEffect(() => { + const interval = setInterval(() => setNow(Date.now()), CLOCK_TICK_MS) + return () => clearInterval(interval) + }, []) + + const layers = useMemo(() => { + const atlas = atlasRef.current + if (!atlas) return [] + return buildMapLayers({ + pokemon, + gyms, + getIconFor: atlas.getIconFor, + getGymIcon: drawGymIcon, + now, + }) + }, [pokemon, gyms, now]) + const { containerRef } = useMapLibre({ initialCamera, ...(onCameraChange ? { onCameraChange } : {}), + layers, }) return ( diff --git a/app/map/draw-icon.ts b/app/map/draw-icon.ts new file mode 100644 index 000000000..79b7d09d1 --- /dev/null +++ b/app/map/draw-icon.ts @@ -0,0 +1,125 @@ +import type { IconDescriptor, IconDrawer } from './atlas' +import type { PokemonEntity } from './types' + +const ICON_SIZE = 64 + +/** + * Colours a pokemon's base circle by whether it carries a badge or weather + * boost, the only two appearance modifiers this placeholder renders + * distinctly (background and costume/form influence real sprite art, which + * this project has none of yet). This is a stand-in for real species + * artwork, not a claim that it is one; swapping in sprite sheets later + * replaces this function's body without touching its signature or anyone + * that calls it. + */ +function baseColorFor(entity: PokemonEntity): string { + if (entity.weather !== undefined) return '#7dd3fc' + if (entity.badge !== undefined) return '#fbbf24' + return '#e5e7eb' +} + +/** + * Draws one pokemon marker's pixels onto an `OffscreenCanvas` - a circle in + * `baseColorFor`'s colour with the species id centred on it, plus a ring + * when the entity carries a badge - and hands back a data URL `IconLayer` + * can fetch. + * + * `OffscreenCanvas` has no synchronous way to produce a data URL + * (`convertToBlob` is promise-based, and `IconDrawer` is synchronous so the + * atlas's cache-then-draw pipeline never has to deal with a pending + * result). This draws on the offscreen surface, then blits that surface + * onto a same-size, never-attached `` purely to call its + * synchronous `toDataURL`. All pixel compositing happens on the + * `OffscreenCanvas`; the bridge canvas does no drawing of its own. + * + * Only runs in a browser: `OffscreenCanvas` does not exist in the `bun + * test` environment, so this function is exercised by manual/browser + * verification, not a unit test (see task-4-report.md). + */ +export const drawPokemonIcon: IconDrawer = ( + entity: PokemonEntity, + key: string, +): IconDescriptor => { + const offscreen = new OffscreenCanvas(ICON_SIZE, ICON_SIZE) + const ctx = offscreen.getContext('2d') + if (!ctx) throw new Error('2d context unavailable on OffscreenCanvas') + + const center = ICON_SIZE / 2 + const radius = ICON_SIZE / 2 - 4 + + ctx.fillStyle = baseColorFor(entity) + ctx.beginPath() + ctx.arc(center, center, radius, 0, Math.PI * 2) + ctx.fill() + + if (entity.badge !== undefined) { + ctx.strokeStyle = '#dc2626' + ctx.lineWidth = 3 + ctx.beginPath() + ctx.arc(center, center, radius - 1.5, 0, Math.PI * 2) + ctx.stroke() + } + + ctx.fillStyle = '#111827' + ctx.font = `${ICON_SIZE * 0.3}px sans-serif` + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(String(entity.pokemonId), center, center) + + const bridge = document.createElement('canvas') + bridge.width = ICON_SIZE + bridge.height = ICON_SIZE + const bridgeCtx = bridge.getContext('2d') + if (!bridgeCtx) throw new Error('2d context unavailable on bridge canvas') + bridgeCtx.drawImage(offscreen, 0, 0) + + return { + id: key, + url: bridge.toDataURL('image/png'), + width: ICON_SIZE, + height: ICON_SIZE, + } +} + +const GYM_ICON_ID = 'gym-marker-mask' +let cachedGymIcon: IconDescriptor | undefined + +/** + * The single mask icon every gym marker shares: a plain circle, drawn once + * and cached at module scope rather than through Task 2's atlas, because a + * gym has one shape regardless of team - there is nothing per-entity for a + * cache keyed on appearance to do here. `IconLayer.getColor` tints this per + * gym from `readTeamColor` (see layers.ts); the pixels drawn here carry no + * colour of their own beyond full opacity, which is what makes tinting + * work. + */ +export function drawGymIcon(): IconDescriptor { + if (cachedGymIcon) return cachedGymIcon + + const offscreen = new OffscreenCanvas(ICON_SIZE, ICON_SIZE) + const ctx = offscreen.getContext('2d') + if (!ctx) throw new Error('2d context unavailable on OffscreenCanvas') + + const center = ICON_SIZE / 2 + const radius = ICON_SIZE / 2 - 4 + + ctx.fillStyle = '#ffffff' + ctx.beginPath() + ctx.arc(center, center, radius, 0, Math.PI * 2) + ctx.fill() + + const bridge = document.createElement('canvas') + bridge.width = ICON_SIZE + bridge.height = ICON_SIZE + const bridgeCtx = bridge.getContext('2d') + if (!bridgeCtx) throw new Error('2d context unavailable on bridge canvas') + bridgeCtx.drawImage(offscreen, 0, 0) + + cachedGymIcon = { + id: GYM_ICON_ID, + url: bridge.toDataURL('image/png'), + width: ICON_SIZE, + height: ICON_SIZE, + } + return cachedGymIcon +} diff --git a/app/map/layers.test.ts b/app/map/layers.test.ts new file mode 100644 index 000000000..834d7f80f --- /dev/null +++ b/app/map/layers.test.ts @@ -0,0 +1,207 @@ +import { afterAll, beforeAll, expect, test } from 'bun:test' +import { setupDom, teardownDom } from '../test-setup' +import type { IconDescriptor } from './atlas' +import { + buildGymIconLayer, + buildMapLayers, + buildPokemonIconLayer, + buildPokemonTextLayer, + formatCountdown, + GYM_ICON_LAYER_ID, + POKEMON_ICON_LAYER_ID, + POKEMON_LABEL_LAYER_ID, + readTeamColor, +} from './layers' +import type { GymEntity, PokemonEntity } from './types' + +/* + * Layers need a real WebGL context to render, which the test environment + * lacks (see task-4-report.md). What is tested here is construction: given + * entities, do the right layers come out with the right ids, counts and + * accessors. Whether markers actually sit under street labels in an + * interleaved MapLibre map is a claim only a browser can back; nothing + * below asserts that. + */ + +beforeAll(setupDom) +afterAll(teardownDom) + +const POKEMON: PokemonEntity = { + kind: 'pokemon', + spawnId: 'spawn-1', + pokemonId: 6, + form: 0, + costume: 0, + gender: 1, + lat: 51.5, + lon: -0.1, + expiresAt: 1_000, +} + +const GYM: GymEntity = { + kind: 'gym', + gymId: 'gym-1', + lat: 51.6, + lon: -0.2, + team: 2, + inBattle: false, +} + +const STUB_ICON: IconDescriptor = { + id: 'stub', + url: 'data:image/png;base64,stub', + width: 64, + height: 64, +} + +test('formatCountdown counts down from a pinned now', () => { + expect(formatCountdown(90_000, 0)).toBe('1:30') + expect(formatCountdown(5_000, 0)).toBe('0:05') +}) + +test('formatCountdown clamps an already-expired entity to zero rather than going negative', () => { + // This is the fixtures' actual situation: FIXTURE_EPOCH is a fixed past + // instant, so every fixture pokemon's expiresAt is behind real + // wall-clock now. A countdown must not read as a negative or wrap. + expect(formatCountdown(1_000, 999_999)).toBe('0:00') +}) + +test('buildPokemonIconLayer produces one instance per entity with the atlas descriptor', () => { + const layer = buildPokemonIconLayer([POKEMON], () => STUB_ICON) + expect(layer.id).toBe(POKEMON_ICON_LAYER_ID) + expect(layer.props.data).toHaveLength(1) + const icon = (layer.props.getIcon as (entity: PokemonEntity) => unknown)( + POKEMON, + ) + expect(icon).toEqual({ + id: STUB_ICON.id, + url: STUB_ICON.url, + width: STUB_ICON.width, + height: STUB_ICON.height, + }) +}) + +test('buildPokemonIconLayer calls getIconFor once per accessor invocation, not eagerly for the whole set', () => { + let calls = 0 + const layer = buildPokemonIconLayer([POKEMON], () => { + calls += 1 + return STUB_ICON + }) + expect(calls).toBe(0) + ;(layer.props.getIcon as (entity: PokemonEntity) => unknown)(POKEMON) + expect(calls).toBe(1) +}) + +test('buildPokemonTextLayer renders iv and countdown together when iv is present', () => { + const withIv: PokemonEntity = { ...POKEMON, iv: 82, expiresAt: 65_000 } + const layer = buildPokemonTextLayer([withIv], 5_000) + expect(layer.id).toBe(POKEMON_LABEL_LAYER_ID) + const text = (layer.props.getText as (entity: PokemonEntity) => string)( + withIv, + ) + expect(text).toBe('82% 1:00') +}) + +test('buildPokemonTextLayer omits iv entirely when the entity has none', () => { + const layer = buildPokemonTextLayer([POKEMON], 0) + const text = (layer.props.getText as (entity: PokemonEntity) => string)( + POKEMON, + ) + expect(text).not.toContain('%') +}) + +test('buildPokemonTextLayer data count matches the visible set, one entry per timer', () => { + const many = Array.from({ length: 12 }, (_, index) => ({ + ...POKEMON, + spawnId: `spawn-${index}`, + })) + const layer = buildPokemonTextLayer(many, 0) + expect(layer.props.data).toHaveLength(12) +}) + +test('readTeamColor resolves a token set on the given root element', () => { + // happy-dom (and real browsers) only resolve computed style, custom + // properties included, for an element actually attached to the document; + // a detached element's getComputedStyle reads every property as empty, + // which looks identical to a pruned token if this were skipped. + const root = document.createElement('div') + root.style.setProperty('--color-team-2', '#d83c22') + document.body.appendChild(root) + try { + expect(readTeamColor(2, root)).toEqual([0xd8, 0x3c, 0x22, 255]) + } finally { + root.remove() + } +}) + +test('readTeamColor falls back to a visible grey, not a colourless value, when the token does not resolve', () => { + const root = document.createElement('div') + // No --color-team-1 set on this element: simulates the token being + // pruned or the palette stylesheet not having loaded. + const warn = console.warn + let warned = false + console.warn = () => { + warned = true + } + try { + const color = readTeamColor(1, root) + expect(color).toEqual([128, 128, 128, 255]) + expect(warned).toBe(true) + } finally { + console.warn = warn + } +}) + +test('buildGymIconLayer tints its shared mask icon per entity from the team token', () => { + const root = document.createElement('div') + root.style.setProperty('--color-team-2', '#d83c22') + document.body.appendChild(root) + try { + const layer = buildGymIconLayer([GYM], () => STUB_ICON, root) + expect(layer.id).toBe(GYM_ICON_LAYER_ID) + expect(layer.props.data).toHaveLength(1) + const color = (layer.props.getColor as (entity: GymEntity) => unknown)(GYM) + expect(color).toEqual([0xd8, 0x3c, 0x22, 255]) + } finally { + root.remove() + } +}) + +test('buildGymIconLayer marks the shared icon as a mask so getColor actually tints it', () => { + const layer = buildGymIconLayer([GYM], () => STUB_ICON) + const getIcon = layer.props.getIcon as unknown as (entity: GymEntity) => { + mask?: boolean + } + expect(getIcon(GYM).mask).toBe(true) +}) + +test('buildMapLayers returns gyms, pokemon icons and pokemon labels, in that draw order', () => { + const layers = buildMapLayers({ + pokemon: [POKEMON], + gyms: [GYM], + getIconFor: () => STUB_ICON, + getGymIcon: () => STUB_ICON, + now: 0, + }) + expect(layers.map((layer) => layer.id)).toEqual([ + GYM_ICON_LAYER_ID, + POKEMON_ICON_LAYER_ID, + POKEMON_LABEL_LAYER_ID, + ]) +}) + +test('buildMapLayers scales to the viewport-sized set the fixtures exist to exercise', () => { + const pokemon = Array.from({ length: 500 }, (_, index) => ({ + ...POKEMON, + spawnId: `spawn-${index}`, + })) + const layers = buildMapLayers({ + pokemon, + gyms: [], + getIconFor: () => STUB_ICON, + getGymIcon: () => STUB_ICON, + now: 0, + }) + const iconLayer = layers.find((layer) => layer.id === POKEMON_ICON_LAYER_ID) + expect(iconLayer?.props.data).toHaveLength(500) +}) diff --git a/app/map/layers.ts b/app/map/layers.ts new file mode 100644 index 000000000..4a5468245 --- /dev/null +++ b/app/map/layers.ts @@ -0,0 +1,205 @@ +import type { Color, Layer } from '@deck.gl/core' +import { IconLayer, TextLayer } from '@deck.gl/layers' +import type { IconDescriptor } from './atlas' +import type { GymEntity, PokemonEntity, Team } from './types' + +export const POKEMON_ICON_LAYER_ID = 'pokemon-icons' +export const POKEMON_LABEL_LAYER_ID = 'pokemon-labels' +export const GYM_ICON_LAYER_ID = 'gym-icons' + +/** Fallback for a team colour token that failed to resolve. Neutral grey, + * distinct from every real team colour, so a broken token is visible on + * the map rather than invisible in a colourless marker. */ +const FALLBACK_TEAM_COLOR: Color = [128, 128, 128, 255] + +/** + * Maps each `Team` value to the custom property carrying its colour in + * `app/tokens/data-palette.css`. That file's `@theme static` block is what + * keeps these from being pruned by Tailwind's unused-variable scan; see + * the comment there for why a scanner can't see this file's usage. + */ +const TEAM_COLOR_TOKEN: Record = { + 0: '--color-team-0', + 1: '--color-team-1', + 2: '--color-team-2', + 3: '--color-team-3', +} + +function parseHexColor(value: string): Color | undefined { + const match = /^#([0-9a-f]{6})$/i.exec(value.trim()) + if (!match) return undefined + const hex = match[1] as string + return [ + Number.parseInt(hex.slice(0, 2), 16), + Number.parseInt(hex.slice(2, 4), 16), + Number.parseInt(hex.slice(4, 6), 16), + 255, + ] +} + +/** + * Reads one team's colour from the data palette's CSS custom properties at + * runtime, rather than importing a colour table, so the map surface and the + * rest of the design system share one source of truth. + * + * A pruned or otherwise unresolved custom property comes back from + * `getComputedStyle` as an empty string, and nothing about that fails + * loudly; `getIcon`/`getColor` would just receive nothing and the marker + * would render without colour. This checks what the property actually + * resolved to and falls back to a visible neutral grey with a console + * warning instead of letting that happen silently. + */ +export function readTeamColor( + team: Team, + root: HTMLElement = document.documentElement, +): Color { + const token = TEAM_COLOR_TOKEN[team] + const raw = getComputedStyle(root).getPropertyValue(token) + const color = parseHexColor(raw) + if (!color) { + // eslint-disable-next-line no-console + console.warn( + `[map] team colour token ${token} resolved to ${JSON.stringify(raw)}, expected a #rrggbb value. Falling back to grey.`, + ) + return FALLBACK_TEAM_COLOR + } + return color +} + +/** + * Formats the time remaining until `expiresAt` as `m:ss`, clamped at zero + * rather than going negative once a fixture (or a real encounter) expires. + * + * Takes `now` as a parameter instead of reading `Date.now()` internally so + * a test can pin a moment and compare against it, and so the caller is the + * single place that decides how often this gets recomputed (once a second, + * driven by a timer that rebuilds the layer's data array; see MapCanvas). + */ +export function formatCountdown(expiresAt: number, now: number): string { + const remainingSeconds = Math.max(0, Math.floor((expiresAt - now) / 1000)) + const minutes = Math.floor(remainingSeconds / 60) + const seconds = remainingSeconds % 60 + return `${minutes}:${seconds.toString().padStart(2, '0')}` +} + +function pokemonLabelText(entity: PokemonEntity, now: number): string { + const countdown = formatCountdown(entity.expiresAt, now) + return entity.iv === undefined ? countdown : `${entity.iv}% ${countdown}` +} + +/** + * Markers for every visible pokemon. `getIcon` calls `getIconFor` per + * entity rather than pre-drawing the whole array: the atlas's own LRU is + * what keeps that cheap on a hit, and staying entity-in-descriptor-out here + * keeps this layer ignorant of the drawing/caching machinery entirely. + */ +export function buildPokemonIconLayer( + pokemon: readonly PokemonEntity[], + getIconFor: (entity: PokemonEntity) => IconDescriptor, +): IconLayer { + return new IconLayer({ + id: POKEMON_ICON_LAYER_ID, + data: pokemon, + pickable: true, + getPosition: (entity) => [entity.lon, entity.lat], + getIcon: (entity) => { + const icon = getIconFor(entity) + return { + id: icon.id, + url: icon.url, + width: icon.width, + height: icon.height, + } + }, + getSize: 32, + }) +} + +/** + * Countdown (and, when present, IV) text for every visible pokemon. This is + * plain layer data: one `TextLayer` covering the whole array, rebuilt with + * a fresh `now` once a second by the caller. A `TextLayer` diffs its data + * array itself and only touches the instances whose text actually changed, + * which is the entire reason this exists instead of a per-marker timer + * component - thousands of those is exactly the pattern this rewrite + * replaces. + */ +export function buildPokemonTextLayer( + pokemon: readonly PokemonEntity[], + now: number, +): TextLayer { + return new TextLayer({ + id: POKEMON_LABEL_LAYER_ID, + data: pokemon, + pickable: false, + getPosition: (entity) => [entity.lon, entity.lat], + getText: (entity) => pokemonLabelText(entity, now), + getSize: 12, + getPixelOffset: [0, 20], + background: true, + getBackgroundColor: [0, 0, 0, 140], + }) +} + +/** + * Markers for every visible gym. `getGymIcon` supplies one shared mask + * icon (a gym has no per-entity appearance the way a pokemon does, so + * there is nothing here for Task 2's atlas to key on); `getColor` tints + * that mask per gym from `readTeamColor`, which is what actually carries + * the team's meaning. + */ +export function buildGymIconLayer( + gyms: readonly GymEntity[], + getGymIcon: () => IconDescriptor, + root?: HTMLElement, +): IconLayer { + return new IconLayer({ + id: GYM_ICON_LAYER_ID, + data: gyms, + pickable: true, + getPosition: (entity) => [entity.lon, entity.lat], + getIcon: () => { + const icon = getGymIcon() + return { + id: icon.id, + url: icon.url, + width: icon.width, + height: icon.height, + mask: true, + } + }, + getColor: (entity) => readTeamColor(entity.team, root), + getSize: 28, + }) +} + +export interface BuildMapLayersOptions { + pokemon: readonly PokemonEntity[] + gyms: readonly GymEntity[] + getIconFor: (entity: PokemonEntity) => IconDescriptor + getGymIcon: () => IconDescriptor + now: number + root?: HTMLElement +} + +/** + * All layers this task adds, in the order deck.gl should draw them: gyms + * and pokemon icons first, pokemon labels last so text always sits on top + * of the markers it describes. Draw order between layers is independent of + * `interleaved`, which only controls where the whole deck.gl stack sits + * relative to MapLibre's own street-label layer. + */ +export function buildMapLayers({ + pokemon, + gyms, + getIconFor, + getGymIcon, + now, + root, +}: BuildMapLayersOptions): Layer[] { + return [ + buildGymIconLayer(gyms, getGymIcon, root), + buildPokemonIconLayer(pokemon, getIconFor), + buildPokemonTextLayer(pokemon, now), + ] +} diff --git a/app/map/useMapLibre.test.ts b/app/map/useMapLibre.test.ts new file mode 100644 index 000000000..1ee28ad31 --- /dev/null +++ b/app/map/useMapLibre.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from 'bun:test' +import { IconLayer } from '@deck.gl/layers' +import { buildOverlayProps } from './useMapLibre' + +/* + * Mounting a real MapLibre map needs a WebGL context the test environment + * does not have, so `useMapLibre` itself is not exercised here. What is + * asserted instead is the one setting that determines whether markers + * render under or over MapLibre's street labels: `interleaved: true` on + * the props `MapboxOverlay` is constructed with. Getting it wrong renders + * without erroring, so this is the only automated guard against that + * regressing; whether the interleaving visually looks right still needs a + * browser (see task-4-report.md). + */ + +test('overlay props request interleaved rendering', () => { + const props = buildOverlayProps([]) + expect(props.interleaved).toBe(true) +}) + +test('overlay props carry whatever layers are passed through unchanged', () => { + const layer = new IconLayer({ + id: 'probe', + data: [], + getPosition: () => [0, 0], + getIcon: () => ({ id: 'x', url: 'u', width: 1, height: 1 }), + }) + const props = buildOverlayProps([layer]) + expect(props.layers).toEqual([layer]) +}) diff --git a/app/map/useMapLibre.ts b/app/map/useMapLibre.ts index 99891f933..25e810ddb 100644 --- a/app/map/useMapLibre.ts +++ b/app/map/useMapLibre.ts @@ -1,3 +1,6 @@ +import type { Layer } from '@deck.gl/core' +import type { MapboxOverlayProps } from '@deck.gl/mapbox' +import { MapboxOverlay } from '@deck.gl/mapbox' import { Map as MapLibreMap, NavigationControl } from 'maplibre-gl' import { type RefObject, useEffect, useRef } from 'react' import { resolveBasemapStyle } from './basemap' @@ -18,6 +21,17 @@ export interface UseMapLibreOptions { * pushed on `move` would make the back button useless after one pan. */ onCameraChange?: (camera: Camera) => void + /** + * deck.gl layers to render on top of the basemap, attached through a + * `MapboxOverlay` created with `interleaved: true`. Interleaved is not + * cosmetic: it is what places these layers beneath MapLibre's own street + * label layer instead of painting over it, and it is the reason this + * hook builds on MapLibre rather than deck.gl replacing it outright. + * Updates flow through `overlay.setProps` on every change of this array, + * independent of the mount effect below, so panning/zooming the camera + * never tears down and rebuilds the overlay. + */ + layers?: Layer[] } export interface UseMapLibreResult { @@ -25,6 +39,21 @@ export interface UseMapLibreResult { containerRef: RefObject } +/** + * Builds the props `MapboxOverlay` is constructed with. Pulled out as a + * plain function, rather than inlined in the mount effect, so its one + * load-bearing setting is assertable without a real WebGL context: + * `interleaved: true` is what places these layers beneath MapLibre's own + * street label layer instead of over it, and getting it wrong renders + * without erroring, so a test asserting the props object is the only + * automated guard against that regressing. Actual interleaving - markers + * visually sitting under labels - still needs a browser; see + * task-4-report.md. + */ +export function buildOverlayProps(layers: Layer[]): MapboxOverlayProps { + return { interleaved: true, layers } +} + /** * Owns one MapLibre instance for the lifetime of the component that calls * this hook: creates it against `containerRef`'s element on mount, tears it @@ -35,11 +64,17 @@ export interface UseMapLibreResult { export function useMapLibre({ initialCamera, onCameraChange, + layers, }: UseMapLibreOptions): UseMapLibreResult { const containerRef = useRef(null) const mapRef = useRef(null) + const overlayRef = useRef(null) const onCameraChangeRef = useRef(onCameraChange) onCameraChangeRef.current = onCameraChange + // Read by the mount effect below, once, so the overlay starts with + // whatever layers are already available instead of an empty frame while + // waiting for the layers-sync effect to run. + const initialLayersRef = useRef(layers) useEffect(() => { const container = containerRef.current @@ -54,6 +89,12 @@ export function useMapLibre({ }) map.addControl(new NavigationControl(), 'top-right') + const overlay = new MapboxOverlay( + buildOverlayProps(initialLayersRef.current ?? []), + ) + map.addControl(overlay) + overlayRef.current = overlay + const handleMoveEnd = () => { const center = map.getCenter() onCameraChangeRef.current?.({ @@ -67,7 +108,10 @@ export function useMapLibre({ mapRef.current = map return () => { map.off('moveend', handleMoveEnd) + // map.remove() tears down every control it holds, this overlay + // included, so there is nothing extra to release here. map.remove() + overlayRef.current = null mapRef.current = null } // Camera is only read once, to seed the map on creation; every camera @@ -76,5 +120,9 @@ export function useMapLibre({ // change would tear down and recreate the whole map on every pan. }, []) + useEffect(() => { + overlayRef.current?.setProps({ layers: layers ?? [] }) + }, [layers]) + return { containerRef } } diff --git a/bun.lock b/bun.lock index e328b99d2..f2e1c2753 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,9 @@ "@apollo/server": "5.5.1", "@as-integrations/express5": "1.1.2", "@base-ui/react": "1.7.0", + "@deck.gl/core": "9.3.10", + "@deck.gl/layers": "9.3.10", + "@deck.gl/mapbox": "9.3.10", "@emotion/react": "11.14.0", "@emotion/styled": "11.14.1", "@fontsource-variable/fredoka": "5.3.0", @@ -45,6 +48,7 @@ "cors": "^2.8.6", "date-fns": "^2.30.0", "date-fns-tz": "^2.0.0", + "deck.gl": "9.3.10", "discord.js": "^14.26.4", "dlv": "^1.1.3", "dotenv": "^16.3.1", @@ -223,6 +227,8 @@ "uuid": "11.1.1", }, "packages": { + "@amcharts/amcharts5": ["@amcharts/amcharts5@5.14.4", "", { "dependencies": { "@types/d3": "^7.0.0", "@types/d3-chord": "^3.0.0", "@types/d3-hierarchy": "3.1.1", "@types/d3-sankey": "^0.11.1", "@types/d3-shape": "^3.0.0", "@types/geojson": "^7946.0.8", "@types/polylabel": "^1.0.5", "@types/svg-arc-to-cubic-bezier": "^3.2.0", "d3": "^7.0.0", "d3-chord": "^3.0.0", "d3-force": "^3.0.0", "d3-geo": "^3.0.0", "d3-hierarchy": "^3.0.0", "d3-sankey": "^0.12.3", "d3-selection": "^3.0.0", "d3-shape": "^3.0.0", "d3-transition": "^3.0.0", "d3-voronoi-treemap": "^1.1.2", "flatpickr": "^4.6.13", "markerjs2": "^2.29.4", "pdfmake": "^0.2.2", "polylabel": "^1.1.0", "seedrandom": "^3.0.5", "svg-arc-to-cubic-bezier": "^3.2.0", "tslib": "^2.2.0" } }, "sha512-Tl7wQLWvsvyWVtlCIm1yhZtJviSDYjuNTnlUkO0D49GyByoK0nb9fr0DK4rUw4DVgyLcySxWBsb2lzTJm5Rd9Q=="], + "@apollo/cache-control-types": ["@apollo/cache-control-types@1.0.3", "", { "peerDependencies": { "graphql": "14.x || 15.x || 16.x" } }, "sha512-F17/vCp7QVwom9eG7ToauIKdAxpSoadsJnqIfyryLFSkLSOEqu+eC5Z3N8OXcUVStuOMcNHlyraRsA6rRICu4g=="], "@apollo/client": ["@apollo/client@3.14.1", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@wry/caches": "^1.0.0", "@wry/equality": "^0.5.6", "@wry/trie": "^0.5.0", "graphql-tag": "^2.12.6", "hoist-non-react-statics": "^3.3.2", "optimism": "^0.18.0", "prop-types": "^15.7.2", "rehackt": "^0.1.0", "symbol-observable": "^4.0.0", "ts-invariant": "^0.10.3", "tslib": "^2.3.0", "zen-observable-ts": "^1.2.5" }, "peerDependencies": { "graphql": "^15.0.0 || ^16.0.0", "graphql-ws": "^5.5.5 || ^6.0.3", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc", "subscriptions-transport-ws": "^0.9.0 || ^0.11.0" }, "optionalPeers": ["graphql-ws", "react", "react-dom", "subscriptions-transport-ws"] }, "sha512-SgGX6E23JsZhUdG2anxiyHvEvvN6CUaI4ZfMsndZFeuHPXL3H0IsaiNAhLITSISbeyeYd+CBd9oERXQDdjXWZw=="], @@ -259,6 +265,12 @@ "@apollo/utils.withrequired": ["@apollo/utils.withrequired@3.0.0", "", {}, "sha512-aaxeavfJ+RHboh7c2ofO5HHtQobGX4AgUujXP4CXpREHp9fQ9jPi6K9T1jrAKe7HIipoP0OJ1gd6JamSkFIpvA=="], + "@arcgis/core": ["@arcgis/core@4.34.8", "", { "dependencies": { "@amcharts/amcharts5": "~5.14.1", "@arcgis/toolkit": "^4.34.0", "@esri/arcgis-html-sanitizer": "~4.1.0", "@esri/calcite-components": "^3.3.2", "@vaadin/grid": "~24.9.1", "@zip.js/zip.js": "~2.8.7", "luxon": "~3.7.2", "marked": "~16.3.0", "tslib": "^2.8.1" } }, "sha512-UrEBTjXpSA9fhmmnAENBzz9GG81xALTezQFMXUs2iMB+tiOckmJyBbhATI/W4lIQyUfNEK7Zm/46EP2PhDga/A=="], + + "@arcgis/lumina": ["@arcgis/lumina@4.34.9", "", { "dependencies": { "@arcgis/toolkit": "~4.34.9", "csstype": "^3.1.3", "tslib": "^2.8.1" }, "peerDependencies": { "@lit/context": "^1.1.5", "lit": "^3.3.0" }, "optionalPeers": ["@lit/context"] }, "sha512-efqO+SwR+1IYf29AATh1l2FUeypRyRINTBNkaJY+KkaFe+8gqSJ45qOmputhyzF5WTRDb7WhOYgnChjp6VYPpA=="], + + "@arcgis/toolkit": ["@arcgis/toolkit@4.34.9", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-wFST+eVnCwmg9NyICVyn9bsBnR+TlWklsGqG3L7xqSTgfXo6TuCThE7wtTb8xWxsTBkGvImqMUgpgLuwQuTQ1g=="], + "@as-integrations/express5": ["@as-integrations/express5@1.1.2", "", { "peerDependencies": { "@apollo/server": "^4.0.0 || ^5.0.0", "express": "^5.0.0" } }, "sha512-BxfwtcWNf2CELDkuPQxi5Zl3WqY/dQVJYafeCBOGoFQjv5M0fjhxmAFZ9vKx/5YKKNeok4UY6PkFbHzmQrdxIA=="], "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], @@ -341,6 +353,8 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.10", "", { "os": "win32", "cpu": "x64" }, "sha512-M+2dgBsl3lXRiTfgPVc2p3anS4Tocojke4rzFLScZ2Y/wmF+36dRb1iHCLiyGqOzQGyTplZH1HnEYviiAqi3nA=="], + "@carto/api-client": ["@carto/api-client@0.5.33", "", { "dependencies": { "@loaders.gl/schema": "^4.3.3", "@types/geojson": "^7946.0.16", "d3-format": "^3.1.2", "d3-scale": "^4.0.2", "h3-js": "^4.1.0", "jsep": "^1.4.0", "quadbin": "^0.4.1-alpha.0" } }, "sha512-WXVJUT9ljMTo1C+o9C6Dz5smsUUHyIXXJPJWiRoneuc41VBSdMWDsUp93g3Q7Wu8gI+2uWM4Xwby5jzjy92KUg=="], + "@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="], "@commitlint/cli": ["@commitlint/cli@19.8.1", "", { "dependencies": { "@commitlint/format": "^19.8.1", "@commitlint/lint": "^19.8.1", "@commitlint/load": "^19.8.1", "@commitlint/read": "^19.8.1", "@commitlint/types": "^19.8.1", "tinyexec": "^1.0.0", "yargs": "^17.0.0" }, "bin": { "commitlint": "./cli.js" } }, "sha512-LXUdNIkspyxrlV6VDHWBmCZRtkEVRpBKxi2Gtw3J54cGWhLCTouVD/Q6ZSaSvd2YaDObWK8mDjrz3TIKtaQMAA=="], @@ -379,6 +393,32 @@ "@date-fns/tz": ["@date-fns/tz@1.5.0", "", {}, "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg=="], + "@deck.gl/aggregation-layers": ["@deck.gl/aggregation-layers@9.3.10", "", { "dependencies": { "@luma.gl/shadertools": "^9.3.5", "@math.gl/core": "^4.1.0", "@math.gl/web-mercator": "^4.1.0", "d3-hexbin": "^0.2.1" }, "peerDependencies": { "@deck.gl/core": "~9.3.0", "@deck.gl/layers": "~9.3.0", "@luma.gl/core": "~9.3.5", "@luma.gl/engine": "~9.3.5" } }, "sha512-Fnb/Azwq9CHSP5sBgAewRbRTmHNrFiQEFZShlreEkzXDkv+nl8DJo2OzU51Uxo+m0JMO+bwYFNgQFV4Nc3DtfQ=="], + + "@deck.gl/arcgis": ["@deck.gl/arcgis@9.3.10", "", { "dependencies": { "esri-loader": "^3.7.0" }, "peerDependencies": { "@arcgis/core": "^4.0.0", "@deck.gl/core": "~9.3.0", "@luma.gl/core": "~9.3.5", "@luma.gl/engine": "~9.3.5", "@luma.gl/webgl": "~9.3.5" } }, "sha512-8XT1HWONOC6hA5V1kxNHiCGUeZYRexz8WY4KG7YWNIfM+yumqfXF1nJFd8DFLBWrv8yVyMeREX1VvLgBGniKFA=="], + + "@deck.gl/carto": ["@deck.gl/carto@9.3.10", "", { "dependencies": { "@carto/api-client": "^0.5.19", "@loaders.gl/compression": "^4.4.3", "@loaders.gl/gis": "^4.4.3", "@loaders.gl/loader-utils": "^4.4.3", "@loaders.gl/mvt": "^4.4.3", "@loaders.gl/schema": "^4.4.3", "@loaders.gl/tiles": "^4.4.3", "@luma.gl/core": "^9.3.5", "@luma.gl/shadertools": "^9.3.5", "@math.gl/web-mercator": "^4.1.0", "@types/d3-array": "^3.0.2", "@types/d3-color": "^3.1.3", "@types/d3-scale": "^3.0.0", "cartocolor": "^5.0.2", "d3-array": "^3.2.0", "d3-color": "^3.1.0", "d3-format": "^3.1.0", "d3-scale": "^4.0.0", "earcut": "^2.2.4", "h3-js": "^4.4.0", "moment-timezone": "^0.6.0", "pbf": "^3.2.1", "quadbin": "^0.4.0" }, "peerDependencies": { "@deck.gl/aggregation-layers": "~9.3.0", "@deck.gl/core": "~9.3.0", "@deck.gl/extensions": "~9.3.0", "@deck.gl/geo-layers": "~9.3.0", "@deck.gl/layers": "~9.3.0", "@loaders.gl/core": "^4.4.3" } }, "sha512-YMXnScpqP2rXfzjrwn19l8HFZathH16e9YkZ8ZxyPNUkfC6CC67V/+NQGwQuWGodhChAEgpKjR3kofSirTGQAg=="], + + "@deck.gl/core": ["@deck.gl/core@9.3.10", "", { "dependencies": { "@loaders.gl/core": "^4.4.3", "@loaders.gl/images": "^4.4.3", "@luma.gl/core": "^9.3.5", "@luma.gl/engine": "^9.3.5", "@luma.gl/shadertools": "^9.3.5", "@luma.gl/webgl": "^9.3.5", "@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.1", "@probe.gl/log": "^4.1.1", "@probe.gl/stats": "^4.1.1", "@types/offscreencanvas": "^2019.6.4", "gl-matrix": "^3.0.0", "mjolnir.js": "^3.0.0" } }, "sha512-BAJ6r+eRV4euuGcIi2SnlzrodgZ+sD2VuXsoL1DER0LwwKXmKmQu6WA8ax6DB9KFsBghLVoTUN5zaCGF6uKHuQ=="], + + "@deck.gl/extensions": ["@deck.gl/extensions@9.3.10", "", { "dependencies": { "@luma.gl/shadertools": "^9.3.5", "@luma.gl/webgl": "^9.3.5", "@math.gl/core": "^4.1.0" }, "peerDependencies": { "@deck.gl/core": "~9.3.0", "@luma.gl/core": "~9.3.5", "@luma.gl/engine": "~9.3.5" } }, "sha512-VT5NxR+SgUCwvaWeKFKkql5HV90KjyiPyXYlzKfxxXoE8wxlOgt5Enl2MTAOLcIhlCymLp9vtWO/HEgTfvlzDw=="], + + "@deck.gl/geo-layers": ["@deck.gl/geo-layers@9.3.10", "", { "dependencies": { "@loaders.gl/3d-tiles": "^4.4.3", "@loaders.gl/gis": "^4.4.3", "@loaders.gl/loader-utils": "^4.4.3", "@loaders.gl/mvt": "^4.4.3", "@loaders.gl/schema": "^4.4.3", "@loaders.gl/terrain": "^4.4.3", "@loaders.gl/tiles": "^4.4.3", "@loaders.gl/wms": "^4.4.3", "@luma.gl/gltf": "^9.3.5", "@luma.gl/shadertools": "^9.3.5", "@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.7.2", "h3-js": "^4.4.0", "long": "^3.2.0" }, "peerDependencies": { "@deck.gl/core": "~9.3.0", "@deck.gl/extensions": "~9.3.0", "@deck.gl/layers": "~9.3.0", "@deck.gl/mesh-layers": "~9.3.0", "@loaders.gl/core": "^4.4.3", "@luma.gl/core": "~9.3.5", "@luma.gl/engine": "~9.3.5" } }, "sha512-MH6/MyWTiJ02yxrkMz6bMP1Z8d13JEmxogwZmWx2I9XLDNpCnLxnXElnVxxvDvhIbWdWq/i/xl8Kn9WOLiywBg=="], + + "@deck.gl/google-maps": ["@deck.gl/google-maps@9.3.10", "", { "dependencies": { "@luma.gl/webgl": "^9.3.5", "@math.gl/core": "^4.1.0", "@types/google.maps": "^3.48.6" }, "peerDependencies": { "@deck.gl/core": "~9.3.0", "@luma.gl/core": "~9.3.5" } }, "sha512-S84742VZ0SAdYGNVp4C4qHHeuREFLoUZHc9AP3boC69lgosOrHGHNK35zRr2sFYJNhJBLNTiTmPAHdUjIZ4yHw=="], + + "@deck.gl/json": ["@deck.gl/json@9.3.10", "", { "dependencies": { "jsep": "^0.3.0" }, "peerDependencies": { "@deck.gl/core": "~9.3.0" } }, "sha512-MhKcaaGVfpnPlHwLDp1o0MHCcq+JW5SOR4f5ZYriSHKKVG1gK1+xvI/2eos4Et2sLAtuS3r4OwaXHw8AjWnEEg=="], + + "@deck.gl/layers": ["@deck.gl/layers@9.3.10", "", { "dependencies": { "@loaders.gl/images": "^4.4.3", "@loaders.gl/schema": "^4.4.3", "@luma.gl/shadertools": "^9.3.5", "@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", "@types/geojson": "^7946.0.16", "earcut": "^2.2.4" }, "peerDependencies": { "@deck.gl/core": "~9.3.0", "@loaders.gl/core": "^4.4.3", "@luma.gl/core": "~9.3.5", "@luma.gl/engine": "~9.3.5" } }, "sha512-F9CRJlRWXvTKlgjKmmTotm0cA5V7I1zMtdnwJbjBZTyCJ4skuskbIIt6f+BSQ+rSxM2uW4q5/8VkcCJ2SePhXg=="], + + "@deck.gl/mapbox": ["@deck.gl/mapbox@9.3.10", "", { "dependencies": { "@math.gl/web-mercator": "^4.1.0" }, "peerDependencies": { "@deck.gl/core": "~9.3.0", "@luma.gl/core": "~9.3.5" } }, "sha512-go5+imfjdq/+P5ELMX88ncuzNSpUpL4wolV7LbegwXnKcBIgU13IKbCxJnoGCSW8oxHZu+J99sGDcDyvkYtZcw=="], + + "@deck.gl/mesh-layers": ["@deck.gl/mesh-layers@9.3.10", "", { "dependencies": { "@loaders.gl/gltf": "^4.4.3", "@loaders.gl/schema": "^4.4.3", "@luma.gl/gltf": "^9.3.5", "@luma.gl/shadertools": "^9.3.5" }, "peerDependencies": { "@deck.gl/core": "~9.3.0", "@luma.gl/core": "~9.3.5", "@luma.gl/engine": "~9.3.5" } }, "sha512-HzExbHWQ05fU7YgXcCQPHjHKoMc2PTWUTu3THzdXc5HXhGqpiAd/X1YA7McA9fRJBjw1yD36otMwGEXqUOEKAA=="], + + "@deck.gl/react": ["@deck.gl/react@9.3.10", "", { "peerDependencies": { "@deck.gl/core": "~9.3.0", "@deck.gl/widgets": "~9.3.0", "react": ">=16.3.0", "react-dom": ">=16.3.0" } }, "sha512-kbfceQ6uZxFS5DVwM3JLY1gSVfx+IJ8ihnkSomxX5Nhhfos1GWnH5wJo8wznKGl9WdLd/iY5bHc8q9i0f5TVjA=="], + + "@deck.gl/widgets": ["@deck.gl/widgets@9.3.10", "", { "dependencies": { "@floating-ui/dom": "^1.7.5", "preact": "^10.17.0" }, "peerDependencies": { "@deck.gl/core": "~9.3.0", "@luma.gl/core": "~9.3.5" } }, "sha512-eEQuxQeqIkm04d9MgUH0MtGTmW3xSvqS/nkyIGPhaoAj6VWGu850JfXVdQbnORxFweoQWPI1jfU0Q8j8twhkEA=="], + "@discordjs/builders": ["@discordjs/builders@1.14.1", "", { "dependencies": { "@discordjs/formatters": "^0.6.2", "@discordjs/util": "^1.2.0", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.38.40", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" } }, "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ=="], "@discordjs/collection": ["@discordjs/collection@1.5.3", "", {}, "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ=="], @@ -481,6 +521,12 @@ "@eslint/js": ["@eslint/js@8.57.1", "", {}, "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q=="], + "@esri/arcgis-html-sanitizer": ["@esri/arcgis-html-sanitizer@4.1.0", "", { "dependencies": { "xss": "1.0.13" } }, "sha512-einEveDJ/k1180NOp78PB/4Hje9eBy3dyOGLLtLn6bSkizpUfCwuYBIXOA7Y3F/k/BsTQXgKqUVwQ0eiscWMdA=="], + + "@esri/calcite-components": ["@esri/calcite-components@3.3.3", "", { "dependencies": { "@arcgis/lumina": ">=4.34.0-next.158 <4.35.0", "@arcgis/toolkit": ">=4.34.0-next.158 <4.35.0", "@esri/calcite-ui-icons": "4.3.0", "@floating-ui/dom": "^1.6.12", "@floating-ui/utils": "^0.2.8", "@types/sortablejs": "^1.15.8", "color": "^5.0.0", "composed-offset-position": "^0.0.6", "es-toolkit": "^1.39.8", "focus-trap": "^7.6.5", "interactjs": "^1.10.27", "lit": "^3.3.0", "sortablejs": "^1.15.6", "timezone-groups": "^0.10.4", "type-fest": "^4.30.1" } }, "sha512-tw+EfJ3pb+Odj71W6E9GUkm8rMbNxfW1KeiI8GgsKDzhr39hMKwY+zYYFFYuO0FONxWGvAB+B8yqB0NvH7WeHw=="], + + "@esri/calcite-ui-icons": ["@esri/calcite-ui-icons@4.3.0", "", { "bin": { "spriter": "bin/spriter.js" } }, "sha512-iOOuRurpjFxFVw6+aXW2JpSkRBrdOpBcbdibfPOmSPqMd1aoHBtYmYXetKoH9vfrXoBiPyO2PkDnczhsu/N9IA=="], + "@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="], "@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="], @@ -489,6 +535,14 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="], + "@foliojs-fork/fontkit": ["@foliojs-fork/fontkit@1.9.2", "", { "dependencies": { "@foliojs-fork/restructure": "^2.0.2", "brotli": "^1.2.0", "clone": "^1.0.4", "deep-equal": "^1.0.0", "dfa": "^1.2.0", "tiny-inflate": "^1.0.2", "unicode-properties": "^1.2.2", "unicode-trie": "^2.0.0" } }, "sha512-IfB5EiIb+GZk+77TRB86AHroVaqfq8JRFlUbz0WEwsInyCG0epX2tCPOy+UfaWPju30DeVoUAXfzWXmhn753KA=="], + + "@foliojs-fork/linebreak": ["@foliojs-fork/linebreak@1.1.2", "", { "dependencies": { "base64-js": "1.3.1", "unicode-trie": "^2.0.0" } }, "sha512-ZPohpxxbuKNE0l/5iBJnOAfUaMACwvUIKCvqtWGKIMv1lPYoNjYXRfhi9FeeV9McBkBLxsMFWTVVhHJA8cyzvg=="], + + "@foliojs-fork/pdfkit": ["@foliojs-fork/pdfkit@0.15.3", "", { "dependencies": { "@foliojs-fork/fontkit": "^1.9.2", "@foliojs-fork/linebreak": "^1.1.1", "crypto-js": "^4.2.0", "jpeg-exif": "^1.1.4", "png-js": "^1.0.0" } }, "sha512-Obc0Wmy3bm7BINFVvPhcl2rnSSK61DQrlHU8aXnAqDk9LCjWdUOPwhgD8Ywz5VtuFjRxmVOM/kQ/XLIBjDvltw=="], + + "@foliojs-fork/restructure": ["@foliojs-fork/restructure@2.0.2", "", {}, "sha512-59SgoZ3EXbkfSX7b63tsou/SDGzwUEK6MuB5sKqgVK1/XE0fxmpsOb9DQI8LXW3KfGnAjImCGhhEb7uPPAUVNA=="], + "@fontsource-variable/fredoka": ["@fontsource-variable/fredoka@5.3.0", "", {}, "sha512-13w98iw2RcmYfZQRZPLSu+0YJK3HHm5A2XrpU26moFIAePmfiKfvkmEJUgkc8r5GNR+pbDijOueS1KdGAMAWmQ=="], "@fontsource-variable/nunito": ["@fontsource-variable/nunito@5.3.0", "", {}, "sha512-tw0ch7evIDSwoeUy+0XzocYHfbRWLeCRPJy22KgbrLjDLAbNw/fErBiil10Ssrs3Sjr55zaZrr36a975j8cBkQ=="], @@ -519,6 +573,8 @@ "@inquirer/external-editor": ["@inquirer/external-editor@1.0.3", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA=="], + "@interactjs/types": ["@interactjs/types@1.10.28", "", {}, "sha512-vPmu4HWmsg0Fub3BPKGk7DuozkhgVK84TVkziY47v8Uh0A14gph55/vVRaQN4oZov0lGNJNK6Jyddim2qd/4vw=="], + "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], @@ -533,8 +589,66 @@ "@keyv/serialize": ["@keyv/serialize@1.1.1", "", {}, "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA=="], + "@lit-labs/ssr-dom-shim": ["@lit-labs/ssr-dom-shim@1.6.0", "", {}, "sha512-VHb0ALPMTlgKjM6yIxxoQNnpKyUKLD04VzeQdsiXkMqkvYlAHxq9glGLmgbb889/1GsohSOAjvQYoiBppXFqrQ=="], + + "@lit/reactive-element": ["@lit/reactive-element@2.1.2", "", { "dependencies": { "@lit-labs/ssr-dom-shim": "^1.5.0" } }, "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A=="], + + "@loaders.gl/3d-tiles": ["@loaders.gl/3d-tiles@4.4.5", "", { "dependencies": { "@loaders.gl/compression": "4.4.5", "@loaders.gl/crypto": "4.4.5", "@loaders.gl/draco": "4.4.5", "@loaders.gl/gltf": "4.4.5", "@loaders.gl/images": "4.4.5", "@loaders.gl/loader-utils": "4.4.5", "@loaders.gl/math": "4.4.5", "@loaders.gl/tiles": "4.4.5", "@loaders.gl/zip": "4.4.5", "@math.gl/core": "^4.1.0", "@math.gl/culling": "^4.1.0", "@math.gl/geospatial": "^4.1.0", "@probe.gl/log": "^4.1.1", "long": "^5.2.1" }, "peerDependencies": { "@loaders.gl/core": "~4.4.0" } }, "sha512-+W9ncpVw5+OX9pjhZFmpbK+pBqT1XNCAaUbUY/qItFlK1PcTSQdy34NVvAziRR7dFxJ0VmWPEu5Q75LcNCCX2A=="], + + "@loaders.gl/compression": ["@loaders.gl/compression@4.4.5", "", { "dependencies": { "@loaders.gl/loader-utils": "4.4.5", "@loaders.gl/worker-utils": "4.4.5", "@types/pako": "^1.0.1", "fflate": "0.7.4", "pako": "1.0.11", "snappyjs": "^0.6.1" }, "optionalDependencies": { "@types/brotli": "^1.3.0", "brotli": "^1.3.2", "lz4js": "^0.2.0", "zstd-codec": "^0.1" }, "peerDependencies": { "@loaders.gl/core": "~4.4.0" } }, "sha512-vsuvdIyn3xLqiJxXhK970ENO3T8bLPtmM0jka8/y0zVAEover6q3Vd8gVsaqgWGfc28cWx5RYI4zKhJzRcTHNg=="], + + "@loaders.gl/core": ["@loaders.gl/core@4.4.5", "", { "dependencies": { "@loaders.gl/loader-utils": "4.4.5", "@loaders.gl/schema": "4.4.5", "@loaders.gl/schema-utils": "4.4.5", "@loaders.gl/worker-utils": "4.4.5", "@probe.gl/log": "^4.1.1" } }, "sha512-hlpE200MinDAyU0kMjEM4yk+0TDxiB9nT+GcwOCrXc7buE0e0YwTn+Ynz2Q85dwDKvJZw3kNHTTBGvQwq9533A=="], + + "@loaders.gl/crypto": ["@loaders.gl/crypto@4.4.5", "", { "dependencies": { "@loaders.gl/loader-utils": "4.4.5", "@loaders.gl/worker-utils": "4.4.5", "@types/crypto-js": "^4.0.2" }, "peerDependencies": { "@loaders.gl/core": "~4.4.0" } }, "sha512-P0n43KWN7GGsWF7+E094Nj1mwngN6ypfgts5tu2hSEroUS5dB01TgDa0Pr3sVeZ672jFSKKRgRuFDazRcITOFA=="], + + "@loaders.gl/draco": ["@loaders.gl/draco@4.4.5", "", { "dependencies": { "@loaders.gl/loader-utils": "4.4.5", "@loaders.gl/schema": "4.4.5", "@loaders.gl/schema-utils": "4.4.5", "@loaders.gl/worker-utils": "4.4.5", "draco3d": "1.5.7" }, "peerDependencies": { "@loaders.gl/core": "~4.4.0" } }, "sha512-J4DuzoUQNJhjxmErCArF5J1PC0LCMbhPkY/YdkTaDmUwxSQdh1M/VWg85AXKLAxUiwGkhmC8AUUaSyPxTpCUrQ=="], + + "@loaders.gl/geoarrow": ["@loaders.gl/geoarrow@4.4.5", "", { "dependencies": { "@math.gl/polygon": "^4.1.0", "apache-arrow": ">= 17.0.0" }, "peerDependencies": { "@loaders.gl/core": "~4.4.0" } }, "sha512-uUWp+5nZxQe6Sv/o36R6jcmCCNhguY0VhjougyMoH6FWqF6iJHrOnwx5UdEtvkw/zV9mHabs05Hv91dBvhfljQ=="], + + "@loaders.gl/gis": ["@loaders.gl/gis@4.4.5", "", { "dependencies": { "@loaders.gl/geoarrow": "4.4.5", "@loaders.gl/loader-utils": "4.4.5", "@loaders.gl/schema": "4.4.5", "@loaders.gl/schema-utils": "4.4.5", "@mapbox/vector-tile": "^1.3.1", "@math.gl/polygon": "^4.1.0", "pbf": "^3.2.1" }, "peerDependencies": { "@loaders.gl/core": "~4.4.0" } }, "sha512-r3klnZ6jjzaEZlUSCmGufPbH1pQpM5PCcGS70zLlGIFbpJPo80gaXDtZDVOSQvpWmqY8u8d7/6moAsogVoymzA=="], + + "@loaders.gl/gltf": ["@loaders.gl/gltf@4.4.5", "", { "dependencies": { "@loaders.gl/draco": "4.4.5", "@loaders.gl/images": "4.4.5", "@loaders.gl/loader-utils": "4.4.5", "@loaders.gl/schema": "4.4.5", "@loaders.gl/textures": "4.4.5", "@math.gl/core": "^4.1.0", "meshoptimizer": "^1.2.0" }, "peerDependencies": { "@loaders.gl/core": "~4.4.0" } }, "sha512-YIhgeoDYdRbfXQS+cpRWYbiRiiaajF5UCqpEoFpDEpOpcW6pKC2sfp5vZ1LiUIXu2CVv/HFJaDeLuQjeX5uVlQ=="], + + "@loaders.gl/images": ["@loaders.gl/images@4.4.5", "", { "dependencies": { "@loaders.gl/loader-utils": "4.4.5" }, "peerDependencies": { "@loaders.gl/core": "~4.4.0" } }, "sha512-7/odHt5n67OVLHelTm1euNNw5gCwycezQI7Wg3NBUg7q3bHWmTPY++Z4uz2egiuzJNY3GLegfg8AI1c3fD64Jw=="], + + "@loaders.gl/loader-utils": ["@loaders.gl/loader-utils@4.4.5", "", { "dependencies": { "@loaders.gl/schema": "4.4.5", "@loaders.gl/worker-utils": "4.4.5", "@probe.gl/log": "^4.1.1", "@probe.gl/stats": "^4.1.1" } }, "sha512-c4h2RUMEru+hbfvgZEIy5cLQKyQka2TPQZW/2Wy+yIPr3FU7RKwfp+vLwIfir6Xkdn2zniWyWretZwE02aY/Ug=="], + + "@loaders.gl/math": ["@loaders.gl/math@4.4.5", "", { "dependencies": { "@math.gl/core": "^4.1.0" }, "peerDependencies": { "@loaders.gl/core": "~4.4.0" } }, "sha512-Az7rMw7oMNMzi1bXgPaabdkpPtk0safkWBr+I+dHjZHzny1RK5mFF4VjRkalw++DZBKbL0TskMw/OcdiRaL/Bw=="], + + "@loaders.gl/mvt": ["@loaders.gl/mvt@4.4.5", "", { "dependencies": { "@loaders.gl/gis": "4.4.5", "@loaders.gl/images": "4.4.5", "@loaders.gl/loader-utils": "4.4.5", "@loaders.gl/schema": "4.4.5", "@loaders.gl/schema-utils": "4.4.5", "@math.gl/polygon": "^4.1.0", "@probe.gl/stats": "^4.1.1", "pbf": "^3.2.1" }, "peerDependencies": { "@loaders.gl/core": "~4.4.0" } }, "sha512-3c4ojvF6+Fb0XjX2xGeRKIUAshaNgBfi0VkszooFNCNvrVv7faGcn69jI9wh1Pjf2ge1PtsZnAoOuLQXLLzWSQ=="], + + "@loaders.gl/schema": ["@loaders.gl/schema@4.4.5", "", { "dependencies": { "@types/geojson": "^7946.0.7", "apache-arrow": ">= 17.0.0" } }, "sha512-q2XNquegw+hX+ZEshacKYL/+FKBUIS5FHA+2Ft3JWmcNiR6umGnYCdXQhDcFCqAm8Ks87fMCV+z/zTloxaIZgQ=="], + + "@loaders.gl/schema-utils": ["@loaders.gl/schema-utils@4.4.5", "", { "dependencies": { "@loaders.gl/schema": "4.4.5", "@math.gl/types": "^4.1.0", "@types/geojson": "^7946.0.7", "apache-arrow": ">= 17.0.0" }, "peerDependencies": { "@loaders.gl/core": "~4.4.0" } }, "sha512-vi6AROLWvoLIN609tVmchwotTaHK5rHv8SWjoVUWLhPCXmIc9npz0rCl7NJScCgr7UjYifZG1rWOnrAxaYTlyA=="], + + "@loaders.gl/terrain": ["@loaders.gl/terrain@4.4.5", "", { "dependencies": { "@loaders.gl/images": "4.4.5", "@loaders.gl/loader-utils": "4.4.5", "@loaders.gl/schema": "4.4.5", "@mapbox/martini": "^0.2.0" }, "peerDependencies": { "@loaders.gl/core": "~4.4.0" } }, "sha512-H8BED2/0I1wrviE8mBr1ny/01nK1vqctMHBYSoAHS36JJm5m+Tt2JfKXIgnNY2TU3Ulfr9L9x1VfH9a/z9V1jw=="], + + "@loaders.gl/textures": ["@loaders.gl/textures@4.4.5", "", { "dependencies": { "@loaders.gl/images": "4.4.5", "@loaders.gl/loader-utils": "4.4.5", "@loaders.gl/schema": "4.4.5", "@loaders.gl/worker-utils": "4.4.5", "@math.gl/types": "^4.1.0", "ktx-parse": "^0.7.0", "texture-compressor": "^1.0.2" }, "peerDependencies": { "@loaders.gl/core": "~4.4.0" } }, "sha512-zB9xMIKiU3tQN6q5/A6yaXw/DbodA+bqMDTVu7pzk4lw4zcBwWiGtD5LV0QjgtlTj2ZnwV8cCd1rYk9bwiX2Bw=="], + + "@loaders.gl/tiles": ["@loaders.gl/tiles@4.4.5", "", { "dependencies": { "@loaders.gl/loader-utils": "4.4.5", "@loaders.gl/math": "4.4.5", "@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.1.1" }, "peerDependencies": { "@loaders.gl/core": "~4.4.0" } }, "sha512-XO9NeUsukjj+90YN1GEJWOgt7rI5GIv20dCpPIYdgj299aa8iYB5DWcm1YGcOuNjGqdQNZXIgPSv6ocdN/X7Mw=="], + + "@loaders.gl/wms": ["@loaders.gl/wms@4.4.5", "", { "dependencies": { "@loaders.gl/images": "4.4.5", "@loaders.gl/loader-utils": "4.4.5", "@loaders.gl/schema": "4.4.5", "@loaders.gl/xml": "4.4.5", "@turf/rewind": "^5.1.5", "deep-strict-equal": "^0.2.0" }, "peerDependencies": { "@loaders.gl/core": "~4.4.0" } }, "sha512-0+nXHY017bAl3tELIefUa0FB3ptUpEVxtQD1imNqyUtRr1vrzhyc+kbBcc3uRIcDj/F6iW9pq2V6lBwcIGZXFw=="], + + "@loaders.gl/worker-utils": ["@loaders.gl/worker-utils@4.4.5", "", { "peerDependencies": { "@loaders.gl/core": "~4.4.0" } }, "sha512-ZQDoM6f/HpZ3Lzj6vjtPHXSvi+cUK0QG7yJz+MKWAokmeWa98WMZwswS8zkn/qNkY95rLcxh66N4Sl96+7hqAQ=="], + + "@loaders.gl/xml": ["@loaders.gl/xml@4.4.5", "", { "dependencies": { "@loaders.gl/loader-utils": "4.4.5", "@loaders.gl/schema": "4.4.5", "fast-xml-parser": "^5.3.6" }, "peerDependencies": { "@loaders.gl/core": "~4.4.0" } }, "sha512-UsN7T9e67Sa1Q6Ra/u+j2mtaT9GTcFhH8iz8ba5QFU90YrRn3dwBEwq9SVxQiJrfC6dE4jFdMannW5mzeV5owg=="], + + "@loaders.gl/zip": ["@loaders.gl/zip@4.4.5", "", { "dependencies": { "@loaders.gl/compression": "4.4.5", "@loaders.gl/crypto": "4.4.5", "@loaders.gl/loader-utils": "4.4.5", "jszip": "^3.1.5", "md5": "^2.3.0" }, "peerDependencies": { "@loaders.gl/core": "~4.4.0" } }, "sha512-vXlwnJ2QCCtXc8Pf55YJUNTJ0sjtjbUr34StDY91GPmoutdAXovqikuwzehrAlMDE69ldbnMtVATmNe4XeWGXQ=="], + + "@luma.gl/core": ["@luma.gl/core@9.3.6", "", { "dependencies": { "@math.gl/types": "^4.1.0", "@probe.gl/env": "^4.1.1", "@probe.gl/log": "^4.1.1", "@probe.gl/stats": "^4.1.1", "@types/offscreencanvas": "^2019.7.3" } }, "sha512-eqHnCPh2xHYkdd9rEkiIIkGMixNiJkq1ROrnTob6npvmZNVHXL0ubIw5KueL7rqW0+J1tm+p2+TXZaCmn0sP7Q=="], + + "@luma.gl/engine": ["@luma.gl/engine@9.3.6", "", { "dependencies": { "@math.gl/core": "^4.1.0", "@math.gl/types": "^4.1.0", "@probe.gl/log": "^4.1.1", "@probe.gl/stats": "^4.1.1" }, "peerDependencies": { "@luma.gl/core": "~9.3.0", "@luma.gl/shadertools": "~9.3.0" } }, "sha512-NYTdhn2NaH/MjN8qXCl2ukrT1Ac9iTKu1mgN0wz11XRd1QYnlMNBXswRROD7IZ2PUsBWdmYHc9qE8wKBQqMuIA=="], + + "@luma.gl/gltf": ["@luma.gl/gltf@9.3.6", "", { "dependencies": { "@loaders.gl/core": "~4.4.0", "@loaders.gl/gltf": "~4.4.0", "@loaders.gl/textures": "~4.4.0", "@math.gl/core": "^4.1.0" }, "peerDependencies": { "@luma.gl/core": "~9.3.0", "@luma.gl/engine": "~9.3.0", "@luma.gl/shadertools": "~9.3.0" } }, "sha512-9R27aYwvu6KBJzd9Kxs4cqIJpgJsI1AyL6Pn6+5CrmGbaCqMtgWi7Sf9Wo+bqv0PRxwzH0j1oAv7qRk0hGbstw=="], + + "@luma.gl/shadertools": ["@luma.gl/shadertools@9.3.6", "", { "dependencies": { "@math.gl/core": "^4.1.0", "@math.gl/types": "^4.1.0" }, "peerDependencies": { "@luma.gl/core": "~9.3.0" } }, "sha512-dDuD8lCOkAE1L7hJGZEyZAi7upR1HG3wEEIQ1gCLaTTbJWC7tNtnVz853lqUA+VXgjgS9NiQFFRcosiIZmW4Vg=="], + + "@luma.gl/webgl": ["@luma.gl/webgl@9.3.6", "", { "dependencies": { "@math.gl/types": "^4.1.0", "@probe.gl/env": "^4.1.1" }, "peerDependencies": { "@luma.gl/core": "~9.3.0" } }, "sha512-tGm7FGWPmJKxGZMvkRPRi219x3tKmBxR7Uke09nTp2ujNak/O5V9xPIQ9lUBGTxqAb8U2yjKkXG8j+f/4b2+sw=="], + "@mapbox/jsonlint-lines-primitives": ["@mapbox/jsonlint-lines-primitives@2.0.3", "", {}, "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg=="], + "@mapbox/martini": ["@mapbox/martini@0.2.0", "", {}, "sha512-7hFhtkb0KTLEls+TRw/rWayq5EeHtTaErgm/NskVoXmtgAQu/9D299aeyj6mzAR/6XUnYRp2lU+4IcrYRFjVsQ=="], + "@mapbox/point-geometry": ["@mapbox/point-geometry@1.1.0", "", {}, "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ=="], "@mapbox/tiny-sdf": ["@mapbox/tiny-sdf@2.2.0", "", {}, "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg=="], @@ -551,6 +665,20 @@ "@maplibre/vt-pbf": ["@maplibre/vt-pbf@4.3.2", "", { "dependencies": { "@mapbox/point-geometry": "^1.1.0", "@types/geojson": "^7946.0.16", "pbf": "^5.1.0" } }, "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw=="], + "@math.gl/core": ["@math.gl/core@4.1.0", "", { "dependencies": { "@math.gl/types": "4.1.0" } }, "sha512-FrdHBCVG3QdrworwrUSzXIaK+/9OCRLscxI2OUy6sLOHyHgBMyfnEGs99/m3KNvs+95BsnQLWklVfpKfQzfwKA=="], + + "@math.gl/culling": ["@math.gl/culling@4.1.0", "", { "dependencies": { "@math.gl/core": "4.1.0", "@math.gl/types": "4.1.0" } }, "sha512-jFmjFEACnP9kVl8qhZxFNhCyd47qPfSVmSvvjR0/dIL6R9oD5zhR1ub2gN16eKDO/UM7JF9OHKU3EBIfeR7gtg=="], + + "@math.gl/geospatial": ["@math.gl/geospatial@4.1.0", "", { "dependencies": { "@math.gl/core": "4.1.0", "@math.gl/types": "4.1.0" } }, "sha512-BzsUhpVvnmleyYF6qdqJIip6FtIzJmnWuPTGhlBuPzh7VBHLonCFSPtQpbkRuoyAlbSyaGXcVt6p6lm9eK2vtg=="], + + "@math.gl/polygon": ["@math.gl/polygon@4.1.0", "", { "dependencies": { "@math.gl/core": "4.1.0" } }, "sha512-YA/9PzaCRHbIP5/0E9uTYrqe+jsYTQoqoDWhf6/b0Ixz8bPZBaGDEafLg3z7ffBomZLacUty9U3TlPjqMtzPjA=="], + + "@math.gl/sun": ["@math.gl/sun@4.1.0", "", {}, "sha512-i3q6OCBLSZ5wgZVhXg+X7gsjY/TUtuFW/2KBiq/U1ypLso3S4sEykoU/MGjxUv1xiiGtr+v8TeMbO1OBIh/HmA=="], + + "@math.gl/types": ["@math.gl/types@4.1.0", "", {}, "sha512-clYZdHcmRvMzVK5fjeDkQlHUzXQSNdZ7s4xOqC3nJPgz4C/TZkUecTo9YS4PruZqtDda/ag4erndP0MIn40dGA=="], + + "@math.gl/web-mercator": ["@math.gl/web-mercator@4.1.0", "", { "dependencies": { "@math.gl/core": "4.1.0" } }, "sha512-HZo3vO5GCMkXJThxRJ5/QYUYRr3XumfT8CzNNCwoJfinxy5NtKUd7dusNTXn7yJ40UoB8FMIwkVwNlqaiRZZAw=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.30.0", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA=="], "@monaco-editor/loader": ["@monaco-editor/loader@1.7.0", "", { "dependencies": { "state-local": "^1.0.6" } }, "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA=="], @@ -581,6 +709,8 @@ "@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="], + "@nodable/entities": ["@nodable/entities@3.0.0", "", {}, "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], @@ -609,6 +739,8 @@ "@octokit/types": ["@octokit/types@13.10.0", "", { "dependencies": { "@octokit/openapi-types": "^24.2.0" } }, "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA=="], + "@open-wc/dedupe-mixin": ["@open-wc/dedupe-mixin@1.4.0", "", {}, "sha512-Sj7gKl1TLcDbF7B6KUhtvr+1UCxdhMbNY5KxdU5IfMFWqL8oy1ZeAcCANjoB1TL0AJTcPmcCFsCbHf8X2jGDUA=="], + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], "@pnpm/config.env-replace": ["@pnpm/config.env-replace@1.1.0", "", {}, "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w=="], @@ -617,8 +749,16 @@ "@pnpm/npm-conf": ["@pnpm/npm-conf@3.0.3", "", { "dependencies": { "@pnpm/config.env-replace": "^1.1.0", "@pnpm/network.ca-file": "^1.0.1", "config-chain": "^1.1.11" } }, "sha512-//0sR/cow/s4ICQaYoAobOl4aU8cjU6x/V24V7XkKotb9+O+3zySIYp146vpaobYHnxa4pZX8NkV54Z5AwbDKA=="], + "@polymer/polymer": ["@polymer/polymer@3.5.2", "", { "dependencies": { "@webcomponents/shadycss": "^1.9.1" } }, "sha512-fWwImY/UH4bb2534DVSaX+Azs2yKg8slkMBHOyGeU2kKx7Xmxp6Lee0jP8p6B3d7c1gFUPB2Z976dTUtX81pQA=="], + "@popperjs/core": ["@popperjs/core@2.11.8", "", {}, "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="], + "@probe.gl/env": ["@probe.gl/env@4.1.1", "", {}, "sha512-+68seNDMVsEegRB47pFA/Ws1Fjy8agcFYXxzorKToyPcD6zd+gZ5uhwoLd7TzsSw6Ydns//2KEszWn+EnNHTbA=="], + + "@probe.gl/log": ["@probe.gl/log@4.1.1", "", { "dependencies": { "@probe.gl/env": "4.1.1" } }, "sha512-kcZs9BT44pL7hS1OkRGKYRXI/SN9KejUlPD+BY40DguRLzdC5tLG/28WGMyfKdn/51GT4a0p+0P8xvDn1Ez+Kg=="], + + "@probe.gl/stats": ["@probe.gl/stats@4.1.1", "", {}, "sha512-4VpAyMHOqydSvPlEyHwXaE+AkIdR03nX+Qhlxsk2D/IW4OVmDZgIsvJB1cDzyEEtcfKcnaEbfXeiPgejBceT6g=="], + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], @@ -977,6 +1117,8 @@ "@turf/bbox": ["@turf/bbox@7.3.5", "", { "dependencies": { "@turf/helpers": "7.3.5", "@turf/meta": "7.3.5", "@types/geojson": "^7946.0.10", "tslib": "^2.8.1" } }, "sha512-oG1ya/HtBjAIg4TimbWx+nOYPbY0bCvt82Bq8tm6sBw3qqtbOyRSfDz79Sq90TnH7DXJprJ1qnVGKNtZ6jemfw=="], + "@turf/boolean-clockwise": ["@turf/boolean-clockwise@5.1.5", "", { "dependencies": { "@turf/helpers": "^5.1.5", "@turf/invariant": "^5.1.5" } }, "sha512-FqbmEEOJ4rU4/2t7FKx0HUWmjFEVqR+NJrFP7ymGSjja2SQ7Q91nnBihGuT+yuHHl6ElMjQ3ttsB/eTmyCycxA=="], + "@turf/boolean-contains": ["@turf/boolean-contains@7.3.5", "", { "dependencies": { "@turf/bbox": "7.3.5", "@turf/boolean-point-in-polygon": "7.3.5", "@turf/boolean-point-on-line": "7.3.5", "@turf/helpers": "7.3.5", "@turf/invariant": "7.3.5", "@turf/line-split": "7.3.5", "@types/geojson": "^7946.0.10", "tslib": "^2.8.1" } }, "sha512-P4JUAHgvJkD+8ybQ6d1OHp9TBsGsjJxF5lWeXJgp0k4+Hd/D0CVy4/mhLkZdNa6QdljVdwNcfU0CTqy1WsSQig=="], "@turf/boolean-overlap": ["@turf/boolean-overlap@7.3.5", "", { "dependencies": { "@turf/helpers": "7.3.5", "@turf/invariant": "7.3.5", "@turf/line-intersect": "7.3.5", "@turf/line-overlap": "7.3.5", "@turf/meta": "7.3.5", "@types/geojson": "^7946.0.10", "geojson-equality-ts": "^1.0.2", "tslib": "^2.8.1" } }, "sha512-DUeiPVqFSTjW79erPbo780pRcKCRad59NcscVsTYkZD/92peF/4rQvHbvAUpUDPZaQCxe+mvfeDHfUFmfVKCGA=="], @@ -987,6 +1129,8 @@ "@turf/center": ["@turf/center@7.3.5", "", { "dependencies": { "@turf/bbox": "7.3.5", "@turf/helpers": "7.3.5", "@types/geojson": "^7946.0.10", "tslib": "^2.8.1" } }, "sha512-eub5/Kfdmn89ZqwCONHI7astmTDEtN5M6+JfOkgoSyhKKFhUJYNxUyH1F/vCtIP7j1K369Vs4L9TYiuGapvIKQ=="], + "@turf/clone": ["@turf/clone@5.1.5", "", { "dependencies": { "@turf/helpers": "^5.1.5" } }, "sha512-//pITsQ8xUdcQ9pVb4JqXiSqG4dos5Q9N4sYFoWghX21tfOV2dhc5TGqYOhnHrQS7RiKQL1vQ48kIK34gQ5oRg=="], + "@turf/destination": ["@turf/destination@7.3.5", "", { "dependencies": { "@turf/helpers": "7.3.5", "@turf/invariant": "7.3.5", "@types/geojson": "^7946.0.10", "tslib": "^2.8.1" } }, "sha512-x6ylChhOlIbucRSw7wF6z2gSEqqQl+dE0nSH5AL/ojZkqqGYahiw+2P4A8ZMuDM6rzH+FIqRYDes0o4nSArZCw=="], "@turf/distance": ["@turf/distance@7.3.5", "", { "dependencies": { "@turf/helpers": "7.3.5", "@turf/invariant": "7.3.5", "@types/geojson": "^7946.0.10", "tslib": "^2.8.1" } }, "sha512-uQAC63zg/l91KUxzfhqio7Ii3+UXTrPOVJScIdRj6EO6+9XHI4kC+AdyIS4cPAv14sZfJLIBxzMnzcGrss+kEA=="], @@ -1009,38 +1153,92 @@ "@turf/nearest-point-on-line": ["@turf/nearest-point-on-line@7.3.5", "", { "dependencies": { "@turf/distance": "7.3.5", "@turf/helpers": "7.3.5", "@turf/invariant": "7.3.5", "@turf/meta": "7.3.5", "@types/geojson": "^7946.0.10", "tslib": "^2.8.1" } }, "sha512-MZn6OkEFZpjS6BNUANfqiHMIbQSivu7TNji3a+OAIrnPJ71vp8cbz0N2aVEa5M7I8ipvxoxAPIV3eqg3h280Vg=="], + "@turf/rewind": ["@turf/rewind@5.1.5", "", { "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" } }, "sha512-Gdem7JXNu+G4hMllQHXRFRihJl3+pNl7qY+l4qhQFxq+hiU1cQoVFnyoleIqWKIrdK/i2YubaSwc3SCM7N5mMw=="], + "@turf/truncate": ["@turf/truncate@7.3.5", "", { "dependencies": { "@turf/helpers": "7.3.5", "@turf/meta": "7.3.5", "@types/geojson": "^7946.0.10", "tslib": "^2.8.1" } }, "sha512-Qx2iv3KIqKuDAUduMfaJ5fFegEWBeRve5zePalRevS16bMUqEX+jnKPK9fWGyUuPqT61qP1Kybz0PTWPbUbljQ=="], "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], + "@types/brotli": ["@types/brotli@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-9xoNr+bcxT236/7ZgcWw/6Pb2RRetE13p4bFy1xYSckKwyOiRfmInay8baUWZgH7/284Wl6IPe7+nOI9+OQg/A=="], + "@types/config": ["@types/config@3.3.5", "", {}, "sha512-itq2HtXQBrNUKwMNZnb9mBRE3T99VYCdl1gjST9rq+9kFaB1iMMGuDeZnP88qid73DnpAMKH9ZolqDpS1Lz7+w=="], "@types/conventional-commits-parser": ["@types/conventional-commits-parser@5.0.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-BgT2szDXnVypgpNxOK8aL5SGjUdaQbC++WZNjF1Qge3Og2+zhHj+RWhmehLhYyvQwqAmvezruVfOf8+3m74W+g=="], + "@types/crypto-js": ["@types/crypto-js@4.2.2", "", {}, "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ=="], + + "@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="], + "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], + "@types/d3-axis": ["@types/d3-axis@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw=="], + + "@types/d3-brush": ["@types/d3-brush@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A=="], + + "@types/d3-chord": ["@types/d3-chord@3.0.6", "", {}, "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg=="], + "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + "@types/d3-contour": ["@types/d3-contour@3.0.6", "", { "dependencies": { "@types/d3-array": "*", "@types/geojson": "*" } }, "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg=="], + + "@types/d3-delaunay": ["@types/d3-delaunay@6.0.4", "", {}, "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw=="], + + "@types/d3-dispatch": ["@types/d3-dispatch@3.0.7", "", {}, "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA=="], + + "@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="], + + "@types/d3-dsv": ["@types/d3-dsv@3.0.7", "", {}, "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g=="], + "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], + "@types/d3-fetch": ["@types/d3-fetch@3.0.7", "", { "dependencies": { "@types/d3-dsv": "*" } }, "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA=="], + + "@types/d3-force": ["@types/d3-force@3.0.10", "", {}, "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw=="], + + "@types/d3-format": ["@types/d3-format@3.0.4", "", {}, "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g=="], + + "@types/d3-geo": ["@types/d3-geo@3.1.1", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w=="], + + "@types/d3-hierarchy": ["@types/d3-hierarchy@3.1.1", "", {}, "sha512-QwjxA3+YCKH3N1Rs3uSiSy1bdxlLB1uUiENXeJudBoAFvtDuswUxLcanoOaR2JYn1melDTuIXR8VhnVyI3yG/A=="], + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], - "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + "@types/d3-polygon": ["@types/d3-polygon@3.0.2", "", {}, "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA=="], + + "@types/d3-quadtree": ["@types/d3-quadtree@3.0.6", "", {}, "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg=="], + + "@types/d3-random": ["@types/d3-random@3.0.4", "", {}, "sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA=="], + + "@types/d3-sankey": ["@types/d3-sankey@0.11.2", "", { "dependencies": { "@types/d3-shape": "^1" } }, "sha512-U6SrTWUERSlOhnpSrgvMX64WblX1AxX6nEjI2t3mLK2USpQrnbwYYK+AS9SwiE7wgYmOsSSKoSdr8aoKBH0HgQ=="], + + "@types/d3-scale": ["@types/d3-scale@3.3.5", "", { "dependencies": { "@types/d3-time": "^2" } }, "sha512-YOpKj0kIEusRf7ofeJcSZQsvKbnTwpe1DUF+P2qsotqG53kEsjm7EzzliqQxMkAWdkZcHrg5rRhB4JiDOQPX+A=="], + + "@types/d3-scale-chromatic": ["@types/d3-scale-chromatic@3.1.0", "", {}, "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ=="], + + "@types/d3-selection": ["@types/d3-selection@3.0.11", "", {}, "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="], "@types/d3-shape": ["@types/d3-shape@3.2.0", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw=="], "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], + "@types/d3-time-format": ["@types/d3-time-format@4.0.3", "", {}, "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg=="], + "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], + "@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="], + + "@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="], + "@types/dlv": ["@types/dlv@1.1.5", "", {}, "sha512-JHOWNfiWepAhfwlSw17kiWrWrk6od2dEQgHltJw9AS0JPFoLZJBge5+Dnil2NfdjAvJ/+vGSX60/BRW20PpUXw=="], "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], + "@types/google.maps": ["@types/google.maps@3.65.5", "", {}, "sha512-oR9Hq/WaKGzIguZEwoCuyQ0ofDhCbefmS9RrjLUZ5scMziwTE6xA90drJYt2ZsyUCYmCqnxQcDpfGqeRE/Zgog=="], + "@types/http-cache-semantics": ["@types/http-cache-semantics@4.2.0", "", {}, "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q=="], "@types/leaflet": ["@types/leaflet@1.9.22", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA=="], @@ -1051,8 +1249,14 @@ "@types/normalize-package-data": ["@types/normalize-package-data@2.4.4", "", {}, "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA=="], + "@types/offscreencanvas": ["@types/offscreencanvas@2019.7.3", "", {}, "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A=="], + + "@types/pako": ["@types/pako@1.0.7", "", {}, "sha512-YBtzT2ztNF6R/9+UXj2wTGFnC9NklAnASt3sC0h2m1bbH7G6FyBIkt4AN8ThZpNfxUo1b2iMVO0UawiJymEt8A=="], + "@types/parse-json": ["@types/parse-json@4.0.2", "", {}, "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw=="], + "@types/polylabel": ["@types/polylabel@1.1.3", "", {}, "sha512-9Zw2KoDpi+T4PZz2G6pO2xArE0m/GSMTW1MIxF2s8ZY8x9XDO6fv9um0ydRGvcbkFLlaq8yNK6eZxnmMZtDgWQ=="], + "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], "@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="], @@ -1061,8 +1265,14 @@ "@types/react-transition-group": ["@types/react-transition-group@4.4.12", "", { "peerDependencies": { "@types/react": "*" } }, "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w=="], + "@types/sortablejs": ["@types/sortablejs@1.15.9", "", {}, "sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ=="], + + "@types/svg-arc-to-cubic-bezier": ["@types/svg-arc-to-cubic-bezier@3.2.3", "", {}, "sha512-UNOnbTtl0nVTm8hwKaz5R5VZRvSulFMGojO5+Q7yucKxBoCaTtS4ibSQVRHo5VW5AaRo145U8p1Vfg5KrYe9Bg=="], + "@types/tinycolor2": ["@types/tinycolor2@1.4.6", "", {}, "sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], "@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="], @@ -1073,10 +1283,40 @@ "@ungap/structured-clone": ["@ungap/structured-clone@1.3.3", "", {}, "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg=="], + "@vaadin/a11y-base": ["@vaadin/a11y-base@24.9.17", "", { "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", "@vaadin/component-base": "~24.9.17", "lit": "^3.0.0" } }, "sha512-y0uxPHx89aobXvL0q9i81XNJ9oUrINiKmwjXZh9vWygN9Tj02nsBJWad6Q0N1nunEN2M6EvrT4c3vmE1TnI6lg=="], + + "@vaadin/checkbox": ["@vaadin/checkbox@24.9.17", "", { "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", "@vaadin/a11y-base": "~24.9.17", "@vaadin/component-base": "~24.9.17", "@vaadin/field-base": "~24.9.17", "@vaadin/vaadin-lumo-styles": "~24.9.17", "@vaadin/vaadin-material-styles": "~24.9.17", "@vaadin/vaadin-themable-mixin": "~24.9.17", "lit": "^3.0.0" } }, "sha512-iSsjNt4heOatYyVqBKCPooSi3qwauMSPZOdhHKX3bpOvnA2vJRAthtsbIWRyWIxk5i3vAFeh48MGfCvGjCyiHQ=="], + + "@vaadin/component-base": ["@vaadin/component-base@24.9.17", "", { "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", "@vaadin/vaadin-development-mode-detector": "^2.0.0", "@vaadin/vaadin-usage-statistics": "^2.1.0", "lit": "^3.0.0" } }, "sha512-nmE1G9bjpaHiEvrUhOaPk0SEHq3OY8lVsimYnqLHwZ9g1DlNzN7YoCZvBy0A4dC8+X9+RKWo7+AK3mbXqawr4w=="], + + "@vaadin/field-base": ["@vaadin/field-base@24.9.17", "", { "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", "@vaadin/a11y-base": "~24.9.17", "@vaadin/component-base": "~24.9.17", "lit": "^3.0.0" } }, "sha512-aVda8LDkAzQ7AbFLbKMTvfMlCgrqkC7wPsqjXIASkWV2tIhWQqbnKabGlpf/yopuIUx8l+q90um0Yga5oQ9kqw=="], + + "@vaadin/grid": ["@vaadin/grid@24.9.17", "", { "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", "@vaadin/a11y-base": "~24.9.17", "@vaadin/checkbox": "~24.9.17", "@vaadin/component-base": "~24.9.17", "@vaadin/lit-renderer": "~24.9.17", "@vaadin/text-field": "~24.9.17", "@vaadin/vaadin-lumo-styles": "~24.9.17", "@vaadin/vaadin-material-styles": "~24.9.17", "@vaadin/vaadin-themable-mixin": "~24.9.17", "lit": "^3.0.0" } }, "sha512-vZXy9jaqtnugZyZ3XdL43i1E53dVktMEOcg7ZA4IW/KoIeoiy5ioJp+X4JaPCfElY3+rAr0EWIGOcWHrF/dWXg=="], + + "@vaadin/icon": ["@vaadin/icon@24.9.17", "", { "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", "@vaadin/component-base": "~24.9.17", "@vaadin/vaadin-lumo-styles": "~24.9.17", "@vaadin/vaadin-themable-mixin": "~24.9.17", "lit": "^3.0.0" } }, "sha512-yxFBv6SdCGehbhYz5OAbiVeA83dW8eL2oxdeJu7VbyTztxCdnGPioSQN63K9IGVY0dmLlmVNnpA8Li18UgEAdQ=="], + + "@vaadin/input-container": ["@vaadin/input-container@24.9.17", "", { "dependencies": { "@polymer/polymer": "^3.0.0", "@vaadin/component-base": "~24.9.17", "@vaadin/vaadin-lumo-styles": "~24.9.17", "@vaadin/vaadin-material-styles": "~24.9.17", "@vaadin/vaadin-themable-mixin": "~24.9.17", "lit": "^3.0.0" } }, "sha512-h4zfY4HmNQ9b61e0F/BMCMPkkNcDN2vyVjH64DFQcYwhT42Eay+lGfRo675wj+O1sqvouQ4KjPZS8MsFeVlGHA=="], + + "@vaadin/lit-renderer": ["@vaadin/lit-renderer@24.9.17", "", { "dependencies": { "lit": "^3.0.0" } }, "sha512-la43FB3cF5zVtphr8rQas/6AUq8Q/SqR4sYp8jrnXWPGIc8nBqWTMPcIAsJkm6f2SacWc69ymr6GCV0nZQ1QWA=="], + + "@vaadin/text-field": ["@vaadin/text-field@24.9.17", "", { "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", "@vaadin/a11y-base": "~24.9.17", "@vaadin/component-base": "~24.9.17", "@vaadin/field-base": "~24.9.17", "@vaadin/input-container": "~24.9.17", "@vaadin/vaadin-lumo-styles": "~24.9.17", "@vaadin/vaadin-material-styles": "~24.9.17", "@vaadin/vaadin-themable-mixin": "~24.9.17", "lit": "^3.0.0" } }, "sha512-WM1luEbUOYZ94EPFhabc37HtMJmCymABw7QuVSnIMa0txZ0UIX77Nr/fJclwB9JUwMTOEf9Sx5UftqqftYg7OA=="], + + "@vaadin/vaadin-development-mode-detector": ["@vaadin/vaadin-development-mode-detector@2.0.7", "", {}, "sha512-9FhVhr0ynSR3X2ao+vaIEttcNU5XfzCbxtmYOV8uIRnUCtNgbvMOIcyGBvntsX9I5kvIP2dV3cFAOG9SILJzEA=="], + + "@vaadin/vaadin-lumo-styles": ["@vaadin/vaadin-lumo-styles@24.9.17", "", { "dependencies": { "@polymer/polymer": "^3.0.0", "@vaadin/component-base": "~24.9.17", "@vaadin/icon": "~24.9.17", "@vaadin/vaadin-themable-mixin": "~24.9.17" } }, "sha512-5Fjhku/jkopiyBTDNukJMV4j3WwzBN+YUeNK88zxBqr+Qd/61tIzUz5mvH6FLjjKE003FYC9/SlIFfWRzPzIrA=="], + + "@vaadin/vaadin-material-styles": ["@vaadin/vaadin-material-styles@24.9.17", "", { "dependencies": { "@polymer/polymer": "^3.0.0", "@vaadin/component-base": "~24.9.17", "@vaadin/vaadin-themable-mixin": "~24.9.17" } }, "sha512-FCwaV9j7Ox2AR0hpvcZgClMzhZicQku5xIv0TBm99Be2FVQpfo/KAowkoAYQoDpmrXNzpByamG3qF4hvDW451w=="], + + "@vaadin/vaadin-themable-mixin": ["@vaadin/vaadin-themable-mixin@24.9.17", "", { "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "lit": "^3.0.0" } }, "sha512-3DpdPhhxVjpb3+91YhrfTcmRHn3XM2gMdXWmtSUwLZnUj2zyprJsAL5TK3/MSqVFuPaOwaUWqZXWsk809Ohd7w=="], + + "@vaadin/vaadin-usage-statistics": ["@vaadin/vaadin-usage-statistics@2.1.3", "", { "dependencies": { "@vaadin/vaadin-development-mode-detector": "^2.0.0" } }, "sha512-8r4TNknD7OJQADe3VygeofFR7UNAXZ2/jjBFP5dgI8+2uMfnuGYgbuHivasKr9WSQ64sPej6m8rDoM1uSllXjQ=="], + "@vitejs/plugin-react-swc": ["@vitejs/plugin-react-swc@3.11.0", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-beta.27", "@swc/core": "^1.12.11" }, "peerDependencies": { "vite": "^4 || ^5 || ^6 || ^7" } }, "sha512-YTJCGFdNMHCMfjODYtxRNVAYmTWQ1Lb8PulP/2/f/oEEtglw8oKxKIZmmRkyXrVrHfsKOaVkAc3NT9/dMutO5w=="], "@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.7", "", {}, "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="], + "@webcomponents/shadycss": ["@webcomponents/shadycss@1.11.2", "", {}, "sha512-vRq+GniJAYSBmTRnhCYPAPq6THYqovJ/gzGThWbgEZUQaBccndGTi1hdiUP15HzEco0I6t4RCtXyX0rsSmwgPw=="], + "@whatwg-node/promise-helpers": ["@whatwg-node/promise-helpers@1.3.2", "", { "dependencies": { "tslib": "^2.6.3" } }, "sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA=="], "@wry/caches": ["@wry/caches@1.0.1", "", { "dependencies": { "tslib": "^2.3.0" } }, "sha512-bXuaUNLVVkD20wcGBWRyo7j9N3TxePEWFZj2Y+r9OoUzfqmavM84+mFykRicNsBqatba5JLay1t48wxaXaWnlA=="], @@ -1087,8 +1327,12 @@ "@wry/trie": ["@wry/trie@0.5.0", "", { "dependencies": { "tslib": "^2.3.0" } }, "sha512-FNoYzHawTMk/6KMQoEG5O4PuioX19UbwdQKF44yw0nLfOypfQdjtfZzo/UIJWAJ23sNIFbD1Ug9lbaDGMwbqQA=="], + "@zip.js/zip.js": ["@zip.js/zip.js@2.8.57", "", {}, "sha512-cd5a0s7CS0MOD52f7FW4ju84wc/+b+YNu5XIG72PfXRLk6rInEdvIrB/BjR8D8hS4fMKuYJOBjv6FoLh3e7DHw=="], + "JSONStream": ["JSONStream@1.3.5", "", { "dependencies": { "jsonparse": "^1.2.0", "through": ">=2.2.7 <3" }, "bin": { "JSONStream": "./bin.js" } }, "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ=="], + "a5-js": ["a5-js@0.7.3", "", { "dependencies": { "gl-matrix": "^3.4.3" } }, "sha512-3aoMwHmNkyuMDHS4q6GRRInpOawamen2pokIbc0MQmR9cqG0Y9+B0bZpzswwetjrSG2ckbYtShH+nKru6+3O5Q=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], @@ -1115,6 +1359,10 @@ "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], + "anynum": ["anynum@1.0.1", "", {}, "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A=="], + + "apache-arrow": ["apache-arrow@21.2.0", "", { "dependencies": { "@types/node": "^25.2.0", "flatbuffers": "^25.1.24", "json-with-bigint": "^3.5.3", "tslib": "^2.6.2" }, "bin": { "arrow2csv": "bin/arrow2csv.js" } }, "sha512-Hxe6Agq26gQOM954qpzYSllJBPJl+e16U5CkfuMUhLrNba+5nKkttIVlflaovN6oaTratqMGAO8H5u/aNhmHWQ=="], + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "argv-formatter": ["argv-formatter@1.0.0", "", {}, "sha512-F2+Hkm9xFaRg+GkaNnbwXNDV5O6pnCFEmqyhvfC/Ic5LbgOWjJh3L+mN/s91rxVL3znE7DYVpW0GJFT+4YBgWw=="], @@ -1171,8 +1419,14 @@ "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + "brotli": ["brotli@1.3.3", "", { "dependencies": { "base64-js": "^1.1.2" } }, "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg=="], + + "browserify-zlib": ["browserify-zlib@0.2.0", "", { "dependencies": { "pako": "~1.0.5" } }, "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA=="], + "browserslist": ["browserslist@4.28.8", "", { "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", "electron-to-chromium": "^1.5.402", "node-releases": "^2.0.53", "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA=="], + "buf-compare": ["buf-compare@1.0.1", "", {}, "sha512-Bvx4xH00qweepGc43xFvMs5BKASXTbHaHm6+kDYIK9p/4iFwjATQkmPKHQSgJZzKbAymhztRbXUf1Nqhzl73/Q=="], + "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], @@ -1203,6 +1457,8 @@ "caniuse-lite": ["caniuse-lite@1.0.30001809", "", {}, "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ=="], + "cartocolor": ["cartocolor@5.0.2", "", { "dependencies": { "colorbrewer": "1.5.6" } }, "sha512-Ihb/wU5V6BVbHwapd8l/zg7bnhZ4YPFVfa7quSpL86lfkPJSf4YuNBT+EvesPRP5vSqhl6vZVsQJwCR8alBooQ=="], + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "chalkercli": ["chalkercli-v2@github:TurtIeSocks/chalkercli#2322333", { "dependencies": { "chalk": "^2.3.2", "gradient-string": "^1.1.0", "meow": "^13.0.0" }, "bin": "./cli.js" }, "TurtIeSocks-chalkercli-2322333", "sha512-CF8gq40DSpVof0edtC3yz43IWFEp4odiAgCQk7l1AqAAZbNz4wtUnuhZuyAGnxEsJJbKXgZ3BlN+kBpharMijw=="], @@ -1211,6 +1467,8 @@ "chardet": ["chardet@2.2.0", "", {}, "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA=="], + "charenc": ["charenc@0.0.2", "", {}, "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA=="], + "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], "chunk-data": ["chunk-data@0.1.0", "", {}, "sha512-zFyPtyC0SZ6Zu79b9sOYtXZcgrsXe0RpePrzRyj52hYVFG1+Rk6rBqjjOEk+GNQwc3PIX+86teQMok970pod1g=="], @@ -1241,10 +1499,16 @@ "code-point-at": ["code-point-at@1.1.0", "", {}, "sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA=="], + "color": ["color@5.0.3", "", { "dependencies": { "color-convert": "^3.1.3", "color-string": "^2.1.3" } }, "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "color-string": ["color-string@2.1.4", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg=="], + + "colorbrewer": ["colorbrewer@1.5.6", "", {}, "sha512-fONg2pGXyID8zNgKHBlagW8sb/AMShGzj4rRJfz5biZ7iuHQZYquSCLE/Co1oSQFmt/vvwjyezJCejQl7FG/tg=="], + "colorette": ["colorette@2.0.19", "", {}, "sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ=="], "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], @@ -1253,6 +1517,8 @@ "compare-func": ["compare-func@2.0.0", "", { "dependencies": { "array-ify": "^1.0.0", "dot-prop": "^5.1.0" } }, "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA=="], + "composed-offset-position": ["composed-offset-position@0.0.6", "", { "peerDependencies": { "@floating-ui/utils": "^0.2.5" } }, "sha512-Q7dLompI6lUwd7LWyIcP66r4WcS9u7AL2h8HaeipiRfCRPLMWqRx8fYsjb4OHi6UQFifO7XtNC2IlEJ1ozIFxw=="], + "compressible": ["compressible@2.0.18", "", { "dependencies": { "mime-db": ">= 1.43.0 < 2" } }, "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg=="], "compression": ["compression@1.8.1", "", { "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", "negotiator": "~0.6.4", "on-headers": "~1.1.0", "safe-buffer": "5.2.1", "vary": "~1.1.2" } }, "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w=="], @@ -1293,6 +1559,8 @@ "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + "core-assert": ["core-assert@0.2.1", "", { "dependencies": { "buf-compare": "^1.0.0", "is-error": "^2.2.0" } }, "sha512-IG97qShIP+nrJCXMCgkNZgH7jZQ4n8RpPyPeXX++T6avR/KhLhgLiHKoEn5Rc1KjfycSfA9DMa6m+4C4eguHhw=="], + "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], @@ -1307,30 +1575,76 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "crypt": ["crypt@0.0.2", "", {}, "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow=="], + + "crypto-js": ["crypto-js@4.2.0", "", {}, "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="], + "crypto-random-string": ["crypto-random-string@4.0.0", "", { "dependencies": { "type-fest": "^1.0.1" } }, "sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA=="], "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + "cssfilter": ["cssfilter@0.0.10", "", {}, "sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "cz-conventional-changelog": ["cz-conventional-changelog@3.3.0", "", { "dependencies": { "chalk": "^2.4.1", "commitizen": "^4.0.3", "conventional-commit-types": "^3.0.0", "lodash.map": "^4.5.1", "longest": "^2.0.1", "word-wrap": "^1.0.3" }, "optionalDependencies": { "@commitlint/load": ">6.1.1" } }, "sha512-U466fIzU5U22eES5lTNiNbZ+d8dfcHcssH4o7QsdWaCcRs/feIPCxKYSWkYBNs5mny7MvEfwpTLWjvbm94hecw=="], "cz-conventional-commit": ["cz-conventional-commit@1.0.6", "", { "dependencies": { "chalk": "^2.4.1", "commitizen": "^4.0.3", "lodash.map": "^4.5.1", "longest": "^2.0.1", "right-pad": "^1.0.1", "word-wrap": "^1.0.3" }, "optionalDependencies": { "@commitlint/load": ">6.1.1" } }, "sha512-E/mzC3hHGQo36oyFlGfOTI01S1Cq9Zg2Y5kcZvMN+w6Uv3ZlACaTk8Z3iXCnxoT2i2KB7wEGDZpgPIAkNQNF8Q=="], + "d3": ["d3@7.9.0", "", { "dependencies": { "d3-array": "3", "d3-axis": "3", "d3-brush": "3", "d3-chord": "3", "d3-color": "3", "d3-contour": "4", "d3-delaunay": "6", "d3-dispatch": "3", "d3-drag": "3", "d3-dsv": "3", "d3-ease": "3", "d3-fetch": "3", "d3-force": "3", "d3-format": "3", "d3-geo": "3", "d3-hierarchy": "3", "d3-interpolate": "3", "d3-path": "3", "d3-polygon": "3", "d3-quadtree": "3", "d3-random": "3", "d3-scale": "4", "d3-scale-chromatic": "3", "d3-selection": "3", "d3-shape": "3", "d3-time": "3", "d3-time-format": "4", "d3-timer": "3", "d3-transition": "3", "d3-zoom": "3" } }, "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA=="], + "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + "d3-axis": ["d3-axis@3.0.0", "", {}, "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw=="], + + "d3-brush": ["d3-brush@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "3", "d3-transition": "3" } }, "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ=="], + + "d3-chord": ["d3-chord@3.0.1", "", { "dependencies": { "d3-path": "1 - 3" } }, "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g=="], + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + "d3-contour": ["d3-contour@4.0.2", "", { "dependencies": { "d3-array": "^3.2.0" } }, "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA=="], + + "d3-delaunay": ["d3-delaunay@6.0.4", "", { "dependencies": { "delaunator": "5" } }, "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A=="], + + "d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="], + + "d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="], + + "d3-dsv": ["d3-dsv@3.0.1", "", { "dependencies": { "commander": "7", "iconv-lite": "0.6", "rw": "1" }, "bin": { "csv2json": "bin/dsv2json.js", "csv2tsv": "bin/dsv2dsv.js", "dsv2dsv": "bin/dsv2dsv.js", "dsv2json": "bin/dsv2json.js", "json2csv": "bin/json2dsv.js", "json2dsv": "bin/json2dsv.js", "json2tsv": "bin/json2dsv.js", "tsv2csv": "bin/dsv2dsv.js", "tsv2json": "bin/dsv2json.js" } }, "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q=="], + "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + "d3-fetch": ["d3-fetch@3.0.1", "", { "dependencies": { "d3-dsv": "1 - 3" } }, "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw=="], + + "d3-force": ["d3-force@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="], + "d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="], + "d3-geo": ["d3-geo@3.1.1", "", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q=="], + + "d3-hexbin": ["d3-hexbin@0.2.2", "", {}, "sha512-KS3fUT2ReD4RlGCjvCEm1RgMtp2NFZumdMu4DBzQK8AZv3fXRM6Xm8I4fSU07UXvH4xxg03NwWKWdvxfS/yc4w=="], + + "d3-hierarchy": ["d3-hierarchy@3.1.2", "", {}, "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA=="], + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], + "d3-polygon": ["d3-polygon@3.0.1", "", {}, "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg=="], + + "d3-quadtree": ["d3-quadtree@3.0.1", "", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="], + + "d3-random": ["d3-random@3.0.1", "", {}, "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ=="], + + "d3-sankey": ["d3-sankey@0.12.3", "", { "dependencies": { "d3-array": "1 - 2", "d3-shape": "^1.2.0" } }, "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ=="], + "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + "d3-scale-chromatic": ["d3-scale-chromatic@3.1.0", "", { "dependencies": { "d3-color": "1 - 3", "d3-interpolate": "1 - 3" } }, "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ=="], + + "d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="], + "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], @@ -1339,6 +1653,16 @@ "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + "d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="], + + "d3-voronoi-map": ["d3-voronoi-map@2.1.1", "", { "dependencies": { "d3-dispatch": "2.*", "d3-polygon": "2.*", "d3-timer": "2.*", "d3-weighted-voronoi": "1.*" } }, "sha512-mCXfz/kD9IQxjHaU2IMjkO8fSo4J6oysPR2iL+omDsCy1i1Qn6BQ/e4hEAW8C6ms2kfuHwqtbNom80Hih94YsA=="], + + "d3-voronoi-treemap": ["d3-voronoi-treemap@1.1.2", "", { "dependencies": { "d3-voronoi-map": "2.*" } }, "sha512-7odu9HdG/yLPWwzDteJq4yd9Q/NwgQV7IE/u36VQtcCK7k1sZwDqbkHCeMKNTBsq5mQjDwolTsrXcU0j8ZEMCA=="], + + "d3-weighted-voronoi": ["d3-weighted-voronoi@1.1.3", "", { "dependencies": { "d3-array": "2", "d3-polygon": "2" } }, "sha512-C3WdvSKl9aqhAy+f3QT3PPsQG6V+ajDfYO3BSclQDSD+araW2xDBFIH67aKzsSuuuKaX8K2y2dGq1fq/dWTVig=="], + + "d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="], + "dargs": ["dargs@8.1.0", "", {}, "sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw=="], "date-fns": ["date-fns@2.30.0", "", { "dependencies": { "@babel/runtime": "^7.21.0" } }, "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw=="], @@ -1353,14 +1677,20 @@ "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], + "deck.gl": ["deck.gl@9.3.10", "", { "dependencies": { "@deck.gl/aggregation-layers": "9.3.10", "@deck.gl/arcgis": "9.3.10", "@deck.gl/carto": "9.3.10", "@deck.gl/core": "9.3.10", "@deck.gl/extensions": "9.3.10", "@deck.gl/geo-layers": "9.3.10", "@deck.gl/google-maps": "9.3.10", "@deck.gl/json": "9.3.10", "@deck.gl/layers": "9.3.10", "@deck.gl/mapbox": "9.3.10", "@deck.gl/mesh-layers": "9.3.10", "@deck.gl/react": "9.3.10", "@deck.gl/widgets": "9.3.10", "@loaders.gl/core": "^4.4.3", "@luma.gl/core": "^9.3.5", "@luma.gl/engine": "^9.3.5" }, "peerDependencies": { "@arcgis/core": "^4.0.0", "react": ">=16.3.0", "react-dom": ">=16.3.0" }, "optionalPeers": ["@arcgis/core", "react", "react-dom"] }, "sha512-iy+qGha/xj7isGwzF0eoDnF/VwzHQXyMIBb2pl3TBb8FXqzvheA/h4IKnsd7F4lji1SEeQ0A5kyqPmKnqTMgfQ=="], + "decompress-response": ["decompress-response@10.0.0", "", { "dependencies": { "mimic-response": "^4.0.0" } }, "sha512-oj7KWToJuuxlPr7VV0vabvxEIiqNMo+q0NueIiL3XhtwC6FVOX7Hr1c0C4eD0bmf7Zr+S/dSf2xvkH3Ad6sU3Q=="], "dedent": ["dedent@0.7.0", "", {}, "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA=="], + "deep-equal": ["deep-equal@1.1.2", "", { "dependencies": { "is-arguments": "^1.1.1", "is-date-object": "^1.0.5", "is-regex": "^1.1.4", "object-is": "^1.1.5", "object-keys": "^1.1.1", "regexp.prototype.flags": "^1.5.1" } }, "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg=="], + "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + "deep-strict-equal": ["deep-strict-equal@0.2.0", "", { "dependencies": { "core-assert": "^0.2.0" } }, "sha512-3daSWyvZ/zwJvuMGlzG1O+Ow0YSadGfb3jsh9xoCutv2tWyB9dA4YvR9L9/fSdDZa2dByYQe+TqapSGUrjnkoA=="], + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], "default-browser": ["default-browser@5.5.1", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw=="], @@ -1373,8 +1703,12 @@ "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], + "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + "del": ["del@6.1.1", "", { "dependencies": { "globby": "^11.0.1", "graceful-fs": "^4.2.4", "is-glob": "^4.0.1", "is-path-cwd": "^2.2.0", "is-path-inside": "^3.0.2", "p-map": "^4.0.0", "rimraf": "^3.0.2", "slash": "^3.0.0" } }, "sha512-ua8BhapfP0JUJKC/zV9yHHDW/rDoDxP4Zhn3AkA6/xT6gY7jYXJiaeyBZznYVujhZZET+UgcbZiQ7sN3WqcImg=="], + "delaunator": ["delaunator@5.1.0", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ=="], + "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="], "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], @@ -1393,6 +1727,8 @@ "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], + "dfa": ["dfa@1.2.0", "", {}, "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="], + "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], @@ -1413,11 +1749,13 @@ "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + "draco3d": ["draco3d@1.5.7", "", {}, "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "duplexer2": ["duplexer2@0.1.4", "", { "dependencies": { "readable-stream": "^2.0.2" } }, "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA=="], - "earcut": ["earcut@3.2.3", "", {}, "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q=="], + "earcut": ["earcut@2.2.4", "", {}, "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ=="], "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], @@ -1481,6 +1819,8 @@ "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + "esri-loader": ["esri-loader@3.7.0", "", {}, "sha512-cB1Sw9EQjtW4mtT7eFBjn/6VaaIWNTjmTd2asnnEyuZk1xVSFRMCfLZSBSjZM7ZarDcVu5WIjOP0t0MYVu4hVQ=="], + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], @@ -1515,10 +1855,16 @@ "fast-uri": ["fast-uri@3.1.6", "", {}, "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q=="], + "fast-xml-builder": ["fast-xml-builder@1.3.1", "", { "dependencies": { "path-expression-matcher": "^1.6.2", "xml-naming": "^0.3.0" } }, "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug=="], + + "fast-xml-parser": ["fast-xml-parser@5.11.0", "", { "dependencies": { "@nodable/entities": "^3.0.0", "fast-xml-builder": "^1.2.0", "is-unsafe": "^2.0.0", "path-expression-matcher": "^1.6.2", "strnum": "^2.4.2", "xml-naming": "^0.3.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-9IGxMqvqLOnqP+Egi1nqDHKv5k8aZ7r9n558enxcucmyVGEBNPAU+MOg/8jPIS7rO7sSq4gFm1/nHtiaubMruw=="], + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fflate": ["fflate@0.7.4", "", {}, "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw=="], + "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="], "file-entry-cache": ["file-entry-cache@6.0.1", "", { "dependencies": { "flat-cache": "^3.0.4" } }, "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg=="], @@ -1545,8 +1891,14 @@ "flat-cache": ["flat-cache@3.2.0", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.3", "rimraf": "^3.0.2" } }, "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw=="], + "flatbuffers": ["flatbuffers@25.9.23", "", {}, "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ=="], + + "flatpickr": ["flatpickr@4.6.13", "", {}, "sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw=="], + "flatted": ["flatted@3.4.4", "", {}, "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q=="], + "focus-trap": ["focus-trap@7.8.0", "", { "dependencies": { "tabbable": "^6.4.0" } }, "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA=="], + "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], @@ -1565,6 +1917,8 @@ "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + "functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="], + "fuzzysort": ["fuzzysort@3.1.0", "", {}, "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ=="], "generate-function": ["generate-function@2.3.1", "", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="], @@ -1635,6 +1989,8 @@ "graphql-type-json": ["graphql-type-json@0.3.2", "", { "peerDependencies": { "graphql": ">=0.8.0" } }, "sha512-J+vjof74oMlCWXSvt0DOf2APEdZOCdubEvGDUAlqH//VBYcOYsGgRW7Xzorr44LvkjiuvecWc8fChxuZZbChtg=="], + "h3-js": ["h3-js@4.5.0", "", {}, "sha512-uKmdPc+DuarnR+XqZxEuWOMw7KzzKROrx3MLeJpFnMOs78S9M5eZ+X5RieS9UcSFQqbeXzbfWuX/W9Pyajinqw=="], + "handlebars": ["handlebars@4.7.9", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ=="], "happy-dom": ["happy-dom@20.11.6", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-Hldbg8AdAa5a5oDcZpjqnGitp7JB0hqWmfv/8qr+kft4vzSD8BHsbdRfzYvL/0QcbKcURC/yyoygbeDQarPvYg=="], @@ -1697,6 +2053,8 @@ "ignore-by-default": ["ignore-by-default@1.0.1", "", {}, "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA=="], + "image-size": ["image-size@0.7.5", "", { "bin": { "image-size": "bin/image-size.js" } }, "sha512-Hiyv+mXHfFEP7LzUL/llg9RwFxxY+o9N3JVLIeG5E7iFIFAalxvRU9UZthBdYDEVnzHMgjnKJPPpay5BWf1g9g=="], + "immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], "immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="], @@ -1723,6 +2081,8 @@ "inquirer": ["inquirer@8.2.7", "", { "dependencies": { "@inquirer/external-editor": "^1.0.0", "ansi-escapes": "^4.2.1", "chalk": "^4.1.1", "cli-cursor": "^3.1.0", "cli-width": "^3.0.0", "figures": "^3.0.0", "lodash": "^4.17.21", "mute-stream": "0.0.8", "ora": "^5.4.1", "run-async": "^2.4.0", "rxjs": "^7.5.5", "string-width": "^4.1.0", "strip-ansi": "^6.0.0", "through": "^2.3.6", "wrap-ansi": "^6.0.1" } }, "sha512-UjOaSel/iddGZJ5xP/Eixh6dY1XghiBw4XK13rCCIJcJfyhhoul/7KhLLUGtebEj6GDYM6Vnx/mVsjx2L/mFIA=="], + "interactjs": ["interactjs@1.10.28", "", { "dependencies": { "@interactjs/types": "1.10.28" } }, "sha512-QCa4ksTPd2p/FZ4I6hrH6fogjLqru1TxYywRiKYNuLZtrlAxTIYUa/MYsE7PnUgWZJU3SrGVDjiz065BeaXYng=="], + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], "interpret": ["interpret@2.2.0", "", {}, "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw=="], @@ -1733,16 +2093,24 @@ "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "is-arguments": ["is-arguments@1.2.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA=="], + "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], + "is-buffer": ["is-buffer@1.1.6", "", {}, "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="], + "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], + "is-date-object": ["is-date-object@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="], + "is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], + "is-error": ["is-error@2.2.2", "", {}, "sha512-IOQqts/aHWbiisY5DuPJQ0gcbvaLFCa7fBa9xoLfxBZvQ+ZI/Zh9xoI7Gk+G64N0FdK4AbibytHht2tWgpJWLg=="], + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-finite": ["is-finite@1.1.0", "", {}, "sha512-cdyMtqX/BOqqNBBiKlIVkytNHm49MtMlYyn1zxzvJKWmFMlGzm+ry5BBfYyeY9YmNKbRSo/o7OX9w9ale0wg3w=="], @@ -1773,6 +2141,8 @@ "is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="], + "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], + "is-regexp": ["is-regexp@3.1.0", "", {}, "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA=="], "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], @@ -1783,6 +2153,8 @@ "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], + "is-unsafe": ["is-unsafe@2.0.2", "", {}, "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ=="], + "is-utf8": ["is-utf8@0.2.1", "", {}, "sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q=="], "is-windows": ["is-windows@1.0.2", "", {}, "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA=="], @@ -1803,10 +2175,14 @@ "jose": ["jose@6.2.10", "", {}, "sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g=="], + "jpeg-exif": ["jpeg-exif@1.1.4", "", {}, "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="], + "jsep": ["jsep@0.3.5", "", {}, "sha512-AoRLBDc6JNnKjNcmonituEABS5bcfqDhQAWWXNTFrqu6nVXBpBAGfcoTGZMFlIrh9FjmE1CQyX9CTNwZrXMMDA=="], + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], @@ -1825,6 +2201,8 @@ "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], + "json-with-bigint": ["json-with-bigint@3.5.12", "", {}, "sha512-uwbF/wSSuOgC7qqlq27Xp5B6a2MHVug3t0idZdTqu0JnlFvgJuH7ju+KAk/J06C7GfhoYy2gnb9wz2INqcne7w=="], + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], "jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], @@ -1841,6 +2219,8 @@ "knex": ["knex@3.1.0", "", { "dependencies": { "colorette": "2.0.19", "commander": "^10.0.0", "debug": "4.3.4", "escalade": "^3.1.1", "esm": "^3.2.25", "get-package-type": "^0.1.0", "getopts": "2.3.0", "interpret": "^2.2.0", "lodash": "^4.17.21", "pg-connection-string": "2.6.2", "rechoir": "^0.8.0", "resolve-from": "^5.0.0", "tarn": "^3.0.2", "tildify": "2.0.0" }, "bin": { "knex": "bin/cli.js" } }, "sha512-GLoII6hR0c4ti243gMs5/1Rb3B+AjwMOfjYm97pu0FOQa7JH56hgBxYf5WK2525ceSbBY1cjeZ9yk99GPMB6Kw=="], + "ktx-parse": ["ktx-parse@0.7.1", "", {}, "sha512-FeA3g56ksdFNwjXJJsc1CCc7co+AJYDp6ipIp878zZ2bU8kWROatLYf39TQEd4/XRSUvBXovQ8gaVKWPXsCLEQ=="], + "latest-version": ["latest-version@2.0.0", "", { "dependencies": { "package-json": "^2.0.0" } }, "sha512-8925wFYLfWBciewimt0VmDyYw0GFCRcbFSTrZGt4JgQ7lh5jb/kodMlUt0uMaxXdRKVi+7F3ib30N7fTv83ikw=="], "leaflet": ["leaflet@1.9.4", "", {}, "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA=="], @@ -1881,6 +2261,12 @@ "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + "lit": ["lit@3.3.3", "", { "dependencies": { "@lit/reactive-element": "^2.1.0", "lit-element": "^4.2.0", "lit-html": "^3.3.0" } }, "sha512-fycuvZg/hkpozL00lm1pEJH5nN/lr9ZXd6mJI2HSN4+Bzc+LDNdEApJ6HFbPkdFNHLvOplIIuJvxkS4XUxqirw=="], + + "lit-element": ["lit-element@4.2.2", "", { "dependencies": { "@lit-labs/ssr-dom-shim": "^1.5.0", "@lit/reactive-element": "^2.1.0", "lit-html": "^3.3.0" } }, "sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w=="], + + "lit-html": ["lit-html@3.3.3", "", { "dependencies": { "@types/trusted-types": "^2.0.2" } }, "sha512-el8M6jK2o3RXBnrSHX3ZKrsN8zEV63pSExTO1wYJz7QndGYZ8353e2a5PPX+qHe2aGayfnchQmkAojaWAREOIA=="], + "load-json-file": ["load-json-file@4.0.0", "", { "dependencies": { "graceful-fs": "^4.1.2", "parse-json": "^4.0.0", "pify": "^3.0.0", "strip-bom": "^3.0.0" } }, "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw=="], "localforage": ["localforage@1.10.0", "", { "dependencies": { "lie": "3.1.1" } }, "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg=="], @@ -1939,8 +2325,12 @@ "lucide-react": ["lucide-react@1.33.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-MTRwMy0ZlL8Ur/vOAiJ9XGHE+kFPC7brq6MxAm0GiGXEBj0qy0jA/pG4N675oSzciO/UCdX8T+5yUQdmDeTLxg=="], + "luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="], + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + "lz4js": ["lz4js@0.2.0", "", {}, "sha512-gY2Ia9Lm7Ep8qMiuGRhvUq0Q7qUereeldZPP1PMEJxPtEWHJLqw9pgX68oHajBH0nzJK4MaZEA/YNV3jT8u8Bg=="], + "magic-bytes.js": ["magic-bytes.js@1.13.1", "", {}, "sha512-x5sn4UX2k5gCWlcfmoFwG4TPie8+dctESyqOBdhB5p6MsgWXdBKGmt9nXPObj/JI50TTL928lc5Yt1WntMn1bw=="], "magic-string": ["magic-string@0.30.8", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ=="], @@ -1951,12 +2341,16 @@ "marked-terminal": ["marked-terminal@6.3.0", "", { "dependencies": { "ansi-escapes": "^6.2.0", "chalk": "^5.3.0", "cli-highlight": "^2.1.11", "cli-table3": "^0.6.3", "node-emoji": "^2.1.3", "supports-hyperlinks": "^3.0.0" }, "peerDependencies": { "marked": ">=1 <13" } }, "sha512-icQT4cpNHOC3uxYr0DjZx8yYbFVnb40dzbvbqe9G2CRpFPnCe/RjfPXzP3xKm2vrd8sw4Sqsc3O+IsoPMzE/Iw=="], + "markerjs2": ["markerjs2@2.32.7", "", {}, "sha512-HeFRZjmc43DOG3lSQp92z49cq2oCYpYn2pX++SkJAW1Dij4xJtRquVRf+cXeSZQWDX3ufns1Ry/bGk+zveP7rA=="], + "math-float64-exponent": ["math-float64-exponent@1.0.0", "", { "dependencies": { "math-float64-get-high-word": "^1.0.0" } }, "sha512-IC+IUir6tLqFeGAxap2842rGpYWl4UBItglxDSPCjJTh1vD6Tbuap+aaL+k1LzMxVm9PrXpKTb9uhTqLJRR35w=="], "math-float64-get-high-word": ["math-float64-get-high-word@1.0.0", "", { "dependencies": { "utils-is-little-endian": "^1.0.0" } }, "sha512-kSmcZEyDx/mS9Zahx5kKU/eVggrSvN2MZ/CLTgk7N0we41vqnpFUpoAeNMW5kj+1dJu4EhnMdgiP9P1xiwr7ug=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "md5": ["md5@2.3.0", "", { "dependencies": { "charenc": "0.0.2", "crypt": "0.0.2", "is-buffer": "~1.1.6" } }, "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g=="], + "media-typer": ["media-typer@1.1.1", "", {}, "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ=="], "meow": ["meow@13.2.0", "", {}, "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA=="], @@ -1969,6 +2363,8 @@ "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + "meshoptimizer": ["meshoptimizer@1.2.0", "", {}, "sha512-davRZeIJbxJrE24cwQle7ZDsxjdk/OphNOV83oX+efQinyoHY9Jcyz3MHbaoG0qySZajldGztNZ1RN/T19PZsg=="], + "methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="], "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], @@ -1991,6 +2387,8 @@ "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + "mjolnir.js": ["mjolnir.js@3.1.0", "", {}, "sha512-fWcei0lo+xauQK0CVb5vwC8FaS9xeEBiNPd5vMIM89DchQAi6ENMq9itOjq+G8zAvhKmJu5UPnuEnU8kVrHzdg=="], + "mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="], "moment": ["moment@2.30.1", "", {}, "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how=="], @@ -2053,6 +2451,10 @@ "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + "object-is": ["object-is@1.1.6", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1" } }, "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q=="], + + "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], + "object-treeify": ["object-treeify@1.1.33", "", {}, "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A=="], "objection": ["objection@3.1.5", "", { "dependencies": { "ajv": "^8.17.1", "ajv-formats": "^2.1.1", "db-errors": "^0.2.3" }, "peerDependencies": { "knex": ">=1.0.1" } }, "sha512-Hx/ipAwXSuRBbOMWFKtRsAN0yITafqXtWB4OT4Z9wED7ty1h7bOnBdhLtcNus23GwLJqcMsRWdodL2p5GwlnfQ=="], @@ -2133,6 +2535,8 @@ "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + "path-expression-matcher": ["path-expression-matcher@1.6.2", "", {}, "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ=="], + "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], @@ -2151,6 +2555,8 @@ "pbf": ["pbf@3.3.0", "", { "dependencies": { "ieee754": "^1.1.12", "resolve-protobuf-schema": "^2.1.0" }, "bin": { "pbf": "bin/pbf" } }, "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q=="], + "pdfmake": ["pdfmake@0.2.23", "", { "dependencies": { "@foliojs-fork/linebreak": "^1.1.2", "@foliojs-fork/pdfkit": "^0.15.3", "iconv-lite": "^0.7.1", "xmldoc": "^2.0.3" } }, "sha512-A/IksoKb/ikOZH1edSDJ/2zBbqJKDghD4+fXn3rT7quvCJDlsZMs3NmIB3eajLMMFU9Bd3bZPVvlUMXhvFI+bQ=="], + "pg-connection-string": ["pg-connection-string@2.6.2", "", {}, "sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], @@ -2167,10 +2573,14 @@ "pkginfo": ["pkginfo@0.3.1", "", {}, "sha512-yO5feByMzAp96LtP58wvPKSbaKAi/1C4kV9XpTctr6EepnP6F33RBNOiVrdz9BrPA98U2BMFsTNHo44TWcbQ2A=="], + "png-js": ["png-js@1.1.0", "", { "dependencies": { "browserify-zlib": "^0.2.0" } }, "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q=="], + "pogo-data-generator": ["pogo-data-generator@1.21.2", "", { "dependencies": { "@na-ji/pogo-protos": "<3.0.0", "jszip": "^3.10.1", "node-fetch": "2.6.7" } }, "sha512-hc5ZAYSonPAuhIYgTV/6RzWYm8ZYF2iW3vdU4xO2BJxwu3vNLxn9SZ3Zd5oCe2fzjiUdDmQHog4Rj6BO9yRXAA=="], "point-in-polygon-hao": ["point-in-polygon-hao@1.2.4", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-x2pcvXeqhRHlNRdhLs/tgFapAbSSe86wa/eqmj1G6pWftbEs5aVRJhRGM6FYSUERKu0PjekJzMq0gsI2XyiclQ=="], + "polylabel": ["polylabel@1.1.0", "", { "dependencies": { "tinyqueue": "^2.0.3" } }, "sha512-bxaGcA40sL3d6M4hH72Z4NdLqxpXRsCFk8AITYg6x1rn1Ei3izf00UMLklerBZTO49aPA3CYrIwVulx2Bce2pA=="], + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], "postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="], @@ -2181,6 +2591,8 @@ "powershell-utils": ["powershell-utils@0.2.0", "", {}, "sha512-ZlsFlG7MtSFCoc5xreOvBAozCJ6Pf06opgJjh9ONEv418xpZSAzNjstD36C6+JwOnfSqOW/9uDkqKjezTdxZhw=="], + "preact": ["preact@10.29.8", "", { "peerDependencies": { "preact-render-to-string": ">=5" }, "optionalPeers": ["preact-render-to-string"] }, "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q=="], + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], @@ -2211,6 +2623,8 @@ "qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="], + "quadbin": ["quadbin@0.4.2", "", { "dependencies": { "@math.gl/web-mercator": "^4.1.0" } }, "sha512-1NFzjFVM23Um51/ttD6lFDqGtUHNS5Ky1slZHk3YPwMbC+7Jl3ULLb4QvDo6+Nerv8b8SgUV+ysOhziUh4B5cQ=="], + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], "quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="], @@ -2277,6 +2691,8 @@ "redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="], + "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], + "registry-auth-token": ["registry-auth-token@5.1.1", "", { "dependencies": { "@pnpm/npm-conf": "^3.0.2" } }, "sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q=="], "registry-url": ["registry-url@3.1.0", "", { "dependencies": { "rc": "^1.0.1" } }, "sha512-ZbgR5aZEdf4UKZVBPYIgaglBmSF2Hi94s2PcIHhRGFjKYu+chjJdYfHn4rt3hB6eCKLJ8giVIIfgMa1ehDfZKA=="], @@ -2333,14 +2749,20 @@ "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + "rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="], + "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "sax": ["sax@1.6.1", "", {}, "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "seedrandom": ["seedrandom@3.0.5", "", {}, "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg=="], + "semantic-release": ["semantic-release@22.0.12", "", { "dependencies": { "@semantic-release/commit-analyzer": "^11.0.0", "@semantic-release/error": "^4.0.0", "@semantic-release/github": "^9.0.0", "@semantic-release/npm": "^11.0.0", "@semantic-release/release-notes-generator": "^12.0.0", "aggregate-error": "^5.0.0", "cosmiconfig": "^8.0.0", "debug": "^4.0.0", "env-ci": "^10.0.0", "execa": "^8.0.0", "figures": "^6.0.0", "find-versions": "^5.1.0", "get-stream": "^6.0.0", "git-log-parser": "^1.2.0", "hook-std": "^3.0.0", "hosted-git-info": "^7.0.0", "import-from-esm": "^1.3.1", "lodash-es": "^4.17.21", "marked": "^9.0.0", "marked-terminal": "^6.0.0", "micromatch": "^4.0.2", "p-each-series": "^3.0.0", "p-reduce": "^3.0.0", "read-pkg-up": "^11.0.0", "resolve-from": "^5.0.0", "semver": "^7.3.2", "semver-diff": "^4.0.0", "signale": "^1.2.1", "yargs": "^17.5.1" }, "bin": { "semantic-release": "bin/semantic-release.js" } }, "sha512-0mhiCR/4sZb00RVFJIUlMuiBkW3NMpVIW2Gse7noqEMoFGkvfPPAImEQbkBV8xga4KOPP4FdTRYuLLy32R1fPw=="], "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], @@ -2357,6 +2779,8 @@ "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], + "set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="], + "setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="], "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], @@ -2397,10 +2821,14 @@ "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], + "snappyjs": ["snappyjs@0.6.1", "", {}, "sha512-YIK6I2lsH072UE0aOFxxY1dPDCS43I5ktqHpeAsuLNYWkE5pGxRGWfDM4/vSUfNzXjC1Ivzt3qx31PCLmc9yqg=="], + "socks": ["socks@2.8.9", "", { "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" } }, "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw=="], "sonner": ["sonner@2.0.8", "", { "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-UM/ByIoFra8yzV75n1o0Puu0bw5U/9UNnDacrJNspekBewIfsQ3D6ez1nvlWpt7aTsO6rujQtifBpycwIivqlg=="], + "sortablejs": ["sortablejs@1.15.7", "", {}, "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A=="], + "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], @@ -2417,6 +2845,8 @@ "split2": ["split2@1.0.0", "", { "dependencies": { "through2": "~2.0.0" } }, "sha512-NKywug4u4pX/AZBB1FCPzZ6/7O+Xhz1qMVbzTvvKvikjO99oPN87SkK08mEY9P63/5lWjK+wgOOgApnTg5r6qg=="], + "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], + "sqlstring": ["sqlstring@2.3.3", "", {}, "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg=="], "state-local": ["state-local@1.0.7", "", {}, "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w=="], @@ -2447,6 +2877,8 @@ "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + "strnum": ["strnum@2.4.2", "", { "dependencies": { "anynum": "^1.0.1" } }, "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw=="], + "stylis": ["stylis@4.2.0", "", {}, "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="], "suncalc": ["suncalc@1.9.0", "", {}, "sha512-vMJ8Byp1uIPoj+wb9c1AdK4jpkSKVAywgHX0lqY7zt6+EWRRC3Z+0Ucfjy/0yxTVO1hwwchZe4uoFNqrIC24+A=="], @@ -2459,12 +2891,16 @@ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + "svg-arc-to-cubic-bezier": ["svg-arc-to-cubic-bezier@3.2.0", "", {}, "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g=="], + "sweepline-intersections": ["sweepline-intersections@1.5.0", "", { "dependencies": { "tinyqueue": "^2.0.0" } }, "sha512-AoVmx72QHpKtItPu72TzFL+kcYjd67BPLDoR0LarIk+xyaRg+pDTMFXndIEvZf9xEKnJv6JdhgRMnocoG0D3AQ=="], "symbol-observable": ["symbol-observable@4.0.0", "", {}, "sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ=="], "systeminformation": ["systeminformation@5.33.1", "", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-DEN6ICHk3Tk0Uf/hrAHh7xlt7iL5CJFBtPZinA0H62DrGG/KPKqq/Nzj6lCXPS4Ay/sf/14zNnk9LpqKzBIc+w=="], + "tabbable": ["tabbable@6.5.0", "", {}, "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA=="], + "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], @@ -2485,6 +2921,8 @@ "text-table": ["text-table@0.2.0", "", {}, "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="], + "texture-compressor": ["texture-compressor@1.0.2", "", { "dependencies": { "argparse": "^1.0.10", "image-size": "^0.7.4" }, "bin": { "texture-compressor": "./bin/texture-compressor.js" } }, "sha512-dStVgoaQ11mA5htJ+RzZ51ZxIZqNOgWKAIvtjLrW1AliQQLCmrDqNzQZ8Jh91YealQ95DXt4MEduLzJmbs6lig=="], + "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], @@ -2495,6 +2933,10 @@ "tildify": ["tildify@2.0.0", "", {}, "sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw=="], + "timezone-groups": ["timezone-groups@0.10.4", "", {}, "sha512-AnkJYrbb7uPkDCEqGeVJiawZNiwVlSkkeX4jZg1gTEguClhyX+/Ezn07KB6DT29tG3UN418ldmS/W6KqGOTDjg=="], + + "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], "tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="], @@ -2559,6 +3001,10 @@ "unicode-emoji-modifier-base": ["unicode-emoji-modifier-base@1.0.0", "", {}, "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g=="], + "unicode-properties": ["unicode-properties@1.4.1", "", { "dependencies": { "base64-js": "^1.3.0", "unicode-trie": "^2.0.0" } }, "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg=="], + + "unicode-trie": ["unicode-trie@2.0.0", "", { "dependencies": { "pako": "^0.2.5", "tiny-inflate": "^1.0.0" } }, "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ=="], + "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], "unique-string": ["unique-string@3.0.0", "", { "dependencies": { "crypto-random-string": "^4.0.0" } }, "sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ=="], @@ -2661,6 +3107,12 @@ "xdg-basedir": ["xdg-basedir@2.0.0", "", { "dependencies": { "os-homedir": "^1.0.0" } }, "sha512-NF1pPn594TaRSUO/HARoB4jK8I+rWgcpVlpQCK6/6o5PHyLUt2CSiDrpUZbQ6rROck+W2EwF8mBJcTs+W98J9w=="], + "xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="], + + "xmldoc": ["xmldoc@2.0.3", "", { "dependencies": { "sax": "^1.4.3" } }, "sha512-6gRk4NY/Jvg67xn7OzJuxLRsGgiXBaPUQplVJ/9l99uIugxh4FTOewYz5ic8WScj7Xx/2WvhENiQKwkK9RpE4w=="], + + "xss": ["xss@1.0.13", "", { "dependencies": { "commander": "^2.20.3", "cssfilter": "0.0.10" }, "bin": { "xss": "bin/xss" } }, "sha512-clu7dxTm1e8Mo5fz3n/oW3UCXBfV89xZ72jM8yzo1vR/pIS0w3sgB3XV2H8Vm6zfGnHL0FzvLJPJEBhd86/z4Q=="], + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], @@ -2687,10 +3139,14 @@ "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + "zstd-codec": ["zstd-codec@0.1.5", "", {}, "sha512-v3fyjpK8S/dpY/X5WxqTK3IoCnp/ZOLxn144GZVlNUjtwAchzrVo03h+oMATFhCIiJ5KTr4V3vDQQYz4RU684g=="], + "zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], "@apollo/server/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + "@arcgis/core/marked": ["marked@16.3.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-K3UxuKu6l6bmA5FUwYho8CfJBlsUWAooKtdGgMcERSpF7gcBUrCGsLH7wDaaNOzwq18JzSUDyoEb/YsrqMac3w=="], + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], @@ -2701,6 +3157,8 @@ "@base-ui/utils/reselect": ["reselect@5.3.0", "", {}, "sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg=="], + "@carto/api-client/jsep": ["jsep@1.4.0", "", {}, "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw=="], + "@commitlint/format/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "@commitlint/load/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], @@ -2711,6 +3169,8 @@ "@commitlint/types/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "@deck.gl/geo-layers/long": ["long@3.2.0", "", {}, "sha512-ZYvPPOMqUwPoDsbJaR10iQJYnMuZhRTvHYl62ErLIEX7RgFlziSBUUvrt3OVfc47QlHHpzPZYP17g3Fv7oeJkg=="], + "@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], "@discordjs/ws/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], @@ -2731,6 +3191,10 @@ "@eslint/eslintrc/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "@foliojs-fork/fontkit/clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="], + + "@foliojs-fork/linebreak/base64-js": ["base64-js@1.3.1", "", {}, "sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g=="], + "@graphql-tools/import/@graphql-tools/utils": ["@graphql-tools/utils@12.0.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-aMdIo/l+8j4lhamWAf+MWHr+lOW8zArSBKXspqtKHgt5I2JF9LS55KDLUe/L9AiJOV/O6qJJ60hgYydbnJHbxg=="], "@graphql-tools/merge/@graphql-tools/utils": ["@graphql-tools/utils@12.0.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-aMdIo/l+8j4lhamWAf+MWHr+lOW8zArSBKXspqtKHgt5I2JF9LS55KDLUe/L9AiJOV/O6qJJ60hgYydbnJHbxg=="], @@ -2745,6 +3209,10 @@ "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + "@loaders.gl/3d-tiles/long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + + "@loaders.gl/gis/@mapbox/vector-tile": ["@mapbox/vector-tile@1.3.1", "", { "dependencies": { "@mapbox/point-geometry": "~0.1.0" } }, "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw=="], + "@mapbox/vector-tile/pbf": ["pbf@5.1.2", "", { "dependencies": { "resolve-protobuf-schema": "^2.1.0" }, "bin": { "pbf": "bin/pbf" } }, "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w=="], "@maplibre/vt-pbf/pbf": ["pbf@5.1.2", "", { "dependencies": { "resolve-protobuf-schema": "^2.1.0" }, "bin": { "pbf": "bin/pbf" } }, "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w=="], @@ -2811,10 +3279,32 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@turf/boolean-clockwise/@turf/helpers": ["@turf/helpers@5.1.5", "", {}, "sha512-/lF+JR+qNDHZ8bF9d+Cp58nxtZWJ3sqFe6n3u3Vpj+/0cqkjk4nXKYBSY0azm+GIYB5mWKxUXvuP/m0ZnKj1bw=="], + + "@turf/boolean-clockwise/@turf/invariant": ["@turf/invariant@5.2.0", "", { "dependencies": { "@turf/helpers": "^5.1.5" } }, "sha512-28RCBGvCYsajVkw2EydpzLdcYyhSA77LovuOvgCJplJWaNVyJYH6BOR3HR9w50MEkPqb/Vc/jdo6I6ermlRtQA=="], + + "@turf/clone/@turf/helpers": ["@turf/helpers@5.1.5", "", {}, "sha512-/lF+JR+qNDHZ8bF9d+Cp58nxtZWJ3sqFe6n3u3Vpj+/0cqkjk4nXKYBSY0azm+GIYB5mWKxUXvuP/m0ZnKj1bw=="], + + "@turf/rewind/@turf/helpers": ["@turf/helpers@5.1.5", "", {}, "sha512-/lF+JR+qNDHZ8bF9d+Cp58nxtZWJ3sqFe6n3u3Vpj+/0cqkjk4nXKYBSY0azm+GIYB5mWKxUXvuP/m0ZnKj1bw=="], + + "@turf/rewind/@turf/invariant": ["@turf/invariant@5.2.0", "", { "dependencies": { "@turf/helpers": "^5.1.5" } }, "sha512-28RCBGvCYsajVkw2EydpzLdcYyhSA77LovuOvgCJplJWaNVyJYH6BOR3HR9w50MEkPqb/Vc/jdo6I6ermlRtQA=="], + + "@turf/rewind/@turf/meta": ["@turf/meta@5.2.0", "", { "dependencies": { "@turf/helpers": "^5.1.5" } }, "sha512-ZjQ3Ii62X9FjnK4hhdsbT+64AYRpaI8XMBMcyftEOGSmPMUVnkbvuv3C9geuElAXfQU7Zk1oWGOcrGOD9zr78Q=="], + + "@types/brotli/@types/node": ["@types/node@25.9.5", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg=="], + + "@types/d3/@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + + "@types/d3-sankey/@types/d3-shape": ["@types/d3-shape@1.3.12", "", { "dependencies": { "@types/d3-path": "^1" } }, "sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q=="], + + "@types/d3-scale/@types/d3-time": ["@types/d3-time@2.1.4", "", {}, "sha512-BTfLsxTeo7yFxI/haOOf1ZwJ6xKgQLT9dCp+EcmQv87Gox6X+oKl4mLKfO6fnWm3P22+A6DknMNEZany8ql2Rw=="], + "ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "apache-arrow/@types/node": ["@types/node@25.9.5", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg=="], + "babel-plugin-macros/cosmiconfig": ["cosmiconfig@7.1.0", "", { "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", "parse-json": "^5.0.0", "path-type": "^4.0.0", "yaml": "^1.10.0" } }, "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA=="], "bl/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], @@ -2837,6 +3327,10 @@ "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + "color/color-convert": ["color-convert@3.1.3", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg=="], + + "color-string/color-name": ["color-name@2.1.1", "", {}, "sha512-p2FdgwVx1a9yWBHP2wI0VgShkDpgN4kZISkxdNipGBJWpa5G6b04OINlVWCyJj0JmfvcPrgqt95E9k8yvaOJFg=="], + "commitizen/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], "compression/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], @@ -2865,6 +3359,24 @@ "cz-conventional-commit/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], + "d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], + + "d3-dsv/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "d3-sankey/d3-array": ["d3-array@2.12.1", "", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="], + + "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], + + "d3-voronoi-map/d3-dispatch": ["d3-dispatch@2.0.0", "", {}, "sha512-S/m2VsXI7gAti2pBoLClFFTMOO1HTtT0j99AuXLoGFKO6deHDdnv6ZGTxSTTUTgO1zVcv82fCOtDjYK4EECmWA=="], + + "d3-voronoi-map/d3-polygon": ["d3-polygon@2.0.0", "", {}, "sha512-MsexrCK38cTGermELs0cO1d79DcTsQRN7IWMJKczD/2kBjzNXxLUWP33qRF6VDpiLV/4EI4r6Gs0DAWQkE8pSQ=="], + + "d3-voronoi-map/d3-timer": ["d3-timer@2.0.0", "", {}, "sha512-TO4VLh0/420Y/9dO3+f9abDEFYeCUr2WZRlxJvbp4HPTQcSylXNiL6yZa9FIUvV1yRiFufl1bszTCLDqv9PWNA=="], + + "d3-weighted-voronoi/d3-array": ["d3-array@2.12.1", "", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="], + + "d3-weighted-voronoi/d3-polygon": ["d3-polygon@2.0.0", "", {}, "sha512-MsexrCK38cTGermELs0cO1d79DcTsQRN7IWMJKczD/2kBjzNXxLUWP33qRF6VDpiLV/4EI4r6Gs0DAWQkE8pSQ=="], + "debounce-fn/mimic-fn": ["mimic-fn@3.1.0", "", {}, "sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ=="], "defaults/clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="], @@ -2945,6 +3457,8 @@ "log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], + "maplibre-gl/earcut": ["earcut@3.2.3", "", {}, "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q=="], + "maplibre-gl/pbf": ["pbf@5.1.2", "", { "dependencies": { "resolve-protobuf-schema": "^2.1.0" }, "bin": { "pbf": "bin/pbf" } }, "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w=="], "marked-terminal/ansi-escapes": ["ansi-escapes@6.2.1", "", {}, "sha512-4nJ3yixlEthEJ9Rk4vPcdBRkZvQZlYyu8j4/Mqz5sgIkddmEnH2Yj2ZrnP9S3tQOvSNRUIgVNF/1yPpRAGNRig=="], @@ -3341,6 +3855,8 @@ "pogo-data-generator/node-fetch": ["node-fetch@2.6.7", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ=="], + "polylabel/tinyqueue": ["tinyqueue@2.0.3", "", {}, "sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA=="], + "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], @@ -3403,18 +3919,24 @@ "tempy/type-fest": ["type-fest@2.19.0", "", {}, "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA=="], + "texture-compressor/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "to-buffer/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], "tsconfig-paths/strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], "type-is/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + "unicode-trie/pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="], + "unixify/normalize-path": ["normalize-path@2.1.1", "", { "dependencies": { "remove-trailing-separator": "^1.0.1" } }, "sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w=="], "update-notifier/chalk": ["chalk@1.1.3", "", { "dependencies": { "ansi-styles": "^2.2.1", "escape-string-regexp": "^1.0.2", "has-ansi": "^2.0.0", "strip-ansi": "^3.0.0", "supports-color": "^2.0.0" } }, "sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A=="], "update-notifier/semver-diff": ["semver-diff@2.1.0", "", { "dependencies": { "semver": "^5.0.3" } }, "sha512-gL8F8L4ORwsS0+iQ34yCYv///jsOq0ZL7WP55d1HnJ32o7tyFYEFQZQA22mrLIacZdU6xecaBBZ+uEiffGNyXw=="], + "victory-vendor/@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + "vite-plugin-checker/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], "vscode-languageclient/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], @@ -3425,6 +3947,8 @@ "wsl-utils/powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], + "xss/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "@commitlint/top-level/find-up/locate-path": ["locate-path@7.2.0", "", { "dependencies": { "p-locate": "^6.0.0" } }, "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA=="], @@ -3447,6 +3971,8 @@ "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "@loaders.gl/gis/@mapbox/vector-tile/@mapbox/point-geometry": ["@mapbox/point-geometry@0.1.0", "", {}, "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ=="], + "@octokit/plugin-paginate-rest/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@20.0.0", "", {}, "sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA=="], "@octokit/plugin-throttling/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@20.0.0", "", {}, "sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA=="], @@ -3511,6 +4037,12 @@ "@sentry/cli/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "@types/brotli/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + + "@types/d3-sankey/@types/d3-shape/@types/d3-path": ["@types/d3-path@1.0.11", "", {}, "sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw=="], + + "apache-arrow/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "babel-plugin-macros/cosmiconfig/yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="], "boxen/chalk/ansi-styles": ["ansi-styles@2.2.1", "", {}, "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA=="], @@ -3533,6 +4065,8 @@ "cli-highlight/yargs/yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="], + "color/color-convert/color-name": ["color-name@2.1.1", "", {}, "sha512-p2FdgwVx1a9yWBHP2wI0VgShkDpgN4kZISkxdNipGBJWpa5G6b04OINlVWCyJj0JmfvcPrgqt95E9k8yvaOJFg=="], + "compression/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], @@ -3541,6 +4075,12 @@ "cz-conventional-commit/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], + "d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], + + "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], + + "d3-weighted-voronoi/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], + "env-ci/execa/get-stream": ["get-stream@8.0.1", "", {}, "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA=="], "env-ci/execa/human-signals": ["human-signals@5.0.0", "", {}, "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ=="], diff --git a/package.json b/package.json index 4bd006361..3feb60ace 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,9 @@ "@apollo/server": "5.5.1", "@as-integrations/express5": "1.1.2", "@base-ui/react": "1.7.0", + "@deck.gl/core": "9.3.10", + "@deck.gl/layers": "9.3.10", + "@deck.gl/mapbox": "9.3.10", "@emotion/react": "11.14.0", "@emotion/styled": "11.14.1", "@fontsource-variable/fredoka": "5.3.0", @@ -134,6 +137,7 @@ "cors": "^2.8.6", "date-fns": "^2.30.0", "date-fns-tz": "^2.0.0", + "deck.gl": "9.3.10", "discord.js": "^14.26.4", "dlv": "^1.1.3", "dotenv": "^16.3.1", diff --git a/vite.config.js b/vite.config.js index 4c9817ab0..6f66ba4ab 100644 --- a/vite.config.js +++ b/vite.config.js @@ -176,6 +176,18 @@ const viteConfig = defineConfig(({ mode }) => { // behind index.html's stylesheet (every 1.0 route too) - // either way a visitor who never opens /map would pay for it. if (id.includes('node_modules/maplibre-gl')) return 'maplibre' + // deck.gl is the same story: only ever reached from app/map, + // behind /map's lazy() call. Left ungrouped it falls into the + // generic 'vendor' check below, which app.html preloads on + // every 2.0 route - confirmed by build output before this rule + // existed, where deck.gl's ~1.4MB landed in vendor and pushed + // every route's preload past 2.2MB, not just /map's. + if ( + id.includes('node_modules/@deck.gl') || + id.includes('node_modules/deck.gl') + ) { + return 'deckgl' + } if (id.endsWith('.css')) { return id.startsWith(appDir) ? 'app' : 'index' } From e82dda829504caa64d419c016b552525e1308526 Mon Sep 17 00:00:00 2001 From: Rin <58572875+TurtIeSocks@users.noreply.github.com> Date: Mon, 24 Aug 2026 02:00:42 -0400 Subject: [PATCH 13/24] fix(build): keep deck.gl's engine out of the shared vendor chunk The rule added with deck.gl matched its own package names only. Its rendering engine ships under separate scopes, so luma.gl, math.gl and loaders.gl fell through to the generic vendor bucket that every entry preloads, including the 1.0 hub. Measured: 414 kB raw and 118 kB gzipped of WebGL engine downloaded by visitors who never open a map. Vendor is now 1537 kB rather than 1898 kB and contains none of it. This is the fourth thing to leak through this rule, after tailwind's preflight, the font faces and maplibre's stylesheet. Enumerating package names is not a fix for that pattern, it is a fix for one instance of it. The durable answer is to stop bucketing by path prefix and let rollup place modules by which entry reaches them, which was measured and does work, but it changes the chunk 1.0 users are served and belongs in its own change with its own verification rather than riding along inside a map task. Co-Authored-By: Claude Opus 5 --- vite.config.js | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/vite.config.js b/vite.config.js index 6f66ba4ab..9a58abb63 100644 --- a/vite.config.js +++ b/vite.config.js @@ -182,9 +182,26 @@ const viteConfig = defineConfig(({ mode }) => { // every 2.0 route - confirmed by build output before this rule // existed, where deck.gl's ~1.4MB landed in vendor and pushed // every route's preload past 2.2MB, not just /map's. + // deck.gl's rendering engine ships under separate npm scopes, so + // matching only its own name left luma.gl, math.gl and loaders.gl + // falling through to the generic vendor bucket that every entry + // preloads, the 1.0 hub included. Measured at the time: 414 kB + // raw and 118 kB gzipped of WebGL engine in front of visitors who + // never open the map. + // + // Enumerating scopes is fragile, and this is the fourth thing to + // leak through this rule after tailwind's preflight, the font + // faces and maplibre's stylesheet. The durable fix is to stop + // bucketing by path prefix and let rollup place modules by which + // entry reaches them, but dropping the vendor catch-all changes + // the chunk 1.0 users are served, so that wants its own change + // with its own verification rather than riding along here. if ( id.includes('node_modules/@deck.gl') || - id.includes('node_modules/deck.gl') + id.includes('node_modules/deck.gl') || + id.includes('node_modules/@luma.gl') || + id.includes('node_modules/@math.gl') || + id.includes('node_modules/@loaders.gl') ) { return 'deckgl' } From e0bbdb6811a55165dbd7a9b6ec4c2cb2f33b7bbd Mon Sep 17 00:00:00 2001 From: Rin <58572875+TurtIeSocks@users.noreply.github.com> Date: Mon, 24 Aug 2026 06:27:17 -0400 Subject: [PATCH 14/24] feat(map): wire deck.gl picking to a single anchored popup Replaces per-marker refs and Leaflet popup events with deck.gl's built-in picking. One selected entity at a time, reprojected to screen pixels every camera frame off its own coordinate rather than the click point, so the popup stays glued to its entity through pan and zoom. Popup content reuses the existing Card and Button components instead of building new chrome. --- app/map/MapCanvas.tsx | 42 ++++++++++++-- app/map/Popup.test.tsx | 98 ++++++++++++++++++++++++++++++++ app/map/Popup.tsx | 91 ++++++++++++++++++++++++++++++ app/map/useMapLibre.test.ts | 68 +++++++++++++++++++++- app/map/useMapLibre.ts | 109 ++++++++++++++++++++++++++++++++++-- 5 files changed, 395 insertions(+), 13 deletions(-) create mode 100644 app/map/Popup.test.tsx create mode 100644 app/map/Popup.tsx diff --git a/app/map/MapCanvas.tsx b/app/map/MapCanvas.tsx index b2c688310..ecd512529 100644 --- a/app/map/MapCanvas.tsx +++ b/app/map/MapCanvas.tsx @@ -1,13 +1,15 @@ import 'maplibre-gl/dist/maplibre-gl.css' -import { useEffect, useMemo, useRef, useState } from 'react' +import type { PickingInfo } from '@deck.gl/core' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createAtlas } from './atlas' import { drawGymIcon, drawPokemonIcon } from './draw-icon' import { buildMapLayers } from './layers' +import { Popup } from './Popup' import { createFixtureSource } from './source' import type { GymEntity, MapEntity, MapQuery, PokemonEntity } from './types' import type { Camera } from './useMapLibre' -import { useMapLibre } from './useMapLibre' +import { anchorFor, pickedEntityFrom, useMapLibre } from './useMapLibre' export interface MapCanvasProps { initialCamera: Camera @@ -70,6 +72,23 @@ export function MapCanvas({ initialCamera, onCameraChange }: MapCanvasProps) { // rebuilt from it on the same tick rather than each owning its own. const [now, setNow] = useState(() => Date.now()) + // The one selected entity, or none. This is the entire replacement for + // 1.0's per-marker ref plus useForcePopup/useMarkerTimer: deck.gl's + // picking reports what is under the cursor, and there is exactly one + // popup, so one nullable slot is the whole model. + const [selected, setSelected] = useState(null) + + const handlePick = useCallback((info: PickingInfo) => { + setSelected(pickedEntityFrom(info)) + }, []) + + const closePopup = useCallback(() => setSelected(null), []) + + // Recreated only when the selection itself changes, not on every render: + // useMapLibre's reprojection effect keys off this object's identity, and + // a fresh object every render would refire it needlessly. + const anchor = useMemo(() => anchorFor(selected), [selected]) + useEffect(() => { const source = sourceRef.current if (!source) return undefined @@ -102,18 +121,29 @@ export function MapCanvas({ initialCamera, onCameraChange }: MapCanvasProps) { }) }, [pokemon, gyms, now]) - const { containerRef } = useMapLibre({ + const { containerRef, screenPosition } = useMapLibre({ initialCamera, ...(onCameraChange ? { onCameraChange } : {}), layers, + onPick: handlePick, + anchor, }) return (
+ > +
+ {selected && screenPosition && ( + + )} +
) } diff --git a/app/map/Popup.test.tsx b/app/map/Popup.test.tsx new file mode 100644 index 000000000..0cca40227 --- /dev/null +++ b/app/map/Popup.test.tsx @@ -0,0 +1,98 @@ +import { afterAll, afterEach, beforeAll, expect, test } from 'bun:test' +import { cleanup, fireEvent, render, within } from '@testing-library/react' +import { setupDom, teardownDom } from '../test-setup' +import { Popup } from './Popup' +import type { GymEntity, PokemonEntity } from './types' + +/* + * The popup is a plain function of `entity`, `x`, `y`, `now` and + * `onClose`; nothing here needs a map, a camera or WebGL. What genuinely + * needs a browser - whether the position this renders at actually + * survives a real pan or zoom - is `useMapLibre`'s reprojection, covered + * (as far as it can be without a GPU) in useMapLibre.test.ts, and stated + * plainly in task-5-report.md as something only manual/browser + * verification can back. + */ + +beforeAll(setupDom) +afterAll(teardownDom) +afterEach(cleanup) + +const POKEMON: PokemonEntity = { + kind: 'pokemon', + spawnId: 'spawn-1', + pokemonId: 25, + form: 0, + costume: 0, + gender: 1, + lat: 51.5, + lon: -0.1, + expiresAt: 90_000, + iv: 87, +} + +const GYM: GymEntity = { + kind: 'gym', + gymId: 'gym-1', + lat: 40.7, + lon: -74, + team: 2, + inBattle: true, +} + +test('positions itself at the fixed x/y it is given, not something it computes', () => { + const { container } = render( + {}} now={0} />, + ) + const card = container.querySelector('[data-slot="map-popup"]') + expect(card).toBeTruthy() + expect((card as HTMLElement).style.left).toBe('123px') + expect((card as HTMLElement).style.top).toBe('33px') +}) + +test("shows a pokemon entity's species, IV and countdown", () => { + const { container } = render( + {}} now={0} />, + ) + expect(within(container).getByText('Pokemon #25')).toBeTruthy() + expect(within(container).getByText(/87% IV/)).toBeTruthy() + expect(within(container).getByText(/1:30/)).toBeTruthy() +}) + +test("shows a gym entity's team and battle state", () => { + const { container } = render( + {}} now={0} />, + ) + expect(within(container).getByText('Gym (in battle)')).toBeTruthy() + expect(within(container).getByText('Team 2')).toBeTruthy() +}) + +test('the team swatch reads its colour from the data palette token, not a literal', () => { + const { container } = render( + {}} now={0} />, + ) + const swatch = container.querySelector('[aria-hidden="true"].rounded-full') + expect((swatch as HTMLElement).style.backgroundColor).toBe( + 'var(--color-team-2)', + ) +}) + +test('closing calls back exactly once per click', () => { + let closeCount = 0 + const { container } = render( + { + closeCount += 1 + }} + now={0} + />, + ) + const closeButton = within(container).getByRole('button', { + name: 'Close', + }) + fireEvent.click(closeButton) + expect(closeCount).toBe(1) +}) diff --git a/app/map/Popup.tsx b/app/map/Popup.tsx new file mode 100644 index 000000000..8b8240c7b --- /dev/null +++ b/app/map/Popup.tsx @@ -0,0 +1,91 @@ +import { Button } from '@app/components/ui/button' +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@app/components/ui/card' +import { XIcon } from 'lucide-react' +import { formatCountdown } from './layers' +import type { MapEntity } from './types' + +export interface PopupProps { + /** The entity this popup describes. Exactly one of these exists at a time. */ + entity: MapEntity + /** + * Where `entity`'s coordinate currently projects to, in pixels relative + * to the map container. Owned by `useMapLibre`, which recomputes it on + * every camera frame; this component only ever reads a position, it + * never computes one. See task-5-report.md for why. + */ + x: number + y: number + onClose: () => void + /** Clock the countdown reads against. Defaults to the real time; tests + * pin this to get a deterministic countdown string. */ + now?: number +} + +function titleFor(entity: MapEntity): string { + return entity.kind === 'pokemon' + ? `Pokemon #${entity.pokemonId}` + : `Gym${entity.inBattle ? ' (in battle)' : ''}` +} + +/** + * The one popup this map ever shows. Positioned with a plain absolute + * offset rather than through the `Popover` primitive's own floating-ui + * placement: `Popover`'s auto-update tracks its anchor element's DOM rect, + * but this component's anchor is a data coordinate reprojected by + * `useMapLibre` on every camera frame, not a DOM element that moves on its + * own. Driving position from `x`/`y` props keeps this component a pure + * function of its inputs, which is what makes it testable without a map at + * all (see Popup.test.tsx). `Card` still supplies the chrome - rounded + * panel, header, close affordance - so nothing here is hand-built. + */ +export function Popup({ entity, x, y, onClose, now = Date.now() }: PopupProps) { + return ( + + + {titleFor(entity)} + + + + + + {entity.kind === 'pokemon' ? ( + + {entity.iv !== undefined ? `${entity.iv}% IV` : 'IV unknown'} + {' · '} + {formatCountdown(entity.expiresAt, now)} + + ) : ( + + + )} + + + ) +} diff --git a/app/map/useMapLibre.test.ts b/app/map/useMapLibre.test.ts index 1ee28ad31..3b80323ca 100644 --- a/app/map/useMapLibre.test.ts +++ b/app/map/useMapLibre.test.ts @@ -1,6 +1,8 @@ import { expect, test } from 'bun:test' +import type { PickingInfo } from '@deck.gl/core' import { IconLayer } from '@deck.gl/layers' -import { buildOverlayProps } from './useMapLibre' +import type { GymEntity, PokemonEntity } from './types' +import { anchorFor, buildOverlayProps, pickedEntityFrom } from './useMapLibre' /* * Mounting a real MapLibre map needs a WebGL context the test environment @@ -28,3 +30,67 @@ test('overlay props carry whatever layers are passed through unchanged', () => { const props = buildOverlayProps([layer]) expect(props.layers).toEqual([layer]) }) + +test('overlay props omit onClick when none is given, so deck.gl skips picking work on every click', () => { + const props = buildOverlayProps([]) + expect('onClick' in props).toBe(false) +}) + +test('overlay props carry the click handler through when one is given', () => { + const onClick = () => {} + const props = buildOverlayProps([], onClick) + expect(props.onClick).toBe(onClick) +}) + +/* + * Picking and reprojection themselves need a WebGL context and a mounted + * MapLibre map, which this environment does not have (see the file-level + * comment above). What is testable in isolation is the selection state + * transition - a picking result in, an entity or null out - and the pure + * coordinate lookup that decides what a popup anchors to. + */ + +const POKEMON: PokemonEntity = { + kind: 'pokemon', + spawnId: 'spawn-1', + pokemonId: 25, + form: 0, + costume: 0, + gender: 1, + lat: 51.5, + lon: -0.1, + expiresAt: 1_000, +} + +const GYM: GymEntity = { + kind: 'gym', + gymId: 'gym-1', + lat: 40.7, + lon: -74, + team: 2, + inBattle: false, +} + +function pickingInfoFor(object: PokemonEntity | GymEntity | undefined) { + return { object } as PickingInfo +} + +test('a picking hit on a pokemon selects that pokemon', () => { + expect(pickedEntityFrom(pickingInfoFor(POKEMON))).toEqual(POKEMON) +}) + +test('a picking hit on a gym selects that gym', () => { + expect(pickedEntityFrom(pickingInfoFor(GYM))).toEqual(GYM) +}) + +test('a picking miss (empty map, no object) clears the selection', () => { + expect(pickedEntityFrom(pickingInfoFor(undefined))).toBeNull() +}) + +test('the anchor for a selected entity is exactly its coordinate', () => { + expect(anchorFor(POKEMON)).toEqual({ lat: 51.5, lon: -0.1 }) +}) + +test('the anchor for no selection is null', () => { + expect(anchorFor(null)).toBeNull() +}) diff --git a/app/map/useMapLibre.ts b/app/map/useMapLibre.ts index 25e810ddb..cec94a545 100644 --- a/app/map/useMapLibre.ts +++ b/app/map/useMapLibre.ts @@ -1,9 +1,10 @@ -import type { Layer } from '@deck.gl/core' +import type { Layer, PickingInfo } from '@deck.gl/core' import type { MapboxOverlayProps } from '@deck.gl/mapbox' import { MapboxOverlay } from '@deck.gl/mapbox' import { Map as MapLibreMap, NavigationControl } from 'maplibre-gl' -import { type RefObject, useEffect, useRef } from 'react' +import { type RefObject, useEffect, useRef, useState } from 'react' import { resolveBasemapStyle } from './basemap' +import type { MapEntity } from './types' export interface Camera { lat: number @@ -11,6 +12,33 @@ export interface Camera { zoom: number } +/** A point to keep a popup anchored to, in map coordinates. */ +export interface AnchorCoordinate { + lat: number + lon: number +} + +/** Where `AnchorCoordinate` currently projects to, in container pixels. */ +export interface ScreenPosition { + x: number + y: number +} + +/** + * Reads the entity a deck.gl click landed on, if any. Pulled out as a + * plain function so the selection transition (miss clears, hit replaces) + * is assertable without a WebGL context - `PickingInfo` is a plain object + * this can be constructed by hand in a test; nothing here touches the map. + */ +export function pickedEntityFrom(info: PickingInfo): MapEntity | null { + return (info.object as MapEntity | undefined) ?? null +} + +/** The coordinate a selected entity's popup should stay anchored to. */ +export function anchorFor(entity: MapEntity | null): AnchorCoordinate | null { + return entity ? { lat: entity.lat, lon: entity.lon } : null +} + export interface UseMapLibreOptions { /** Camera to center on the moment the map is created. */ initialCamera: Camera @@ -32,11 +60,31 @@ export interface UseMapLibreOptions { * never tears down and rebuilds the overlay. */ layers?: Layer[] + /** + * Fired on every deck.gl click, picked or not (`info.object` is + * `undefined` on a miss). The caller owns selection state; this hook + * only reports what was under the cursor. + */ + onPick?: (info: PickingInfo) => void + /** + * The coordinate the caller wants a popup anchored to, or `null` when + * nothing is selected. Reprojected to container pixels on every camera + * frame; see `screenPosition` below and task-5-report.md for why this + * hook reprojects per frame rather than handing the coordinate to a + * MapLibre `Marker`. + */ + anchor?: AnchorCoordinate | null } export interface UseMapLibreResult { /** Attach to the element MapLibre should mount into. */ containerRef: RefObject + /** + * `anchor` projected through the current camera, in pixels relative to + * `containerRef`'s element. `null` when there is no anchor, or when the + * map has not mounted yet. + */ + screenPosition: ScreenPosition | null } /** @@ -50,8 +98,11 @@ export interface UseMapLibreResult { * visually sitting under labels - still needs a browser; see * task-4-report.md. */ -export function buildOverlayProps(layers: Layer[]): MapboxOverlayProps { - return { interleaved: true, layers } +export function buildOverlayProps( + layers: Layer[], + onClick?: (info: PickingInfo) => void, +): MapboxOverlayProps { + return { interleaved: true, layers, ...(onClick ? { onClick } : {}) } } /** @@ -65,17 +116,29 @@ export function useMapLibre({ initialCamera, onCameraChange, layers, + onPick, + anchor, }: UseMapLibreOptions): UseMapLibreResult { const containerRef = useRef(null) const mapRef = useRef(null) const overlayRef = useRef(null) const onCameraChangeRef = useRef(onCameraChange) onCameraChangeRef.current = onCameraChange + const onPickRef = useRef(onPick) + onPickRef.current = onPick // Read by the mount effect below, once, so the overlay starts with // whatever layers are already available instead of an empty frame while // waiting for the layers-sync effect to run. const initialLayersRef = useRef(layers) + const [screenPosition, setScreenPosition] = useState( + null, + ) + // Read by the 'move' handler on every frame, so the anchor coordinate is + // always current without re-subscribing that handler on every render. + const anchorRef = useRef(anchor) + anchorRef.current = anchor + useEffect(() => { const container = containerRef.current if (!container) return undefined @@ -90,7 +153,9 @@ export function useMapLibre({ map.addControl(new NavigationControl(), 'top-right') const overlay = new MapboxOverlay( - buildOverlayProps(initialLayersRef.current ?? []), + buildOverlayProps(initialLayersRef.current ?? [], (info) => + onPickRef.current?.(info), + ), ) map.addControl(overlay) overlayRef.current = overlay @@ -105,9 +170,28 @@ export function useMapLibre({ } map.on('moveend', handleMoveEnd) + // Reprojects the popup's anchor coordinate on every camera frame, not + // just on 'moveend'. A popup positioned only at gesture-end would + // visibly detach from its entity and snap back into place once the + // pan/zoom settled; 'move' fires continuously through the gesture and + // the whole animation, which is what keeps it glued to the coordinate. + // See task-5-report.md for why this reprojects instead of riding a + // MapLibre `Marker`. + const handleMove = () => { + const current = anchorRef.current + if (!current) { + setScreenPosition(null) + return + } + const point = map.project([current.lon, current.lat]) + setScreenPosition({ x: point.x, y: point.y }) + } + map.on('move', handleMove) + mapRef.current = map return () => { map.off('moveend', handleMoveEnd) + map.off('move', handleMove) // map.remove() tears down every control it holds, this overlay // included, so there is nothing extra to release here. map.remove() @@ -124,5 +208,18 @@ export function useMapLibre({ overlayRef.current?.setProps({ layers: layers ?? [] }) }, [layers]) - return { containerRef } + // Projects immediately when the anchor itself changes (a new selection, + // or a selection clearing), rather than waiting for the next 'move' + // event, which may not come until the user next pans. + useEffect(() => { + const map = mapRef.current + if (!anchor || !map) { + setScreenPosition(null) + return + } + const point = map.project([anchor.lon, anchor.lat]) + setScreenPosition({ x: point.x, y: point.y }) + }, [anchor]) + + return { containerRef, screenPosition } } From 6f3baf11f369e1e03fae45f54e2a25da17b1651d Mon Sep 17 00:00:00 2001 From: Rin <58572875+TurtIeSocks@users.noreply.github.com> Date: Mon, 24 Aug 2026 06:32:32 -0400 Subject: [PATCH 15/24] feat(map): dismiss the popup on escape The popup had a close button and nothing else. Radix's Popover would have given Escape for free, and it was rejected for a real reason: its positioning follows an element's DOM rect through a ResizeObserver, while this anchor moves by an inline style rewritten every camera frame. That argument covers layout and not dismissal, so the behaviour should not have gone with it. Lives in its own hook rather than an effect inside MapCanvas so it can actually be tested. MapCanvas needs a real WebGL context to render at all, so anything inside it is asserted rather than exercised. One of the tests covers the listener surviving unmount, which is silent until it fires against a component that no longer exists. Confirmed it catches that by removing the cleanup and watching it go red. Co-Authored-By: Claude Opus 5 --- app/map/MapCanvas.tsx | 3 ++ app/map/useDismissOnEscape.test.tsx | 54 +++++++++++++++++++++++++++++ app/map/useDismissOnEscape.ts | 25 +++++++++++++ 3 files changed, 82 insertions(+) create mode 100644 app/map/useDismissOnEscape.test.tsx create mode 100644 app/map/useDismissOnEscape.ts diff --git a/app/map/MapCanvas.tsx b/app/map/MapCanvas.tsx index ecd512529..1f7926de8 100644 --- a/app/map/MapCanvas.tsx +++ b/app/map/MapCanvas.tsx @@ -8,6 +8,7 @@ import { buildMapLayers } from './layers' import { Popup } from './Popup' import { createFixtureSource } from './source' import type { GymEntity, MapEntity, MapQuery, PokemonEntity } from './types' +import { useDismissOnEscape } from './useDismissOnEscape' import type { Camera } from './useMapLibre' import { anchorFor, pickedEntityFrom, useMapLibre } from './useMapLibre' @@ -84,6 +85,8 @@ export function MapCanvas({ initialCamera, onCameraChange }: MapCanvasProps) { const closePopup = useCallback(() => setSelected(null), []) + useDismissOnEscape(selected !== null, closePopup) + // Recreated only when the selection itself changes, not on every render: // useMapLibre's reprojection effect keys off this object's identity, and // a fresh object every render would refire it needlessly. diff --git a/app/map/useDismissOnEscape.test.tsx b/app/map/useDismissOnEscape.test.tsx new file mode 100644 index 000000000..d185c7861 --- /dev/null +++ b/app/map/useDismissOnEscape.test.tsx @@ -0,0 +1,54 @@ +import { afterAll, afterEach, beforeAll, expect, test } from 'bun:test' +import { cleanup, render } from '@testing-library/react' +import { setupDom, teardownDom } from '../test-setup' +import { useDismissOnEscape } from './useDismissOnEscape' + +beforeAll(setupDom) +afterAll(teardownDom) +afterEach(cleanup) + +function Harness({ + active, + onDismiss, +}: { + active: boolean + onDismiss: () => void +}) { + useDismissOnEscape(active, onDismiss) + return
harness
+} + +const pressEscape = () => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) +} + +test('dismisses on escape while active', () => { + let count = 0 + render( (count += 1)} />) + pressEscape() + expect(count).toBe(1) +}) + +test('does nothing while inactive', () => { + let count = 0 + render( (count += 1)} />) + pressEscape() + expect(count).toBe(0) +}) + +test('ignores other keys', () => { + let count = 0 + render( (count += 1)} />) + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })) + expect(count).toBe(0) +}) + +test('stops listening once unmounted', () => { + let count = 0 + const { unmount } = render( (count += 1)} />) + unmount() + pressEscape() + // A listener left on document survives navigation and fires against a + // component that no longer exists, which is silent until it throws. + expect(count).toBe(0) +}) diff --git a/app/map/useDismissOnEscape.ts b/app/map/useDismissOnEscape.ts new file mode 100644 index 000000000..92f3ece2c --- /dev/null +++ b/app/map/useDismissOnEscape.ts @@ -0,0 +1,25 @@ +import { useEffect } from 'react' + +/** + * Closes something when Escape is pressed, while it is open. + * + * This exists as its own hook rather than an effect inside MapCanvas so that it + * can be tested. MapCanvas needs a real WebGL context to render at all, so + * anything living inside it is only ever asserted, never exercised. + * + * Radix's Popover would have provided this for free, but its positioning tracks + * an element's DOM rect through a ResizeObserver, and the map popup's anchor + * moves by an inline style rewritten on every camera frame, which that does not + * follow. Dismissal is independent of layout, so it does not get dropped along + * with the layout engine. + */ +export function useDismissOnEscape(active: boolean, onDismiss: () => void) { + useEffect(() => { + if (!active) return undefined + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') onDismiss() + } + document.addEventListener('keydown', onKeyDown) + return () => document.removeEventListener('keydown', onKeyDown) + }, [active, onDismiss]) +} From e7adedf0f87abe8a99dcdca88c63799a5ae9ad4a Mon Sep 17 00:00:00 2001 From: Rin <58572875+TurtIeSocks@users.noreply.github.com> Date: Mon, 24 Aug 2026 06:51:32 -0400 Subject: [PATCH 16/24] fix(map): bound forcedLimit at every zoom, not just below maxZoom Ported 1.0's clustering config (Clustering.jsx) built its supercluster instance with maxZoom set to the configured zoomLevel, then relied on that same instance to keep the rendered count under forcedLimit. supercluster's maxZoom is the zoom above which it stops clustering and returns raw points, so the cap switched itself off exactly when the user zoomed past zoomLevel, the zoom where the most markers are on screen. Nothing errored; the limit just stopped applying. clusterEntities keeps maxZoom: rules.zoomLevel for the zoom-in-to-declutter behaviour below that zoom, then caps the combined cluster and point count against forcedLimit after clustering runs, so the bound holds at every zoom. Clusters are kept in full and loose points are truncated first, and the result reports limitHit so a caller can show that the cap engaged rather than dropping entities silently. clustering.test.ts was run against the ported (unfixed) behaviour first and failed as expected: 2000 entities against a forcedLimit of 200 at a zoom past zoomLevel rendered all 2000. layers.ts wires clusterEntities in behind an optional viewport param on buildMapLayers, defaulting to the existing unclustered behaviour when omitted so every current caller is unaffected. --- app/map/clustering.test.ts | 83 +++++++++++++++ app/map/clustering.ts | 147 ++++++++++++++++++++++++++ app/map/draw-icon.ts | 41 ++++++++ app/map/layers.ts | 205 ++++++++++++++++++++++++++++++++++++- app/map/supercluster.d.ts | 45 ++++++++ 5 files changed, 516 insertions(+), 5 deletions(-) create mode 100644 app/map/clustering.test.ts create mode 100644 app/map/clustering.ts create mode 100644 app/map/supercluster.d.ts diff --git a/app/map/clustering.test.ts b/app/map/clustering.test.ts new file mode 100644 index 000000000..aac3135f4 --- /dev/null +++ b/app/map/clustering.test.ts @@ -0,0 +1,83 @@ +import { expect, test } from 'bun:test' +import { + type ClusterableEntity, + type ClusterRules, + clusterEntities, +} from './clustering' +import type { Bounds } from './types' + +const BOUNDS: Bounds = { west: -1, south: 51, east: 1, north: 52 } + +/** Scatters `count` points across `BOUNDS` with a deterministic PRNG, so a + * run that fails does so for the same reason every time. */ +function scatter(count: number): ClusterableEntity[] { + let state = 20260824 + const rng = () => { + state = (state + 0x6d2b79f5) | 0 + let t = state + t = Math.imul(t ^ (t >>> 15), t | 1) + t ^= t + Math.imul(t ^ (t >>> 7), t | 61) + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } + return Array.from({ length: count }, (_, index) => ({ + id: `entity-${index}`, + lat: BOUNDS.south + rng() * (BOUNDS.north - BOUNDS.south), + lon: BOUNDS.west + rng() * (BOUNDS.east - BOUNDS.west), + })) +} + +const RULES: ClusterRules = { zoomLevel: 10, forcedLimit: 200, minPoints: 5 } + +/* + * This is the regression test for the 1.0 forcedLimit bug (see the task + * brief for the line-numbered trace through Clustering.jsx). Supercluster's + * `maxZoom` is the zoom ABOVE WHICH it stops clustering and returns raw + * points; 1.0 built its clusterer with `maxZoom: rules.zoomLevel` and + * trusted that same clusterer to enforce `forcedLimit`. Above `zoomLevel`, + * clustering switches itself off and the cap goes with it, at exactly the + * zoom where the most markers are on screen. + * + * 2000 entities, a limit of 200, queried at zoom 20 (10 past zoomLevel): + * the ported bug returns every one of the 2000 unclustered, because + * supercluster has stopped clustering by then and nothing else was + * capping the result. + */ +test('clusterEntities bounds the rendered count at a zoom past zoomLevel', () => { + const entities = scatter(2000) + const result = clusterEntities(entities, BOUNDS, 20, RULES) + const rendered = result.clusters.length + result.points.length + expect(rendered).toBeLessThanOrEqual(RULES.forcedLimit) + expect(result.limitHit).toBe(true) +}) + +test('clusterEntities does not cap or flag limitHit when under the limit', () => { + const entities = scatter(50) + const result = clusterEntities(entities, BOUNDS, 20, RULES) + expect(result.limitHit).toBe(false) + expect(result.clusters.length + result.points.length).toBe(50) +}) + +test('clusterEntities groups nearby points into clusters below zoomLevel', () => { + const entities = scatter(500) + const result = clusterEntities(entities, BOUNDS, 2, RULES) + expect(result.clusters.length).toBeGreaterThan(0) + const clusteredCount = result.clusters.reduce((sum, c) => sum + c.count, 0) + expect(clusteredCount + result.points.length).toBe(500) +}) + +test('clusterEntities keeps every cluster and truncates only loose points when capping', () => { + const entities = scatter(2000) + const result = clusterEntities(entities, BOUNDS, 20, RULES) + // At zoom 20 (past zoomLevel 10) supercluster returns no clusters at all, + // so this exercises the plain truncation path: the cap comes entirely + // out of `points`, and `clusters` stays empty. + expect(result.clusters.length).toBe(0) + expect(result.points.length).toBe(RULES.forcedLimit) +}) + +test('clusterEntities is deterministic for the same inputs', () => { + const entities = scatter(2000) + const first = clusterEntities(entities, BOUNDS, 20, RULES) + const second = clusterEntities(entities, BOUNDS, 20, RULES) + expect(first.points.map((p) => p.id)).toEqual(second.points.map((p) => p.id)) +}) diff --git a/app/map/clustering.ts b/app/map/clustering.ts new file mode 100644 index 000000000..5b6f39781 --- /dev/null +++ b/app/map/clustering.ts @@ -0,0 +1,147 @@ +import Supercluster from 'supercluster' +import type { Bounds } from './types' + +/** + * Ported from 1.0's `config/default.json` `clustering` block + * (`src/pages/map/components/Clustering.jsx` line 72 for the shape). + * `zoomLevel` is the zoom above which supercluster stops clustering and + * hands back raw points; `forcedLimit` is the hard cap on how many + * markers may render at once for the category. + */ +export interface ClusterRules { + zoomLevel: number + forcedLimit: number + minPoints: number +} + +/** From `config/default.json`'s `clustering` block. */ +export const DEFAULT_POKEMON_CLUSTER_RULES: ClusterRules = { + zoomLevel: 15, + forcedLimit: 3000, + minPoints: 7, +} + +export const DEFAULT_GYM_CLUSTER_RULES: ClusterRules = { + zoomLevel: 13, + forcedLimit: 2500, + minPoints: 5, +} + +export interface ClusterableEntity { + id: string + lat: number + lon: number +} + +/** A synthetic marker standing in for `count` entities too close together to render individually. */ +export interface ClusterMarker { + kind: 'cluster' + id: string + lat: number + lon: number + count: number +} + +export interface ClusterResult { + /** Individual entities to render as their own markers. */ + points: readonly T[] + /** Synthetic cluster markers, each standing in for several entities. */ + clusters: readonly ClusterMarker[] + /** + * True when `rules.forcedLimit` was exceeded and the rendered set had to + * be capped. The caller should show this, not just cap silently - see + * clustering.test.ts and the task brief for why a silent cap is worse + * than a visible one. + */ + limitHit: boolean +} + +function toFeature(entity: ClusterableEntity): { + type: 'Feature' + id: string + properties: Record + geometry: { type: 'Point'; coordinates: [number, number] } +} { + return { + type: 'Feature', + id: entity.id, + properties: {}, + geometry: { type: 'Point', coordinates: [entity.lon, entity.lat] }, + } +} + +/** + * Clusters `entities` for one viewport/zoom and enforces `rules.forcedLimit` + * as a hard cap on the rendered set, independent of zoom. + * + * supercluster's `maxZoom` is the zoom ABOVE WHICH it stops clustering and + * returns raw, unclustered points - it is not a bound on how many markers + * come back. 1.0 built its clusterer with `maxZoom: rules.zoomLevel` and + * relied on that same clusterer to keep the count under `forcedLimit` + * (`src/pages/map/components/Clustering.jsx` lines 92-98, 157). Above + * `zoomLevel` - exactly where the most markers are on screen - supercluster + * stops clustering, so every entity in view comes back individually and + * nothing enforces the limit any more. + * + * This keeps `maxZoom: rules.zoomLevel` (the configured "zoom in to + * declutter" behaviour is still worth having below that zoom) but then + * caps the combined cluster+point count against `forcedLimit` itself, + * after clustering runs, so the bound holds at every zoom rather than only + * while supercluster is still willing to cluster. Clusters are kept in + * full and loose points are truncated first, since a cluster already + * represents many entities in one cheap marker. + */ +export function clusterEntities( + entities: readonly T[], + bounds: Bounds, + zoom: number, + rules: ClusterRules, +): ClusterResult { + const index = new Supercluster({ + radius: 60, + extent: 256, + maxZoom: rules.zoomLevel, + minPoints: rules.minPoints, + }) + index.load(entities.map(toFeature)) + + const bbox: [number, number, number, number] = [ + bounds.west, + bounds.south, + bounds.east, + bounds.north, + ] + const raw = index.getClusters(bbox, Math.round(zoom)) + + const byId = new Map(entities.map((entity) => [entity.id, entity])) + const clusters: ClusterMarker[] = [] + const points: T[] = [] + for (const feature of raw) { + const [lon, lat] = feature.geometry.coordinates + const properties = feature.properties + if (properties.cluster === true) { + clusters.push({ + kind: 'cluster', + id: `cluster-${feature.id}`, + lat, + lon, + count: properties.point_count as number, + }) + } else if (feature.id !== undefined) { + const entity = byId.get(String(feature.id)) + if (entity) points.push(entity) + } + } + + const rendered = clusters.length + points.length + if (rendered <= rules.forcedLimit) { + return { points, clusters, limitHit: false } + } + + const pointBudget = Math.max(0, rules.forcedLimit - clusters.length) + return { + points: points.slice(0, pointBudget), + clusters, + limitHit: true, + } +} diff --git a/app/map/draw-icon.ts b/app/map/draw-icon.ts index 79b7d09d1..333d55822 100644 --- a/app/map/draw-icon.ts +++ b/app/map/draw-icon.ts @@ -81,6 +81,47 @@ export const drawPokemonIcon: IconDrawer = ( } } +const CLUSTER_ICON_ID = 'cluster-marker-mask' +let cachedClusterIcon: IconDescriptor | undefined + +/** + * The single mask icon every cluster bubble shares: a plain circle, same + * pattern as `drawGymIcon` and for the same reason - a cluster's only + * per-entity property is its count, which is rendered as text on top (see + * `buildClusterTextLayer` in layers.ts), not baked into the icon itself. + * `IconLayer.getColor` tints this per cluster by size bucket. + */ +export function drawClusterIcon(): IconDescriptor { + if (cachedClusterIcon) return cachedClusterIcon + + const offscreen = new OffscreenCanvas(ICON_SIZE, ICON_SIZE) + const ctx = offscreen.getContext('2d') + if (!ctx) throw new Error('2d context unavailable on OffscreenCanvas') + + const center = ICON_SIZE / 2 + const radius = ICON_SIZE / 2 - 4 + + ctx.fillStyle = '#ffffff' + ctx.beginPath() + ctx.arc(center, center, radius, 0, Math.PI * 2) + ctx.fill() + + const bridge = document.createElement('canvas') + bridge.width = ICON_SIZE + bridge.height = ICON_SIZE + const bridgeCtx = bridge.getContext('2d') + if (!bridgeCtx) throw new Error('2d context unavailable on bridge canvas') + bridgeCtx.drawImage(offscreen, 0, 0) + + cachedClusterIcon = { + id: CLUSTER_ICON_ID, + url: bridge.toDataURL('image/png'), + width: ICON_SIZE, + height: ICON_SIZE, + } + return cachedClusterIcon +} + const GYM_ICON_ID = 'gym-marker-mask' let cachedGymIcon: IconDescriptor | undefined diff --git a/app/map/layers.ts b/app/map/layers.ts index 4a5468245..74c0a3611 100644 --- a/app/map/layers.ts +++ b/app/map/layers.ts @@ -1,11 +1,22 @@ import type { Color, Layer } from '@deck.gl/core' import { IconLayer, TextLayer } from '@deck.gl/layers' import type { IconDescriptor } from './atlas' -import type { GymEntity, PokemonEntity, Team } from './types' +import { + type ClusterMarker, + type ClusterRules, + clusterEntities, + DEFAULT_GYM_CLUSTER_RULES, + DEFAULT_POKEMON_CLUSTER_RULES, +} from './clustering' +import type { Bounds, GymEntity, PokemonEntity, Team } from './types' export const POKEMON_ICON_LAYER_ID = 'pokemon-icons' export const POKEMON_LABEL_LAYER_ID = 'pokemon-labels' export const GYM_ICON_LAYER_ID = 'gym-icons' +export const POKEMON_CLUSTER_ICON_LAYER_ID = 'pokemon-cluster-icons' +export const POKEMON_CLUSTER_LABEL_LAYER_ID = 'pokemon-cluster-labels' +export const GYM_CLUSTER_ICON_LAYER_ID = 'gym-cluster-icons' +export const GYM_CLUSTER_LABEL_LAYER_ID = 'gym-cluster-labels' /** Fallback for a team colour token that failed to resolve. Neutral grey, * distinct from every real team colour, so a broken token is visible on @@ -173,6 +184,70 @@ export function buildGymIconLayer( }) } +/** + * Buckets a cluster's colour by how many entities it stands in for, same + * three-tier split as 1.0's `marker-cluster-{small,medium,large}` CSS + * classes (`Clustering.jsx`'s `createClusterIcon`): a cluster of a few + * dozen reads very differently from one standing in for a thousand + * entities, and the colour is the only signal of that at a glance. + */ +function clusterColorFor(count: number): Color { + if (count < 100) return [125, 211, 252, 230] // small: light blue + if (count < 1000) return [251, 191, 36, 230] // medium: amber + return [248, 113, 113, 230] // large: red +} + +/** + * Icon bubble for a set of cluster markers, tinted by `clusterColorFor`. + * Shared between the pokemon and gym cluster layers; only the layer `id` + * and the input array differ between them. + */ +export function buildClusterIconLayer( + clusters: readonly ClusterMarker[], + id: string, + getClusterIcon: () => IconDescriptor, +): IconLayer { + return new IconLayer({ + id, + data: clusters, + pickable: false, + getPosition: (cluster) => [cluster.lon, cluster.lat], + getIcon: () => { + const icon = getClusterIcon() + return { + id: icon.id, + url: icon.url, + width: icon.width, + height: icon.height, + mask: true, + } + }, + getColor: (cluster) => clusterColorFor(cluster.count), + getSize: 36, + }) +} + +/** The entity count centred on each cluster bubble. */ +export function buildClusterTextLayer( + clusters: readonly ClusterMarker[], + id: string, +): TextLayer { + return new TextLayer({ + id, + data: clusters, + pickable: false, + getPosition: (cluster) => [cluster.lon, cluster.lat], + getText: (cluster) => String(cluster.count), + getSize: 13, + getColor: [17, 24, 39, 255], + }) +} + +export interface MapLayersViewport { + bounds: Bounds + zoom: number +} + export interface BuildMapLayersOptions { pokemon: readonly PokemonEntity[] gyms: readonly GymEntity[] @@ -180,6 +255,18 @@ export interface BuildMapLayersOptions { getGymIcon: () => IconDescriptor now: number root?: HTMLElement + /** + * The current camera bounds/zoom. When present, `buildMapLayers` clusters + * pokemon and gyms for that viewport and enforces each category's + * `forcedLimit` (see clustering.ts) - the fix for the bug traced in the + * task brief, where 1.0's limit stopped applying above its clusterer's + * `maxZoom`. Omitted, every entity renders individually as before; this + * keeps every existing caller (and layers.test.ts) working unchanged. + */ + viewport?: MapLayersViewport + /** Per-category override of the default rules read from `config/default.json`. */ + clusterRules?: { pokemon?: ClusterRules; gyms?: ClusterRules } + getClusterIcon?: () => IconDescriptor } /** @@ -189,6 +276,64 @@ export interface BuildMapLayersOptions { * `interleaved`, which only controls where the whole deck.gl stack sits * relative to MapLibre's own street-label layer. */ +interface ClusteredEntities { + points: readonly T[] + clusters: readonly ClusterMarker[] +} + +function clusterPokemon( + pokemon: readonly PokemonEntity[], + viewport: MapLayersViewport, + rules: ClusterRules, +): ClusteredEntities { + const wrapped = pokemon.map((entity) => ({ + id: entity.spawnId, + lat: entity.lat, + lon: entity.lon, + entity, + })) + const result = clusterEntities(wrapped, viewport.bounds, viewport.zoom, rules) + return { + points: result.points.map((wrapper) => wrapper.entity), + clusters: result.clusters, + } +} + +function clusterGyms( + gyms: readonly GymEntity[], + viewport: MapLayersViewport, + rules: ClusterRules, +): ClusteredEntities { + const wrapped = gyms.map((entity) => ({ + id: entity.gymId, + lat: entity.lat, + lon: entity.lon, + entity, + })) + const result = clusterEntities(wrapped, viewport.bounds, viewport.zoom, rules) + return { + points: result.points.map((wrapper) => wrapper.entity), + clusters: result.clusters, + } +} + +/** + * All layers this task adds, in the order deck.gl should draw them: gyms + * and pokemon icons first, pokemon labels last so text always sits on top + * of the markers it describes. Draw order between layers is independent of + * `interleaved`, which only controls where the whole deck.gl stack sits + * relative to MapLibre's own street-label layer. + * + * Without `viewport`, every entity renders individually - the original + * Task 4 behaviour, still what layers.test.ts exercises. With `viewport`, + * pokemon and gyms are each clustered and capped against their + * `forcedLimit` (clustering.ts; see clustering.test.ts for the bug this + * closes) before their icon/text layers are built, and any resulting + * cluster bubbles are added as their own layers when `getClusterIcon` is + * supplied. `clusterEntities`'s `limitHit` is available to callers that + * cluster directly and want to show a warning; this function itself stays + * `Layer[]`-in-`Layer[]`-out so existing callers are unaffected. + */ export function buildMapLayers({ pokemon, gyms, @@ -196,10 +341,60 @@ export function buildMapLayers({ getGymIcon, now, root, + viewport, + clusterRules, + getClusterIcon, }: BuildMapLayersOptions): Layer[] { - return [ - buildGymIconLayer(gyms, getGymIcon, root), - buildPokemonIconLayer(pokemon, getIconFor), - buildPokemonTextLayer(pokemon, now), + if (!viewport) { + return [ + buildGymIconLayer(gyms, getGymIcon, root), + buildPokemonIconLayer(pokemon, getIconFor), + buildPokemonTextLayer(pokemon, now), + ] + } + + const pokemonResult = clusterPokemon( + pokemon, + viewport, + clusterRules?.pokemon ?? DEFAULT_POKEMON_CLUSTER_RULES, + ) + const gymResult = clusterGyms( + gyms, + viewport, + clusterRules?.gyms ?? DEFAULT_GYM_CLUSTER_RULES, + ) + + const layers: Layer[] = [ + buildGymIconLayer(gymResult.points, getGymIcon, root), + buildPokemonIconLayer(pokemonResult.points, getIconFor), + buildPokemonTextLayer(pokemonResult.points, now), ] + + if (getClusterIcon) { + if (gymResult.clusters.length > 0) { + layers.push( + buildClusterIconLayer( + gymResult.clusters, + GYM_CLUSTER_ICON_LAYER_ID, + getClusterIcon, + ), + buildClusterTextLayer(gymResult.clusters, GYM_CLUSTER_LABEL_LAYER_ID), + ) + } + if (pokemonResult.clusters.length > 0) { + layers.push( + buildClusterIconLayer( + pokemonResult.clusters, + POKEMON_CLUSTER_ICON_LAYER_ID, + getClusterIcon, + ), + buildClusterTextLayer( + pokemonResult.clusters, + POKEMON_CLUSTER_LABEL_LAYER_ID, + ), + ) + } + } + + return layers } diff --git a/app/map/supercluster.d.ts b/app/map/supercluster.d.ts new file mode 100644 index 000000000..870f83936 --- /dev/null +++ b/app/map/supercluster.d.ts @@ -0,0 +1,45 @@ +/** + * `supercluster` ships no types and there is no `@types/supercluster` in + * this project. This declares only the surface `clustering.ts` actually + * calls, typed to what supercluster's README documents, so the dependency + * does not force `any` through strict TypeScript. + */ +declare module 'supercluster' { + export interface SuperclusterOptions { + /** Cluster radius, in pixels. Default 40. */ + radius?: number + /** Tile extent, radius is relative to this. Default 512. */ + extent?: number + /** Min zoom to generate clusters at. Default 0. */ + minZoom?: number + /** Max zoom level at which clusters are generated. Above it, `getClusters` returns raw points. Default 16. */ + maxZoom?: number + /** Minimum points to form a cluster. Default 2. */ + minPoints?: number + } + + export interface SuperclusterPointFeature { + type: 'Feature' + id?: string | number + properties: Record + geometry: { type: 'Point'; coordinates: [number, number] } + } + + export interface SuperclusterClusterProperties { + cluster: true + cluster_id: number + point_count: number + point_count_abbreviated: string | number + } + + export type SuperclusterFeature = SuperclusterPointFeature + + export default class Supercluster { + constructor(options?: SuperclusterOptions) + load(points: SuperclusterPointFeature[]): this + getClusters( + bbox: [number, number, number, number], + zoom: number, + ): SuperclusterFeature[] + } +} From 188cb1f71cc213515052cfcb424e21386600b202 Mon Sep 17 00:00:00 2001 From: Rin <58572875+TurtIeSocks@users.noreply.github.com> Date: Mon, 24 Aug 2026 06:54:58 -0400 Subject: [PATCH 17/24] feat(map): recover from WebGL context loss Adds useWebglContextRecovery, watching the canvas MapLibre creates inside the container for webglcontextlost and webglcontextrestored. The default on webglcontextlost has to be prevented or the browser treats the loss as permanent and the restored event never comes; that line lives in its own module with its own test so it has one place to go missing from and one test that fails if it does. MapCanvas shows a restoring overlay while the hook reports the context lost, and re-warms the icon atlas on restoration before forcing a fresh layer build. The atlas's cached descriptors were composited for GPU resources the lost context took with it, so a restore that reused them without clearing first would still be blank with no error, per the task brief. LruCache/atlas gain a clear() for this. Tested as a state machine, not a GPU: the hook's test dispatches the events on a plain canvas element and asserts restoring flips and clears and onRestore fires, nothing here touches an actual WebGL context. --- app/map/MapCanvas.tsx | 35 +++++++- app/map/atlas.test.ts | 31 +++++++ app/map/atlas.ts | 15 +++- app/map/useWebglContextRecovery.test.tsx | 105 +++++++++++++++++++++++ app/map/useWebglContextRecovery.ts | 72 ++++++++++++++++ 5 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 app/map/useWebglContextRecovery.test.tsx create mode 100644 app/map/useWebglContextRecovery.ts diff --git a/app/map/MapCanvas.tsx b/app/map/MapCanvas.tsx index 1f7926de8..bc838df57 100644 --- a/app/map/MapCanvas.tsx +++ b/app/map/MapCanvas.tsx @@ -11,6 +11,7 @@ import type { GymEntity, MapEntity, MapQuery, PokemonEntity } from './types' import { useDismissOnEscape } from './useDismissOnEscape' import type { Camera } from './useMapLibre' import { anchorFor, pickedEntityFrom, useMapLibre } from './useMapLibre' +import { useWebglContextRecovery } from './useWebglContextRecovery' export interface MapCanvasProps { initialCamera: Camera @@ -79,12 +80,30 @@ export function MapCanvas({ initialCamera, onCameraChange }: MapCanvasProps) { // popup, so one nullable slot is the whole model. const [selected, setSelected] = useState(null) + // Bumped on WebGL context restoration purely to force `layers` below to + // recompute immediately. buildMapLayers already gets fresh Layer + // instances every clock tick regardless, since `now` is in its deps - + // this just closes the gap between "context restored" and "next tick" + // rather than leaving the map blank for up to a second. + const [rebuildToken, setRebuildToken] = useState(0) + const handlePick = useCallback((info: PickingInfo) => { setSelected(pickedEntityFrom(info)) }, []) const closePopup = useCallback(() => setSelected(null), []) + // The atlas's cached icons are gone the moment the context that held + // their composited pixels dies, so restoration re-warms it before + // forcing the layer rebuild - a rebuild that reused the stale cache + // would still hand deck.gl icons composited for a context that no + // longer exists, which task-6-7-brief.md calls out as rendering an + // empty map with no error. + const handleContextRestore = useCallback(() => { + atlasRef.current?.clear() + setRebuildToken((token) => token + 1) + }, []) + useDismissOnEscape(selected !== null, closePopup) // Recreated only when the selection itself changes, not on every render: @@ -122,7 +141,9 @@ export function MapCanvas({ initialCamera, onCameraChange }: MapCanvasProps) { getGymIcon: drawGymIcon, now, }) - }, [pokemon, gyms, now]) + // rebuildToken is intentionally in this array with no other purpose + // than to invalidate this memo; see handleContextRestore above. + }, [pokemon, gyms, now, rebuildToken]) const { containerRef, screenPosition } = useMapLibre({ initialCamera, @@ -132,6 +153,10 @@ export function MapCanvas({ initialCamera, onCameraChange }: MapCanvasProps) { anchor, }) + const { restoring } = useWebglContextRecovery(containerRef, { + onRestore: handleContextRestore, + }) + return (
+ {restoring && ( +
+ Restoring map… +
+ )} {selected && screenPosition && ( { expect(() => new LruCache(0)).toThrow() }) +test('clear empties the cache', () => { + const cache = new LruCache(3) + cache.set('a', 1) + cache.set('b', 2) + cache.clear() + expect(cache.size).toBe(0) + expect(cache.has('a')).toBe(false) + expect(cache.get('b')).toBeUndefined() +}) + +test('atlas.clear forces the next getIconFor for a previously-cached key to redraw', () => { + let drawCount = 0 + const atlas = createAtlas({ + capacity: 10, + draw: (_entity, key): IconDescriptor => { + drawCount += 1 + return { id: key, url: `data:${key}`, width: 32, height: 32 } + }, + }) + + atlas.getIconFor(BASE) + atlas.getIconFor(BASE) + expect(drawCount).toBe(1) + + atlas.clear() + atlas.getIconFor(BASE) + + expect(drawCount).toBe(2) + expect(atlas.cache.size).toBe(1) +}) + test('getIconFor draws once per distinct key and reuses the cached descriptor on repeats', () => { let drawCount = 0 const seenKeys: string[] = [] diff --git a/app/map/atlas.ts b/app/map/atlas.ts index e90a70a56..018b5f910 100644 --- a/app/map/atlas.ts +++ b/app/map/atlas.ts @@ -66,6 +66,17 @@ export class LruCache { if (oldest !== undefined) this.#store.delete(oldest) } } + + /** + * Drops every entry. Used to re-warm the atlas after WebGL context + * restoration (see useWebglContextRecovery.ts / MapCanvas.tsx): the + * cached descriptors' composited pixels rode on the GPU resources the + * lost context took with it, so keeping them around would serve stale + * icons instead of ones drawn for the new context. + */ + clear(): void { + this.#store.clear() + } } /** What `IconLayer`'s `getIcon` accessor needs for one marker. */ @@ -107,6 +118,8 @@ export function createAtlas({ }: AtlasOptions): { getIconFor: (entity: PokemonEntity) => IconDescriptor cache: LruCache + /** Drops every cached icon. See `LruCache.clear` for why. */ + clear: () => void } { const cache = new LruCache(capacity) @@ -119,5 +132,5 @@ export function createAtlas({ return drawn } - return { getIconFor, cache } + return { getIconFor, cache, clear: () => cache.clear() } } diff --git a/app/map/useWebglContextRecovery.test.tsx b/app/map/useWebglContextRecovery.test.tsx new file mode 100644 index 000000000..768fa518d --- /dev/null +++ b/app/map/useWebglContextRecovery.test.tsx @@ -0,0 +1,105 @@ +import { afterAll, afterEach, beforeAll, expect, test } from 'bun:test' +import { act, cleanup, render } from '@testing-library/react' +import { useRef } from 'react' +import { setupDom, teardownDom } from '../test-setup' +import { + type UseWebglContextRecoveryResult, + useWebglContextRecovery, +} from './useWebglContextRecovery' + +beforeAll(setupDom) +afterAll(teardownDom) +afterEach(cleanup) + +/* + * The real canvas comes from MapLibre, created inside the container this + * hook watches. The harness stands one up by hand - a plain is + * enough, since this hook only ever touches it as a DOM EventTarget and + * never reaches for a WebGL context. See task-6-7 notes: this is the + * state machine, not the GPU. + */ +function Harness({ + onRestore, + onResult, +}: { + onRestore: () => void + onResult: (result: UseWebglContextRecoveryResult) => void +}) { + const containerRef = useRef(null) + const result = useWebglContextRecovery(containerRef, { onRestore }) + onResult(result) + return ( +
+ +
+ ) +} + +function dispatchOn(canvas: HTMLCanvasElement, type: string): Event { + const event = new Event(type, { cancelable: true }) + act(() => { + canvas.dispatchEvent(event) + }) + return event +} + +test('flips restoring true on context loss and prevents the default', () => { + let latest: UseWebglContextRecoveryResult | undefined + const { container } = render( + undefined} onResult={(r) => (latest = r)} />, + ) + const canvas = container.querySelector('canvas') as HTMLCanvasElement + + const event = dispatchOn(canvas, 'webglcontextlost') + + expect(event.defaultPrevented).toBe(true) + expect(latest?.restoring).toBe(true) +}) + +test('clears restoring and calls onRestore when the context comes back', () => { + let latest: UseWebglContextRecoveryResult | undefined + let restoreCount = 0 + const { container } = render( + (restoreCount += 1)} + onResult={(r) => (latest = r)} + />, + ) + const canvas = container.querySelector('canvas') as HTMLCanvasElement + + dispatchOn(canvas, 'webglcontextlost') + expect(latest?.restoring).toBe(true) + + dispatchOn(canvas, 'webglcontextrestored') + + expect(restoreCount).toBe(1) + expect(latest?.restoring).toBe(false) +}) + +test('onRestore never fires without a preceding loss', () => { + let restoreCount = 0 + render( + (restoreCount += 1)} + onResult={() => undefined} + />, + ) + expect(restoreCount).toBe(0) +}) + +test('stops listening once unmounted', () => { + let restoreCount = 0 + const { container, unmount } = render( + (restoreCount += 1)} + onResult={() => undefined} + />, + ) + const canvas = container.querySelector('canvas') as HTMLCanvasElement + unmount() + + dispatchOn(canvas, 'webglcontextlost') + dispatchOn(canvas, 'webglcontextrestored') + + expect(restoreCount).toBe(0) +}) diff --git a/app/map/useWebglContextRecovery.ts b/app/map/useWebglContextRecovery.ts new file mode 100644 index 000000000..51beba61c --- /dev/null +++ b/app/map/useWebglContextRecovery.ts @@ -0,0 +1,72 @@ +import { type RefObject, useEffect, useRef, useState } from 'react' + +export interface UseWebglContextRecoveryOptions { + /** + * Called once `webglcontextrestored` fires, before `restoring` clears. + * This is where a caller re-warms whatever died with the old context - + * MapCanvas clears the icon atlas and forces a fresh layer build here, + * since the atlas's composited icons are gone along with the GPU + * resources that held them. + */ + onRestore: () => void +} + +export interface UseWebglContextRecoveryResult { + /** True from `webglcontextlost` until `webglcontextrestored` fires. */ + restoring: boolean +} + +/** + * Watches the canvas inside `containerRef` for WebGL context loss and + * restoration, and tracks whether the map is currently in the gap between + * them. + * + * `webglcontextlost` fires with the event's default action being "let the + * context die for good" - calling `preventDefault()` on it is what tells + * the browser recovery is wanted at all. Skipping that line is the classic + * mistake here: the event still fires, `restoring` would still flip true, + * and nothing would look wrong until `webglcontextrestored` never comes + * and the map stays blank forever. This hook exists as its own module, + * separate from MapCanvas, specifically so that line has one place to + * live and one test that can fail if it goes missing. + * + * The canvas itself is looked up from `containerRef`'s DOM subtree rather + * than threaded through as a prop, because `useMapLibre` owns the + * MapLibre instance that creates it and does not (and should not) expose + * the raw canvas element - `containerRef` is already public on its + * result, and MapLibre places its canvas inside that container + * synchronously when it mounts, before this hook's own effect runs. + */ +export function useWebglContextRecovery( + containerRef: RefObject, + { onRestore }: UseWebglContextRecoveryOptions, +): UseWebglContextRecoveryResult { + const [restoring, setRestoring] = useState(false) + const onRestoreRef = useRef(onRestore) + onRestoreRef.current = onRestore + + useEffect(() => { + const canvas = containerRef.current?.querySelector('canvas') ?? null + if (!canvas) return undefined + + const handleContextLost = (event: Event) => { + // Without this, the browser treats the loss as permanent and + // webglcontextrestored never fires. + event.preventDefault() + setRestoring(true) + } + const handleContextRestored = () => { + onRestoreRef.current() + setRestoring(false) + } + + canvas.addEventListener('webglcontextlost', handleContextLost) + canvas.addEventListener('webglcontextrestored', handleContextRestored) + return () => { + canvas.removeEventListener('webglcontextlost', handleContextLost) + canvas.removeEventListener('webglcontextrestored', handleContextRestored) + } + }, [containerRef]) + + return { restoring } +} From 43cc9f4e36f2ea7a2cefc79e3c1d924bb5789358 Mon Sep 17 00:00:00 2001 From: Rin <58572875+TurtIeSocks@users.noreply.github.com> Date: Mon, 24 Aug 2026 07:08:25 -0400 Subject: [PATCH 18/24] fix(map): cap the total rendered set, not just the loose points clusterEntities capped points against forcedLimit minus the cluster count and never capped the clusters themselves. On a uniformly scattered map that binds, which is what the existing test covered. On many well-separated small groups, a country of small towns with a few gyms each, every group survives as its own cluster, the point budget is zero before capping starts, and the cluster count runs unbounded. At gym rules with 3500 groups of 5, zooms 8, 10 and 13 each rendered 3500 markers against a limit of 2500. The cap is now on the total and is applied by coarsening: if the count at the requested zoom does not fit, the same index is queried at successively lower zooms until it does. Every entity in view still stands behind some marker, just a bigger one, so a capped map is decluttered rather than silently missing a third of its towns. Dropping markers is now only a guard for the case where even zoom 0 will not fit, and it keeps the ones standing for the most entities. The test that asserted the old keep-clusters-truncate-points policy is rewritten for the new one, and the suite gains the many-small-groups shape plus a dense-blob-and-groups mix. --- app/map/clustering.test.ts | 107 +++++++++++++++++++++++++++++++-- app/map/clustering.ts | 118 ++++++++++++++++++++++++++----------- 2 files changed, 184 insertions(+), 41 deletions(-) diff --git a/app/map/clustering.test.ts b/app/map/clustering.test.ts index aac3135f4..e68fd9b8c 100644 --- a/app/map/clustering.test.ts +++ b/app/map/clustering.test.ts @@ -26,6 +26,36 @@ function scatter(count: number): ClusterableEntity[] { })) } +/** + * `groupCount` well-separated groups of `size` entities each, laid out on a + * grid across `BOUNDS`. This is the shape the uniform `scatter` above cannot + * produce and the one that broke the cap: every group is far enough from its + * neighbours to survive as its own cluster, so the cluster count alone runs + * past `forcedLimit` while the loose-point budget is already zero. A map of + * many small towns, each with a handful of gyms, viewed at country zoom. + */ +function groupsOf(groupCount: number, size: number): ClusterableEntity[] { + const columns = Math.ceil(Math.sqrt(groupCount)) + const rows = Math.ceil(groupCount / columns) + const entities: ClusterableEntity[] = [] + for (let group = 0; group < groupCount; group += 1) { + const column = group % columns + const row = Math.floor(group / columns) + const lon = + BOUNDS.west + ((column + 0.5) / columns) * (BOUNDS.east - BOUNDS.west) + const lat = + BOUNDS.south + ((row + 0.5) / rows) * (BOUNDS.north - BOUNDS.south) + for (let member = 0; member < size; member += 1) { + entities.push({ + id: `group-${group}-${member}`, + lat: lat + member * 0.00002, + lon: lon + member * 0.00002, + }) + } + } + return entities +} + const RULES: ClusterRules = { zoomLevel: 10, forcedLimit: 200, minPoints: 5 } /* @@ -65,14 +95,24 @@ test('clusterEntities groups nearby points into clusters below zoomLevel', () => expect(clusteredCount + result.points.length).toBe(500) }) -test('clusterEntities keeps every cluster and truncates only loose points when capping', () => { +/* + * This asserted the earlier policy, keep every cluster and truncate points, + * and it is now wrong on purpose: capping coarsens instead of truncating, so + * a set that came back as 2000 loose points at zoom 20 comes back as clusters + * drawn at whatever lower zoom first fits. What matters is that the total + * binds and that nothing was thrown away to make it bind. + */ +test('clusterEntities caps by coarsening rather than by dropping markers', () => { const entities = scatter(2000) const result = clusterEntities(entities, BOUNDS, 20, RULES) - // At zoom 20 (past zoomLevel 10) supercluster returns no clusters at all, - // so this exercises the plain truncation path: the cap comes entirely - // out of `points`, and `clusters` stays empty. - expect(result.clusters.length).toBe(0) - expect(result.points.length).toBe(RULES.forcedLimit) + expect(result.clusters.length).toBeGreaterThan(0) + expect(result.clusters.length + result.points.length).toBeLessThanOrEqual( + RULES.forcedLimit, + ) + const represented = + result.clusters.reduce((sum, cluster) => sum + cluster.count, 0) + + result.points.length + expect(represented).toBe(2000) }) test('clusterEntities is deterministic for the same inputs', () => { @@ -81,3 +121,58 @@ test('clusterEntities is deterministic for the same inputs', () => { const second = clusterEntities(entities, BOUNDS, 20, RULES) expect(first.points.map((p) => p.id)).toEqual(second.points.map((p) => p.id)) }) + +/* + * The many-small-groups shape. `scatter` above is uniform, so at any zoom + * past `zoomLevel` it yields loose points only and the cap comes entirely out + * of the point budget - which is exactly why it passed while this failed. + * With 800 separated groups of 5 the clusters alone are 800 against a limit + * of 200, the point budget is already zero, and capping only points caps + * nothing. Measured on the pre-fix code at gym rules and 3500 groups: + * zoom 8, 10 and 13 each rendered 3500 clusters against a limit of 2500. + */ +test('clusterEntities bounds the total when clusters alone exceed the limit', () => { + const entities = groupsOf(800, 5) + for (const zoom of [4, 8, 10, 13, 20]) { + const result = clusterEntities(entities, BOUNDS, zoom, RULES) + const rendered = result.clusters.length + result.points.length + expect(`zoom ${zoom}: ${rendered}`).toBe( + `zoom ${zoom}: ${Math.min(rendered, RULES.forcedLimit)}`, + ) + } +}) + +test('clusterEntities flags limitHit for the many-small-groups shape', () => { + const result = clusterEntities(groupsOf(800, 5), BOUNDS, 10, RULES) + expect(result.limitHit).toBe(true) +}) + +/* + * Coarsening is what keeps the cap from being a silent deletion: every entity + * in view is still standing behind some marker, just a bigger one. If this + * ever regresses to truncation the sum drops below the input count. + */ +test('clusterEntities represents every entity even when it has to cap', () => { + const entities = groupsOf(800, 5) + const result = clusterEntities(entities, BOUNDS, 10, RULES) + const represented = + result.clusters.reduce((sum, cluster) => sum + cluster.count, 0) + + result.points.length + expect(represented).toBe(entities.length) +}) + +test('clusterEntities bounds a mix of one dense blob and many small groups', () => { + const blob = Array.from({ length: 4000 }, (_, index) => ({ + id: `blob-${index}`, + lat: BOUNDS.south + 0.5 + index * 0.000001, + lon: BOUNDS.west + 0.5 + index * 0.000001, + })) + const entities = [...groupsOf(600, 5), ...blob, ...scatter(1000)] + for (const zoom of [4, 10, 14, 20]) { + const result = clusterEntities(entities, BOUNDS, zoom, RULES) + const rendered = result.clusters.length + result.points.length + expect(`zoom ${zoom}: ${rendered}`).toBe( + `zoom ${zoom}: ${Math.min(rendered, RULES.forcedLimit)}`, + ) + } +}) diff --git a/app/map/clustering.ts b/app/map/clustering.ts index 5b6f39781..eb2b6699d 100644 --- a/app/map/clustering.ts +++ b/app/map/clustering.ts @@ -70,9 +70,33 @@ function toFeature(entity: ClusterableEntity): { } } +interface Rendered { + clusters: ClusterMarker[] + points: T[] +} + +/** + * Last-resort cap for when even zoom 0 will not fit: keep the markers + * standing for the most entities. A cluster covers at least `minPoints` + * entities and a loose point covers one, so clusters by descending count come + * first and points fill whatever budget is left. This maximises how much of + * the data is still represented on screen, which is the only defensible + * ordering when something genuinely has to go. + */ +function keepDensest(rendered: Rendered, limit: number): Rendered { + const clusters = [...rendered.clusters] + .sort((a, b) => b.count - a.count) + .slice(0, limit) + return { + clusters, + points: rendered.points.slice(0, Math.max(0, limit - clusters.length)), + } +} + /** * Clusters `entities` for one viewport/zoom and enforces `rules.forcedLimit` - * as a hard cap on the rendered set, independent of zoom. + * as a hard cap on the total rendered set - clusters plus loose points - + * independent of zoom. * * supercluster's `maxZoom` is the zoom ABOVE WHICH it stops clustering and * returns raw, unclustered points - it is not a bound on how many markers @@ -83,13 +107,29 @@ function toFeature(entity: ClusterableEntity): { * stops clustering, so every entity in view comes back individually and * nothing enforces the limit any more. * - * This keeps `maxZoom: rules.zoomLevel` (the configured "zoom in to - * declutter" behaviour is still worth having below that zoom) but then - * caps the combined cluster+point count against `forcedLimit` itself, - * after clustering runs, so the bound holds at every zoom rather than only - * while supercluster is still willing to cluster. Clusters are kept in - * full and loose points are truncated first, since a cluster already - * represents many entities in one cheap marker. + * An earlier pass at this capped only `points`, against a budget of + * `forcedLimit - clusters.length`, which binds for a uniformly scattered + * map and for nothing else. Give it many well-separated small groups - a + * country of small towns with a few gyms each - and every group survives as + * its own cluster, the point budget is zero before any capping happens, and + * the cluster count runs unbounded. Measured at gym rules with 3500 groups + * of 5, zooms 8, 10 and 13 each rendered 3500 markers against a limit of + * 2500. + * + * The cap here is on the total, and it is applied by COARSENING rather than + * by dropping markers: if the count at the requested zoom does not fit, the + * same index is queried at successively lower zooms until it does. Every + * entity in view is then still standing behind some marker, just a bigger + * one, which is the property that separates a decluttered map from a map + * that has silently deleted a third of its towns. Dropping the smallest + * clusters would have made whole regions vanish with nothing on screen + * saying so, and truncating an arbitrary slice is worse again. + * + * Only if even zoom 0 does not fit does anything get dropped, and then the + * markers kept are the ones standing for the most entities (see + * `keepDensest`). That path needs a `forcedLimit` smaller than the number of + * clusters the whole world collapses to, so in practice it is a guard, not a + * behaviour. */ export function clusterEntities( entities: readonly T[], @@ -111,37 +151,45 @@ export function clusterEntities( bounds.east, bounds.north, ] - const raw = index.getClusters(bbox, Math.round(zoom)) - const byId = new Map(entities.map((entity) => [entity.id, entity])) - const clusters: ClusterMarker[] = [] - const points: T[] = [] - for (const feature of raw) { - const [lon, lat] = feature.geometry.coordinates - const properties = feature.properties - if (properties.cluster === true) { - clusters.push({ - kind: 'cluster', - id: `cluster-${feature.id}`, - lat, - lon, - count: properties.point_count as number, - }) - } else if (feature.id !== undefined) { - const entity = byId.get(String(feature.id)) - if (entity) points.push(entity) + + const collect = (at: number): Rendered => { + const clusters: ClusterMarker[] = [] + const points: T[] = [] + for (const feature of index.getClusters(bbox, at)) { + const [lon, lat] = feature.geometry.coordinates + const properties = feature.properties + if (properties.cluster === true) { + clusters.push({ + kind: 'cluster', + id: `cluster-${feature.id}`, + lat, + lon, + count: properties.point_count as number, + }) + } else if (feature.id !== undefined) { + const entity = byId.get(String(feature.id)) + if (entity) points.push(entity) + } } + return { clusters, points } } - const rendered = clusters.length + points.length - if (rendered <= rules.forcedLimit) { - return { points, clusters, limitHit: false } - } + const fits = (rendered: Rendered) => + rendered.clusters.length + rendered.points.length <= rules.forcedLimit - const pointBudget = Math.max(0, rules.forcedLimit - clusters.length) - return { - points: points.slice(0, pointBudget), - clusters, - limitHit: true, + const requested = Math.round(zoom) + const first = collect(requested) + if (fits(first)) return { ...first, limitHit: false } + + // Everything above `zoomLevel` returns the same unclustered set, so the + // descent starts at `zoomLevel` rather than walking those zooms one by one + // to get the identical answer each time. + let coarsest = first + for (let at = Math.min(requested - 1, rules.zoomLevel); at >= 0; at -= 1) { + coarsest = collect(at) + if (fits(coarsest)) return { ...coarsest, limitHit: true } } + + return { ...keepDensest(coarsest, rules.forcedLimit), limitHit: true } } From cba20ff4c788e87a889b2196e4428d23bf666a38 Mon Sep 17 00:00:00 2001 From: Rin <58572875+TurtIeSocks@users.noreply.github.com> Date: Mon, 24 Aug 2026 07:09:12 -0400 Subject: [PATCH 19/24] fix(map): carry the limitHit signal out of buildMapLayers clusterEntities computes limitHit, then clusterPokemon and clusterGyms returned only points and clusters and buildMapLayers returned a bare Layer array, so nothing downstream could ever learn the cap had engaged. Computing a signal and dropping it is the same as not computing it. buildMapLayers now returns the layers alongside a per-category limitHit. A capped map and a genuinely empty area are pixel for pixel identical, so the flag has to reach a caller that can tell the user which one it is looking at. --- app/map/MapCanvas.tsx | 12 +++++++-- app/map/layers.test.ts | 46 +++++++++++++++++++++++++++++++++-- app/map/layers.ts | 55 ++++++++++++++++++++++++++++++++++-------- 3 files changed, 99 insertions(+), 14 deletions(-) diff --git a/app/map/MapCanvas.tsx b/app/map/MapCanvas.tsx index bc838df57..88a052788 100644 --- a/app/map/MapCanvas.tsx +++ b/app/map/MapCanvas.tsx @@ -4,6 +4,7 @@ import type { PickingInfo } from '@deck.gl/core' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createAtlas } from './atlas' import { drawGymIcon, drawPokemonIcon } from './draw-icon' +import type { MapLayersResult } from './layers' import { buildMapLayers } from './layers' import { Popup } from './Popup' import { createFixtureSource } from './source' @@ -40,6 +41,12 @@ function isGym(entity: MapEntity): entity is GymEntity { return entity.kind === 'gym' } +/** What the layer memo falls back to before the atlas exists. */ +const EMPTY_LAYERS: MapLayersResult = { + layers: [], + limitHit: { pokemon: false, gyms: false }, +} + /** How often the countdown/IV text layer is rebuilt against a fresh clock. */ const CLOCK_TICK_MS = 1000 @@ -131,9 +138,9 @@ export function MapCanvas({ initialCamera, onCameraChange }: MapCanvasProps) { return () => clearInterval(interval) }, []) - const layers = useMemo(() => { + const built = useMemo(() => { const atlas = atlasRef.current - if (!atlas) return [] + if (!atlas) return EMPTY_LAYERS return buildMapLayers({ pokemon, gyms, @@ -144,6 +151,7 @@ export function MapCanvas({ initialCamera, onCameraChange }: MapCanvasProps) { // rebuildToken is intentionally in this array with no other purpose // than to invalidate this memo; see handleContextRestore above. }, [pokemon, gyms, now, rebuildToken]) + const layers = built.layers const { containerRef, screenPosition } = useMapLibre({ initialCamera, diff --git a/app/map/layers.test.ts b/app/map/layers.test.ts index 834d7f80f..55248e89d 100644 --- a/app/map/layers.test.ts +++ b/app/map/layers.test.ts @@ -176,7 +176,7 @@ test('buildGymIconLayer marks the shared icon as a mask so getColor actually tin }) test('buildMapLayers returns gyms, pokemon icons and pokemon labels, in that draw order', () => { - const layers = buildMapLayers({ + const { layers } = buildMapLayers({ pokemon: [POKEMON], gyms: [GYM], getIconFor: () => STUB_ICON, @@ -195,7 +195,7 @@ test('buildMapLayers scales to the viewport-sized set the fixtures exist to exer ...POKEMON, spawnId: `spawn-${index}`, })) - const layers = buildMapLayers({ + const { layers } = buildMapLayers({ pokemon, gyms: [], getIconFor: () => STUB_ICON, @@ -205,3 +205,45 @@ test('buildMapLayers scales to the viewport-sized set the fixtures exist to exer const iconLayer = layers.find((layer) => layer.id === POKEMON_ICON_LAYER_ID) expect(iconLayer?.props.data).toHaveLength(500) }) + +/* + * The point of the flag is that a capped map and an empty area look the same + * on screen, so the flag has to reach a caller that can say which one it is. + * An earlier pass computed it in clusterEntities and dropped it here, which + * is the same as not computing it. + */ +test('buildMapLayers reports no cap when nothing was clustered', () => { + const result = buildMapLayers({ + pokemon: [POKEMON], + gyms: [GYM], + getIconFor: () => STUB_ICON, + getGymIcon: () => STUB_ICON, + now: 0, + }) + expect(result.limitHit).toEqual({ pokemon: false, gyms: false }) +}) + +test('buildMapLayers carries limitHit out of the gym clusterer', () => { + const gyms = Array.from({ length: 400 }, (_, index) => ({ + ...GYM, + gymId: `gym-${index}`, + lat: 51 + (index % 20) * 0.05, + lon: -0.5 + Math.floor(index / 20) * 0.05, + })) + const result = buildMapLayers({ + pokemon: [], + gyms, + getIconFor: () => STUB_ICON, + getGymIcon: () => STUB_ICON, + now: 0, + viewport: { + bounds: { west: -1, south: 50, east: 1, north: 53 }, + zoom: 14, + }, + clusterRules: { + gyms: { zoomLevel: 13, forcedLimit: 50, minPoints: 2 }, + }, + }) + expect(result.limitHit.gyms).toBe(true) + expect(result.limitHit.pokemon).toBe(false) +}) diff --git a/app/map/layers.ts b/app/map/layers.ts index 74c0a3611..12cab8c75 100644 --- a/app/map/layers.ts +++ b/app/map/layers.ts @@ -279,6 +279,8 @@ export interface BuildMapLayersOptions { interface ClusteredEntities { points: readonly T[] clusters: readonly ClusterMarker[] + /** Carried straight out of `clusterEntities`; see `MapLayersResult`. */ + limitHit: boolean } function clusterPokemon( @@ -296,6 +298,7 @@ function clusterPokemon( return { points: result.points.map((wrapper) => wrapper.entity), clusters: result.clusters, + limitHit: result.limitHit, } } @@ -314,9 +317,34 @@ function clusterGyms( return { points: result.points.map((wrapper) => wrapper.entity), clusters: result.clusters, + limitHit: result.limitHit, } } +/** + * Whether each category's `forcedLimit` engaged for the frame just built. + * + * This exists because a capped map and a genuinely empty area are pixel for + * pixel identical: the user has no way to tell "there is nothing here" from + * "there is too much here to draw and some of it has been merged away". The + * flag `clusterEntities` computes has to survive the trip out to something + * that can say so, which means `buildMapLayers` cannot return a bare array. + * + * Both are false when no `viewport` is given, since nothing was clustered and + * nothing was capped. + */ +export interface LimitHit { + pokemon: boolean + gyms: boolean +} + +export interface MapLayersResult { + layers: Layer[] + limitHit: LimitHit +} + +const NOTHING_CAPPED: LimitHit = { pokemon: false, gyms: false } + /** * All layers this task adds, in the order deck.gl should draw them: gyms * and pokemon icons first, pokemon labels last so text always sits on top @@ -330,9 +358,10 @@ function clusterGyms( * `forcedLimit` (clustering.ts; see clustering.test.ts for the bug this * closes) before their icon/text layers are built, and any resulting * cluster bubbles are added as their own layers when `getClusterIcon` is - * supplied. `clusterEntities`'s `limitHit` is available to callers that - * cluster directly and want to show a warning; this function itself stays - * `Layer[]`-in-`Layer[]`-out so existing callers are unaffected. + * supplied. + * + * Returns the layers alongside a per-category `limitHit`, rather than a bare + * array, so a caller can tell the user the view is capped. See `LimitHit`. */ export function buildMapLayers({ pokemon, @@ -344,13 +373,16 @@ export function buildMapLayers({ viewport, clusterRules, getClusterIcon, -}: BuildMapLayersOptions): Layer[] { +}: BuildMapLayersOptions): MapLayersResult { if (!viewport) { - return [ - buildGymIconLayer(gyms, getGymIcon, root), - buildPokemonIconLayer(pokemon, getIconFor), - buildPokemonTextLayer(pokemon, now), - ] + return { + layers: [ + buildGymIconLayer(gyms, getGymIcon, root), + buildPokemonIconLayer(pokemon, getIconFor), + buildPokemonTextLayer(pokemon, now), + ], + limitHit: NOTHING_CAPPED, + } } const pokemonResult = clusterPokemon( @@ -396,5 +428,8 @@ export function buildMapLayers({ } } - return layers + return { + layers, + limitHit: { pokemon: pokemonResult.limitHit, gyms: gymResult.limitHit }, + } } From cee6a76e1c4dfaf882a83774e694139342c8c8fa Mon Sep 17 00:00:00 2001 From: Rin <58572875+TurtIeSocks@users.noreply.github.com> Date: Mon, 24 Aug 2026 07:13:29 -0400 Subject: [PATCH 20/24] fix(map): pass the live viewport into buildMapLayers MapCanvas built its layers without a viewport, so buildMapLayers took the render-everything-individually path and none of the clustering or the forcedLimit cap ran on the actual map. Task 6 was fully tested and inert. useMapLibre now reports what the camera frames, once on mount and again on every moveend, and MapCanvas holds that and feeds it back in along with the cluster icon drawer so cluster bubbles have something to draw. The report is a callback rather than a return value because the caller's layers are derived from the viewport and are themselves an input to the hook; returning it would close that loop inside a single render. moveend rather than move: clustering a viewport is real work, markers already move with the map through a gesture, and the only thing stale mid-gesture is the clustering granularity. Verified in a browser against the running map rather than by reading the wiring. At 51.5/0 zoom 10 the layer set came back as gym-icons, pokemon-icons, pokemon-labels, pokemon-cluster-icons and pokemon-cluster-labels, with the viewport carrying the real camera bounds. Forcing the pokemon forcedLimit down to 3 collapsed the framed entities into one cluster and put the capped notice on screen. --- app/map/MapCanvas.tsx | 31 ++++++++++++++++++-- app/map/layers.ts | 13 +++------ app/map/types.ts | 6 ++++ app/map/useMapLibre.test.ts | 30 ++++++++++++++++++- app/map/useMapLibre.ts | 58 ++++++++++++++++++++++++++++++++++++- 5 files changed, 124 insertions(+), 14 deletions(-) diff --git a/app/map/MapCanvas.tsx b/app/map/MapCanvas.tsx index 88a052788..d88ad3cb1 100644 --- a/app/map/MapCanvas.tsx +++ b/app/map/MapCanvas.tsx @@ -3,12 +3,18 @@ import 'maplibre-gl/dist/maplibre-gl.css' import type { PickingInfo } from '@deck.gl/core' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createAtlas } from './atlas' -import { drawGymIcon, drawPokemonIcon } from './draw-icon' +import { drawClusterIcon, drawGymIcon, drawPokemonIcon } from './draw-icon' import type { MapLayersResult } from './layers' import { buildMapLayers } from './layers' import { Popup } from './Popup' import { createFixtureSource } from './source' -import type { GymEntity, MapEntity, MapQuery, PokemonEntity } from './types' +import type { + GymEntity, + MapEntity, + MapQuery, + PokemonEntity, + Viewport, +} from './types' import { useDismissOnEscape } from './useDismissOnEscape' import type { Camera } from './useMapLibre' import { anchorFor, pickedEntityFrom, useMapLibre } from './useMapLibre' @@ -87,6 +93,9 @@ export function MapCanvas({ initialCamera, onCameraChange }: MapCanvasProps) { // popup, so one nullable slot is the whole model. const [selected, setSelected] = useState(null) + // Null only until the map mounts and reports its first frame. + const [viewport, setViewport] = useState(null) + // Bumped on WebGL context restoration purely to force `layers` below to // recompute immediately. buildMapLayers already gets fresh Layer // instances every clock tick regardless, since `now` is in its deps - @@ -138,6 +147,10 @@ export function MapCanvas({ initialCamera, onCameraChange }: MapCanvasProps) { return () => clearInterval(interval) }, []) + // What the camera frames, reported by useMapLibre on mount and on every + // moveend. Passing it into buildMapLayers is what makes any of the + // clustering run at all: without it every entity renders individually and + // no forcedLimit applies, which is the state task 6 shipped in. const built = useMemo(() => { const atlas = atlasRef.current if (!atlas) return EMPTY_LAYERS @@ -146,12 +159,15 @@ export function MapCanvas({ initialCamera, onCameraChange }: MapCanvasProps) { gyms, getIconFor: atlas.getIconFor, getGymIcon: drawGymIcon, + getClusterIcon: drawClusterIcon, now, + ...(viewport ? { viewport } : {}), }) // rebuildToken is intentionally in this array with no other purpose // than to invalidate this memo; see handleContextRestore above. - }, [pokemon, gyms, now, rebuildToken]) + }, [pokemon, gyms, now, viewport, rebuildToken]) const layers = built.layers + const capped = built.limitHit.pokemon || built.limitHit.gyms const { containerRef, screenPosition } = useMapLibre({ initialCamera, @@ -159,6 +175,7 @@ export function MapCanvas({ initialCamera, onCameraChange }: MapCanvasProps) { layers, onPick: handlePick, anchor, + onViewportChange: setViewport, }) const { restoring } = useWebglContextRecovery(containerRef, { @@ -180,6 +197,14 @@ export function MapCanvas({ initialCamera, onCameraChange }: MapCanvasProps) { Restoring map…
)} + {capped && !restoring && ( +
+ Too much to draw here. Zoom in for detail. +
+ )} {selected && screenPosition && ( IconDescriptor @@ -285,7 +280,7 @@ interface ClusteredEntities { function clusterPokemon( pokemon: readonly PokemonEntity[], - viewport: MapLayersViewport, + viewport: Viewport, rules: ClusterRules, ): ClusteredEntities { const wrapped = pokemon.map((entity) => ({ @@ -304,7 +299,7 @@ function clusterPokemon( function clusterGyms( gyms: readonly GymEntity[], - viewport: MapLayersViewport, + viewport: Viewport, rules: ClusterRules, ): ClusteredEntities { const wrapped = gyms.map((entity) => ({ diff --git a/app/map/types.ts b/app/map/types.ts index 35cc5e941..d65ed593d 100644 --- a/app/map/types.ts +++ b/app/map/types.ts @@ -11,6 +11,12 @@ export interface Bounds { north: number } +/** What the camera currently frames: the area, and how far in it is. */ +export interface Viewport { + bounds: Bounds + zoom: number +} + export type MapEntityKind = 'pokemon' | 'gym' export interface MapQuery { diff --git a/app/map/useMapLibre.test.ts b/app/map/useMapLibre.test.ts index 3b80323ca..44e1c8350 100644 --- a/app/map/useMapLibre.test.ts +++ b/app/map/useMapLibre.test.ts @@ -2,7 +2,12 @@ import { expect, test } from 'bun:test' import type { PickingInfo } from '@deck.gl/core' import { IconLayer } from '@deck.gl/layers' import type { GymEntity, PokemonEntity } from './types' -import { anchorFor, buildOverlayProps, pickedEntityFrom } from './useMapLibre' +import { + anchorFor, + buildOverlayProps, + pickedEntityFrom, + viewportFrom, +} from './useMapLibre' /* * Mounting a real MapLibre map needs a WebGL context the test environment @@ -94,3 +99,26 @@ test('the anchor for a selected entity is exactly its coordinate', () => { test('the anchor for no selection is null', () => { expect(anchorFor(null)).toBeNull() }) + +/* + * The bounds-to-Bounds mapping is four accessors read in an order that is + * easy to get wrong and renders without erroring when it is: a swapped west + * and east gives a bbox supercluster quietly returns nothing for, which + * looks exactly like an empty map. Values here are deliberately asymmetric + * so a swap cannot pass. + */ +test('viewportFrom reads each edge of the camera bounds into its own field', () => { + const viewport = viewportFrom({ + getBounds: () => ({ + getWest: () => -3, + getSouth: () => 50, + getEast: () => 1, + getNorth: () => 54, + }), + getZoom: () => 11.4, + }) + expect(viewport).toEqual({ + bounds: { west: -3, south: 50, east: 1, north: 54 }, + zoom: 11.4, + }) +}) diff --git a/app/map/useMapLibre.ts b/app/map/useMapLibre.ts index cec94a545..809347068 100644 --- a/app/map/useMapLibre.ts +++ b/app/map/useMapLibre.ts @@ -4,7 +4,7 @@ import { MapboxOverlay } from '@deck.gl/mapbox' import { Map as MapLibreMap, NavigationControl } from 'maplibre-gl' import { type RefObject, useEffect, useRef, useState } from 'react' import { resolveBasemapStyle } from './basemap' -import type { MapEntity } from './types' +import type { MapEntity, Viewport } from './types' export interface Camera { lat: number @@ -39,6 +39,34 @@ export function anchorFor(entity: MapEntity | null): AnchorCoordinate | null { return entity ? { lat: entity.lat, lon: entity.lon } : null } +/** + * Reads what a map currently frames. Typed against the accessors rather than + * against `MapLibreMap` so a test can hand it a plain object: mounting a real + * map needs a WebGL context this environment does not have, and the + * bounds-to-`Bounds` mapping is exactly the kind of thing that is wrong by a + * swapped field and renders without erroring. + */ +export function viewportFrom(map: { + getBounds(): { + getWest(): number + getSouth(): number + getEast(): number + getNorth(): number + } + getZoom(): number +}): Viewport { + const bounds = map.getBounds() + return { + bounds: { + west: bounds.getWest(), + south: bounds.getSouth(), + east: bounds.getEast(), + north: bounds.getNorth(), + }, + zoom: map.getZoom(), + } +} + export interface UseMapLibreOptions { /** Camera to center on the moment the map is created. */ initialCamera: Camera @@ -74,6 +102,26 @@ export interface UseMapLibreOptions { * MapLibre `Marker`. */ anchor?: AnchorCoordinate | null + /** + * Fired with what the camera frames, once when the map mounts and again on + * every `moveend`. The caller feeds this straight back in as the area + * `buildMapLayers` clusters and caps against; without it there is no + * viewport, the clustering path never runs, and the whole of task 6 sits + * inert behind green tests. + * + * This is a callback rather than a returned value because the caller's + * `layers` are derived from it and are themselves an input to this hook. + * Returning it would close that loop inside one render pass; handing the + * caller the value to store breaks it, the same shape `onCameraChange` + * already uses. + * + * Fires on `moveend`, not on `move`. Clustering a viewport is real work and + * doing it once per animation frame through a pan would cost far more than + * it buys: markers already move with the map during a gesture, so all that + * is stale mid-gesture is the clustering granularity, and it settles the + * moment the gesture does. + */ + onViewportChange?: (viewport: Viewport) => void } export interface UseMapLibreResult { @@ -118,6 +166,7 @@ export function useMapLibre({ layers, onPick, anchor, + onViewportChange, }: UseMapLibreOptions): UseMapLibreResult { const containerRef = useRef(null) const mapRef = useRef(null) @@ -126,6 +175,8 @@ export function useMapLibre({ onCameraChangeRef.current = onCameraChange const onPickRef = useRef(onPick) onPickRef.current = onPick + const onViewportChangeRef = useRef(onViewportChange) + onViewportChangeRef.current = onViewportChange // Read by the mount effect below, once, so the overlay starts with // whatever layers are already available instead of an empty frame while // waiting for the layers-sync effect to run. @@ -161,6 +212,7 @@ export function useMapLibre({ overlayRef.current = overlay const handleMoveEnd = () => { + onViewportChangeRef.current?.(viewportFrom(map)) const center = map.getCenter() onCameraChangeRef.current?.({ lat: center.lat, @@ -188,6 +240,10 @@ export function useMapLibre({ } map.on('move', handleMove) + // Reported here rather than waiting for the first gesture, or the map + // would spend its whole first view unclustered and uncapped. + onViewportChangeRef.current?.(viewportFrom(map)) + mapRef.current = map return () => { map.off('moveend', handleMoveEnd) From 91ddfa9191beb59fc0b14bdba77b88df363ce663 Mon Sep 17 00:00:00 2001 From: Rin <58572875+TurtIeSocks@users.noreply.github.com> Date: Mon, 24 Aug 2026 07:13:31 -0400 Subject: [PATCH 21/24] chore(dev): add a vite launch config for browser verification The map can only be checked for real in a browser: layer wiring, WebGL context handling and clustering granularity all render without erroring when they are wrong. Port 5273 keeps it clear of the default a local server would already be holding. --- .claude/launch.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .claude/launch.json diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 000000000..295f096d4 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "reactmap-app", + "runtimeExecutable": "bunx", + "runtimeArgs": ["vite", "--port", "5273", "--strictPort", "--no-open"], + "port": 5273 + } + ] +} From 7247a694f42f0f6c59cdcacf67ca99f16ed1dc89 Mon Sep 17 00:00:00 2001 From: Rin <58572875+TurtIeSocks@users.noreply.github.com> Date: Mon, 24 Aug 2026 07:15:57 -0400 Subject: [PATCH 22/24] fix(map): move cluster bubble colours into the data palette The bubbles used hardcoded RGBA literals, and the map entity colours on this plan come from app/tokens/data-palette.css only. Adds cluster-small, cluster-medium, cluster-large and cluster-label tokens, read at runtime the way the gym layer already reads team colours, with the same fallback for a token that does not resolve. The values are 1.0's, from src/assets/css/main.css lines 623-644, keyed by the same count thresholds Clustering.jsx line 29 uses. That is the convention the palette file states, and it also clears a collision the literals had walked into: the old bubble blue and amber were the same values drawPokemonIcon uses for a weather-boosted and a badged pokemon, so a small cluster and a boosted spawn were painted alike. Opacity stays in the layer. It is the bubble's material rather than its meaning and it is the same for every bucket, so it is not a colour token. Contrast, computed in contrast.test.ts because nothing else in this repo can see a colour. The label #111827 against the three fills: 8.75:1 on small #6ecc39, 10.50:1 on medium #f0c20c, 6.62:1 on large #f18017. The tokens do not vary by theme, since a bubble sits on map tiles rather than on the page background, so those are the ratios in both. The test still runs per theme so a later override cannot slip past it, and it also holds the three fills at least 7 dE apart across the three dichromacies. --- app/map/layers.test.ts | 90 +++++++++++++++++++++++++++++++++++++ app/map/layers.ts | 84 +++++++++++++++++++++++++++++----- app/tokens/contrast.test.ts | 44 ++++++++++++++++++ app/tokens/data-palette.css | 24 ++++++++++ 4 files changed, 230 insertions(+), 12 deletions(-) diff --git a/app/map/layers.test.ts b/app/map/layers.test.ts index 55248e89d..4a1eafcf0 100644 --- a/app/map/layers.test.ts +++ b/app/map/layers.test.ts @@ -2,14 +2,20 @@ import { afterAll, beforeAll, expect, test } from 'bun:test' import { setupDom, teardownDom } from '../test-setup' import type { IconDescriptor } from './atlas' import { + buildClusterIconLayer, + buildClusterTextLayer, buildGymIconLayer, buildMapLayers, buildPokemonIconLayer, buildPokemonTextLayer, formatCountdown, + GYM_CLUSTER_ICON_LAYER_ID, + GYM_CLUSTER_LABEL_LAYER_ID, GYM_ICON_LAYER_ID, POKEMON_ICON_LAYER_ID, POKEMON_LABEL_LAYER_ID, + readClusterColor, + readClusterLabelColor, readTeamColor, } from './layers' import type { GymEntity, PokemonEntity } from './types' @@ -247,3 +253,87 @@ test('buildMapLayers carries limitHit out of the gym clusterer', () => { expect(result.limitHit.gyms).toBe(true) expect(result.limitHit.pokemon).toBe(false) }) + +/* + * Cluster bubbles read the same palette the gym layer does. These were + * hardcoded RGBA literals in this file until the tokens existed, which is the + * one thing the plan's colour constraint exists to prevent: a map colour that + * cannot be changed from the palette is a map colour nothing can review. + */ +test('readClusterColor picks a bucket token by count and keeps the bubble alpha', () => { + const root = document.createElement('div') + root.style.setProperty('--color-cluster-small', '#6ecc39') + root.style.setProperty('--color-cluster-medium', '#f0c20c') + root.style.setProperty('--color-cluster-large', '#f18017') + document.body.appendChild(root) + try { + expect(readClusterColor(12, root)).toEqual([0x6e, 0xcc, 0x39, 230]) + expect(readClusterColor(100, root)).toEqual([0xf0, 0xc2, 0x0c, 230]) + expect(readClusterColor(1000, root)).toEqual([0xf1, 0x80, 0x17, 230]) + } finally { + root.remove() + } +}) + +test('readClusterColor falls back to a visible grey when its token does not resolve', () => { + const root = document.createElement('div') + const warn = console.warn + let warned = false + console.warn = () => { + warned = true + } + try { + expect(readClusterColor(5, root)).toEqual([128, 128, 128, 230]) + expect(warned).toBe(true) + } finally { + console.warn = warn + } +}) + +test('buildClusterIconLayer and buildClusterTextLayer take their colours from the palette', () => { + const root = document.createElement('div') + root.style.setProperty('--color-cluster-medium', '#f0c20c') + root.style.setProperty('--color-cluster-label', '#111827') + document.body.appendChild(root) + const cluster = { + kind: 'cluster' as const, + id: 'cluster-1', + lat: 51.5, + lon: -0.1, + count: 250, + } + try { + const icons = buildClusterIconLayer( + [cluster], + GYM_CLUSTER_ICON_LAYER_ID, + () => STUB_ICON, + root, + ) + const getColor = icons.props.getColor as (marker: typeof cluster) => unknown + expect(getColor(cluster)).toEqual([0xf0, 0xc2, 0x0c, 230]) + + const labels = buildClusterTextLayer( + [cluster], + GYM_CLUSTER_LABEL_LAYER_ID, + root, + ) + expect(labels.props.getColor).toEqual([0x11, 0x18, 0x27, 255]) + } finally { + root.remove() + } +}) + +test('readClusterLabelColor falls back to a visible grey when its token does not resolve', () => { + const root = document.createElement('div') + const warn = console.warn + let warned = false + console.warn = () => { + warned = true + } + try { + expect(readClusterLabelColor(root)).toEqual([128, 128, 128, 255]) + expect(warned).toBe(true) + } finally { + console.warn = warn + } +}) diff --git a/app/map/layers.ts b/app/map/layers.ts index 3394539b9..093557a58 100644 --- a/app/map/layers.ts +++ b/app/map/layers.ts @@ -185,16 +185,67 @@ export function buildGymIconLayer( } /** - * Buckets a cluster's colour by how many entities it stands in for, same - * three-tier split as 1.0's `marker-cluster-{small,medium,large}` CSS - * classes (`Clustering.jsx`'s `createClusterIcon`): a cluster of a few - * dozen reads very differently from one standing in for a thousand - * entities, and the colour is the only signal of that at a glance. + * How opaque a cluster bubble is painted. Not a token: this is the bubble's + * material, letting a little of the basemap through so a cluster does not + * read as a hole punched in the map, and it is the same for every bucket. + * The colour tokens carry the meaning; this carries none. */ -function clusterColorFor(count: number): Color { - if (count < 100) return [125, 211, 252, 230] // small: light blue - if (count < 1000) return [251, 191, 36, 230] // medium: amber - return [248, 113, 113, 230] // large: red +const CLUSTER_ALPHA = 230 + +/** + * Buckets a cluster by how many entities it stands in for, same three-tier + * split as 1.0's `marker-cluster-{small,medium,large}` CSS classes + * (`Clustering.jsx`'s `createClusterIcon`): a cluster of a few dozen reads + * very differently from one standing in for a thousand entities, and the + * colour is the only signal of that at a glance. + */ +function clusterColorToken(count: number): string { + if (count < 100) return '--color-cluster-small' + if (count < 1000) return '--color-cluster-medium' + return '--color-cluster-large' +} + +/** + * Reads a cluster bubble's fill from the data palette, the same way + * `readTeamColor` reads a team's, and for the same reason: map entity + * colours live in `app/tokens/data-palette.css` and nowhere else. Same + * unresolved-token fallback too, since a pruned custom property fails + * silently rather than loudly. + */ +export function readClusterColor( + count: number, + root: HTMLElement = document.documentElement, +): Color { + const token = clusterColorToken(count) + const raw = getComputedStyle(root).getPropertyValue(token) + const color = parseHexColor(raw) + if (!color) { + console.warn( + `[map] cluster colour token ${token} resolved to ${JSON.stringify(raw)}, expected a #rrggbb value. Falling back to grey.`, + ) + return [ + FALLBACK_TEAM_COLOR[0], + FALLBACK_TEAM_COLOR[1], + FALLBACK_TEAM_COLOR[2], + CLUSTER_ALPHA, + ] + } + return [color[0], color[1], color[2], CLUSTER_ALPHA] +} + +/** The count drawn on a bubble, read from the same palette as the fill. */ +export function readClusterLabelColor( + root: HTMLElement = document.documentElement, +): Color { + const raw = getComputedStyle(root).getPropertyValue('--color-cluster-label') + const color = parseHexColor(raw) + if (!color) { + console.warn( + `[map] cluster label colour token --color-cluster-label resolved to ${JSON.stringify(raw)}, expected a #rrggbb value. Falling back to grey.`, + ) + return FALLBACK_TEAM_COLOR + } + return color } /** @@ -206,6 +257,7 @@ export function buildClusterIconLayer( clusters: readonly ClusterMarker[], id: string, getClusterIcon: () => IconDescriptor, + root?: HTMLElement, ): IconLayer { return new IconLayer({ id, @@ -222,7 +274,7 @@ export function buildClusterIconLayer( mask: true, } }, - getColor: (cluster) => clusterColorFor(cluster.count), + getColor: (cluster) => readClusterColor(cluster.count, root), getSize: 36, }) } @@ -231,6 +283,7 @@ export function buildClusterIconLayer( export function buildClusterTextLayer( clusters: readonly ClusterMarker[], id: string, + root?: HTMLElement, ): TextLayer { return new TextLayer({ id, @@ -239,7 +292,7 @@ export function buildClusterTextLayer( getPosition: (cluster) => [cluster.lon, cluster.lat], getText: (cluster) => String(cluster.count), getSize: 13, - getColor: [17, 24, 39, 255], + getColor: readClusterLabelColor(root), }) } @@ -404,8 +457,13 @@ export function buildMapLayers({ gymResult.clusters, GYM_CLUSTER_ICON_LAYER_ID, getClusterIcon, + root, + ), + buildClusterTextLayer( + gymResult.clusters, + GYM_CLUSTER_LABEL_LAYER_ID, + root, ), - buildClusterTextLayer(gymResult.clusters, GYM_CLUSTER_LABEL_LAYER_ID), ) } if (pokemonResult.clusters.length > 0) { @@ -414,10 +472,12 @@ export function buildMapLayers({ pokemonResult.clusters, POKEMON_CLUSTER_ICON_LAYER_ID, getClusterIcon, + root, ), buildClusterTextLayer( pokemonResult.clusters, POKEMON_CLUSTER_LABEL_LAYER_ID, + root, ), ) } diff --git a/app/tokens/contrast.test.ts b/app/tokens/contrast.test.ts index 827963a62..a4196c12e 100644 --- a/app/tokens/contrast.test.ts +++ b/app/tokens/contrast.test.ts @@ -113,3 +113,47 @@ for (const theme of ['light', 'dark'] as const) { } }) } + +/* + * Cluster bubbles. The count is drawn straight onto the fill, so the pair + * that has to hold is the label against each of the three buckets. Nothing + * else in this repo can see a colour, and these were hardcoded literals in + * the layer until they moved into the palette, which is exactly the state in + * which a contrast failure ships unnoticed. + * + * Both themes assert the same pairs because the tokens do not vary by theme: + * a bubble sits on map tiles, not on the page background. The test still runs + * per theme so a future theme override cannot slip past it. + */ +const CLUSTER_FILLS = [ + '--color-cluster-small', + '--color-cluster-medium', + '--color-cluster-large', +] + +for (const theme of ['light', 'dark'] as const) { + test(`${theme}: the cluster count clears 4.5:1 on every bubble`, () => { + for (const fill of CLUSTER_FILLS) { + const ratio = contrast( + hex(theme, '--color-cluster-label'), + hex(theme, fill), + ) + expect(`${fill}: ${ratio.toFixed(2)}`).toBe( + `${fill}: ${Math.max(ratio, 4.5).toFixed(2)}`, + ) + } + }) + + test(`${theme}: the three cluster buckets stay apart for dichromats`, () => { + for (let i = 0; i < CLUSTER_FILLS.length; i += 1) { + for (let j = i + 1; j < CLUSTER_FILLS.length; j += 1) { + const a = CLUSTER_FILLS[i] as string + const b = CLUSTER_FILLS[j] as string + const apart = separation(hex(theme, a), hex(theme, b)) + expect(`${a} vs ${b}: ${apart.toFixed(1)}`).toBe( + `${a} vs ${b}: ${Math.max(apart, 7).toFixed(1)}`, + ) + } + } + }) +} diff --git a/app/tokens/data-palette.css b/app/tokens/data-palette.css index 2da241a50..cee232782 100644 --- a/app/tokens/data-palette.css +++ b/app/tokens/data-palette.css @@ -57,6 +57,30 @@ --color-iv-mid: #ed6c02; /* < 82%, warning.main */ --color-iv-mid-high: #2ab5f6; /* < 100%, info.main (1.0 override) */ --color-iv-perfect: #2e7d32; /* = 100%, success.main */ + + /* + * Cluster bubble colours. Source: src/assets/css/main.css lines 623-644, + * the .marker-cluster-{small,medium,large} div fills, keyed by the same + * count thresholds src/pages/map/components/Clustering.jsx line 29 uses. + * A bubble covering a few dozen reads very differently from one covering a + * thousand, and colour is the only signal of that at a glance. + * + * The map engine paints these at higher opacity than 1.0's 0.6, which is + * the one thing here not carried across: the opacity is the bubble's + * material rather than its meaning, so it lives with the layer and not in + * this file. + * + * One set for both themes, like the team colours above and unlike the + * chart series below, because a bubble is painted on map tiles rather than + * on the page background and the theme does not change what sits behind + * it. The count uses --color-cluster-label and clears 4.5:1 on all three + * fills, which is therefore true in both themes by construction (verified + * in contrast.test.ts). + */ + --color-cluster-small: #6ecc39; /* < 100 entities */ + --color-cluster-medium: #f0c20c; /* < 1000 */ + --color-cluster-large: #f18017; /* 1000 and up */ + --color-cluster-label: #111827; } /* From 5e685d665e731d90fc6d6174cf2885222793a239 Mon Sep 17 00:00:00 2001 From: Rin <58572875+TurtIeSocks@users.noreply.github.com> Date: Mon, 24 Aug 2026 07:38:18 -0400 Subject: [PATCH 23/24] fix(build): keep deck.gl's gesture and logging deps out of the 1.0 bundle The review attributed 43,773 bytes of map-engine code sitting in the chunk every 1.0 visitor loads eagerly: mjolnir.js for gesture recognition, probe.gl for logging and stats, and tiny-sdf for text. All are deck.gl dependencies under scopes the rule did not enumerate, so they fell through to the vendor catch-all. Vendor drops from 1578215 to 1534236 bytes and the 1.0 entry chunk is byte identical to before, checked after normalising the chunk hashes it embeds. That is the fifth thing to leak through this rule, and it shipped inside the commit whose own comment predicted a fifth. So this also adds a guard that asserts the property against the built output instead of trusting the rule to produce it. The guard reads sourcemaps rather than the chunks. Minification strips module paths, so a package name does not survive into bundled code and grepping the chunk for one finds nothing however much of that package shipped. My first attempt did exactly that and passed against a build I had deliberately broken. Confirmed the version that ships goes red on that same build and names all three packages. Removing the vendor catch-all entirely was tested again and is still wrong: it inlines 1.0's own dependencies into its version chunk, taking it from 472893 to 1963561 bytes. Deciding chunk membership by which entry reaches a module is still the real answer, and still wants its own change. Co-Authored-By: Claude Opus 5 --- app/build-isolation.test.ts | 90 +++++++++++++++++++++++++++++++++++++ vite.config.js | 11 ++++- 2 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 app/build-isolation.test.ts diff --git a/app/build-isolation.test.ts b/app/build-isolation.test.ts new file mode 100644 index 000000000..881aa2c47 --- /dev/null +++ b/app/build-isolation.test.ts @@ -0,0 +1,90 @@ +import { expect, test } from 'bun:test' +import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { join } from 'node:path' + +/** + * The 1.0 client is what essentially every real user is served. Its bundle must + * not carry code that only the 2.0 map can reach. + * + * This exists because vite.config.js decides chunk membership by matching + * package paths, and that rule has now leaked five separate things into the + * chunk 1.0 loads eagerly: tailwind's preflight reset, the fontsource font + * faces, maplibre's stylesheet, deck.gl's rendering engine, and then deck.gl's + * gesture, logging and text dependencies under scopes of their own. The fifth + * shipped in the same commit whose comment predicted a fifth. + * + * Enumerating package names is not a fix for that, it is a fix for one instance + * of it. Every leak so far was silent: nothing errors, no test fails, and the + * only symptom is that people downloading a map they never open pay for it. + * So this asserts the property directly against the built output rather than + * trusting the rule that is supposed to produce it. + * + * If this fails, do not add the offending package to an ignore list here. Add + * it to the map bucket in vite.config.js, or fix the rule properly by deciding + * chunk membership from which entry actually reaches a module. + */ + +const DIST = join(import.meta.dir, '..', 'dist') + +// Substrings that only exist because of the 2.0 map stack. Each is a real +// package name as it appears in bundled module paths and identifiers. +const MAP_ONLY = [ + 'maplibre-gl', + '@deck.gl', + '@luma.gl', + '@math.gl', + '@loaders.gl', + 'mjolnir.js', + '@probe.gl', + 'tiny-sdf', +] + +const readIfBuilt = () => { + if (!existsSync(DIST)) return null + const html = join(DIST, 'index.html') + if (!existsSync(html)) return null + return readFileSync(html, 'utf8') +} + +test('the 1.0 entry loads no chunk containing map-only code', () => { + const html = readIfBuilt() + // CI runs tests before the build step, so a missing dist is not a failure. + // The check still runs locally and in any job that builds first. + if (html === null) return + + const referenced = [...html.matchAll(/["'(]\/([A-Za-z0-9._-]+\.js)/g)].map( + (match) => match[1], + ) + expect(referenced.length).toBeGreaterThan(0) + + const offenders: string[] = [] + for (const file of referenced) { + // Read the sourcemap, not the chunk. Minification strips module paths, so + // a package name does not survive into the bundled code and grepping for + // one finds nothing however much of that package shipped. The sourcemap's + // `sources` list is where the provenance actually lives, which is how the + // 43 kB leak this guard exists for was originally attributed. + const map = join(DIST, `${file}.map`) + if (!existsSync(map)) continue + const sources: string[] = JSON.parse(readFileSync(map, 'utf8')).sources + for (const name of MAP_ONLY) { + if (sources.some((source) => source.includes(name))) { + offenders.push(`${file} carries ${name}`) + } + } + } + + expect(offenders).toEqual([]) +}) + +test('the map libraries are emitted as their own chunks', () => { + if (!existsSync(DIST)) return + const files = readdirSync(DIST) + // Both should exist as separate chunks so the map route pays for them alone. + expect( + files.some((f) => f.startsWith('maplibre-') && f.endsWith('.js')), + ).toBe(true) + expect(files.some((f) => f.startsWith('deckgl-') && f.endsWith('.js'))).toBe( + true, + ) +}) diff --git a/vite.config.js b/vite.config.js index 9a58abb63..e060cfcfa 100644 --- a/vite.config.js +++ b/vite.config.js @@ -201,7 +201,16 @@ const viteConfig = defineConfig(({ mode }) => { id.includes('node_modules/deck.gl') || id.includes('node_modules/@luma.gl') || id.includes('node_modules/@math.gl') || - id.includes('node_modules/@loaders.gl') + id.includes('node_modules/@loaders.gl') || + // deck.gl's gesture, logging and text dependencies sit under + // scopes of their own again. Enumerating is how this rule keeps + // failing: the fifth leak shipped in the same commit whose + // comment predicted a fifth. Measured at the time, 43,773 bytes + // of mjolnir.js, probe.gl and tiny-sdf were reaching every 1.0 + // visitor through the vendor catch-all below. + id.includes('node_modules/mjolnir.js') || + id.includes('node_modules/@probe.gl') || + id.includes('node_modules/@mapbox/tiny-sdf') ) { return 'deckgl' } From 52d6d4904e391a2acc7d67baab8ba7f8ab6c206d Mon Sep 17 00:00:00 2001 From: Rin <58572875+TurtIeSocks@users.noreply.github.com> Date: Mon, 24 Aug 2026 07:41:40 -0400 Subject: [PATCH 24/24] fix(map): clear every icon cache on restore, and stop re-clustering on the clock Three findings from the whole-branch review. Context loss recovery cleared the pokemon atlas and left the gym and cluster icons alone. Those are plain module state with no owner to clear them, holding canvas descriptors that die with the context, so a restore handed deck.gl textures that no longer existed. Nothing errors; the markers simply do not draw. The layer memo depended on the clock, which ticks every second, so a fresh Supercluster index was built over every subscribed entity once a second whether or not anything had moved. Clustering now memoises on the data and the viewport, and only the countdown text is rebuilt on a tick, which is what the per-second work was supposed to be. Fixture expiries are measured from a fixed epoch so the generator stays reproducible, which put every one of them hours behind real time and made every countdown read 0:00. The clock now runs from that epoch rather than from now, so the fixtures stay deterministic and the timers move. This goes back to Date.now() when a real source replaces the fixtures. Co-Authored-By: Claude Opus 5 --- app/map/MapCanvas.tsx | 60 +++++++++++++++++++++++++++++++++++++------ app/map/draw-icon.ts | 13 ++++++++++ app/map/fixtures.ts | 11 +++++++- app/map/layers.ts | 10 ++++++++ 4 files changed, 85 insertions(+), 9 deletions(-) diff --git a/app/map/MapCanvas.tsx b/app/map/MapCanvas.tsx index d88ad3cb1..2fe84bb80 100644 --- a/app/map/MapCanvas.tsx +++ b/app/map/MapCanvas.tsx @@ -3,9 +3,19 @@ import 'maplibre-gl/dist/maplibre-gl.css' import type { PickingInfo } from '@deck.gl/core' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createAtlas } from './atlas' -import { drawClusterIcon, drawGymIcon, drawPokemonIcon } from './draw-icon' +import { + drawClusterIcon, + drawGymIcon, + drawPokemonIcon, + resetSharedIconCaches, +} from './draw-icon' +import { FIXTURE_EPOCH } from './fixtures' import type { MapLayersResult } from './layers' -import { buildMapLayers } from './layers' +import { + buildMapLayers, + buildPokemonTextLayer, + POKEMON_LABEL_LAYER_ID, +} from './layers' import { Popup } from './Popup' import { createFixtureSource } from './source' import type { @@ -51,6 +61,7 @@ function isGym(entity: MapEntity): entity is GymEntity { const EMPTY_LAYERS: MapLayersResult = { layers: [], limitHit: { pokemon: false, gyms: false }, + renderedPokemon: [], } /** How often the countdown/IV text layer is rebuilt against a fresh clock. */ @@ -85,7 +96,13 @@ export function MapCanvas({ initialCamera, onCameraChange }: MapCanvasProps) { // Countdown/IV text is layer data, not a per-marker component: this is // the one clock this whole tree reads, and every timer-bearing layer is // rebuilt from it on the same tick rather than each owning its own. - const [now, setNow] = useState(() => Date.now()) + // Fixture expiries are measured from a fixed epoch so the generator stays + // reproducible, which means real wall-clock time is long past all of them and + // every countdown would read 0:00. Running the clock from that epoch instead + // keeps the fixtures deterministic and the timers live. When a real source + // replaces the fixtures this becomes Date.now() again. + const timeOrigin = useRef(Date.now()) + const [now, setNow] = useState(() => FIXTURE_EPOCH) // The one selected entity, or none. This is the entire replacement for // 1.0's per-marker ref plus useForcePopup/useMarkerTimer: deck.gl's @@ -117,6 +134,9 @@ export function MapCanvas({ initialCamera, onCameraChange }: MapCanvasProps) { // empty map with no error. const handleContextRestore = useCallback(() => { atlasRef.current?.clear() + // The gym and cluster icons live in module state, not in the atlas, so + // they need clearing too or the restore reuses dead textures. + resetSharedIconCaches() setRebuildToken((token) => token + 1) }, []) @@ -143,7 +163,10 @@ export function MapCanvas({ initialCamera, onCameraChange }: MapCanvasProps) { }, []) useEffect(() => { - const interval = setInterval(() => setNow(Date.now()), CLOCK_TICK_MS) + const interval = setInterval( + () => setNow(FIXTURE_EPOCH + (Date.now() - timeOrigin.current)), + CLOCK_TICK_MS, + ) return () => clearInterval(interval) }, []) @@ -151,21 +174,42 @@ export function MapCanvas({ initialCamera, onCameraChange }: MapCanvasProps) { // moveend. Passing it into buildMapLayers is what makes any of the // clustering run at all: without it every entity renders individually and // no forcedLimit applies, which is the state task 6 shipped in. - const built = useMemo(() => { + // Clustering builds a fresh Supercluster index over every subscribed entity, + // so it must not depend on the clock. It previously sat in one memo with + // `now`, which ticks every second, meaning the whole set was re-indexed once + // a second forever whether or not anything moved. Splitting it means the + // per-second work is rebuilding the text layer's array, which is what the + // spec asks for, rather than re-clustering thousands of points. + const clustered = useMemo(() => { const atlas = atlasRef.current - if (!atlas) return EMPTY_LAYERS + if (!atlas) return null return buildMapLayers({ pokemon, gyms, getIconFor: atlas.getIconFor, getGymIcon: drawGymIcon, getClusterIcon: drawClusterIcon, - now, + // Any value: the text layer this produces is replaced on every tick. + now: 0, ...(viewport ? { viewport } : {}), }) // rebuildToken is intentionally in this array with no other purpose // than to invalidate this memo; see handleContextRestore above. - }, [pokemon, gyms, now, viewport, rebuildToken]) + }, [pokemon, gyms, viewport, rebuildToken]) + + const built = useMemo(() => { + const atlas = atlasRef.current + if (!atlas || !clustered) return EMPTY_LAYERS + // Only the text layer reads the clock, so only it is rebuilt on a tick. + return { + layers: clustered.layers.map((layer) => + layer.id === POKEMON_LABEL_LAYER_ID + ? buildPokemonTextLayer(clustered.renderedPokemon, now) + : layer, + ), + limitHit: clustered.limitHit, + } + }, [clustered, now]) const layers = built.layers const capped = built.limitHit.pokemon || built.limitHit.gyms diff --git a/app/map/draw-icon.ts b/app/map/draw-icon.ts index 333d55822..c7fca9a08 100644 --- a/app/map/draw-icon.ts +++ b/app/map/draw-icon.ts @@ -164,3 +164,16 @@ export function drawGymIcon(): IconDescriptor { } return cachedGymIcon } + +/** + * Drops the module-level icon caches. + * + * These hold canvas-derived descriptors that die with the WebGL context, and + * unlike the pokemon atlas they are plain module state with no owner to clear + * them. A restore that leaves them populated hands deck.gl textures that no + * longer exist, which renders nothing and reports nothing. + */ +export function resetSharedIconCaches() { + cachedClusterIcon = undefined + cachedGymIcon = undefined +} diff --git a/app/map/fixtures.ts b/app/map/fixtures.ts index 5235c7383..96c7a26af 100644 --- a/app/map/fixtures.ts +++ b/app/map/fixtures.ts @@ -24,7 +24,16 @@ const SEED = 20260824 * reason these fixtures are seeded at all: a countdown that changes on * every reload cannot be compared against a screenshot from yesterday. */ -const FIXTURE_EPOCH = Date.UTC(2026, 7, 24) +/** + * The instant fixture expiries are measured from. + * + * Fixed rather than wall-clock so a given seed always produces byte-identical + * entities. The cost is that against real time every fixture is long expired, + * so anything rendering a countdown from `Date.now()` shows 0:00 for all of + * them and the map reads as dead. A caller wanting live countdowns should run + * its clock from here rather than from now; see MapCanvas. + */ +export const FIXTURE_EPOCH = Date.UTC(2026, 7, 24) /** Deterministic PRNG (mulberry32) so fixtures reproduce across runs. */ function createRng(seed: number) { diff --git a/app/map/layers.ts b/app/map/layers.ts index 093557a58..eb614a71d 100644 --- a/app/map/layers.ts +++ b/app/map/layers.ts @@ -387,6 +387,14 @@ export interface LimitHit { } export interface MapLayersResult { + /** + * The pokemon actually rendered after clustering, so a caller can rebuild + * just the countdown text on a tick instead of re-running the clustering. + * Building a Supercluster index over every subscribed entity once a second + * is what this exists to avoid. + */ + renderedPokemon: readonly PokemonEntity[] + layers: Layer[] limitHit: LimitHit } @@ -430,6 +438,7 @@ export function buildMapLayers({ buildPokemonTextLayer(pokemon, now), ], limitHit: NOTHING_CAPPED, + renderedPokemon: pokemon, } } @@ -486,5 +495,6 @@ export function buildMapLayers({ return { layers, limitHit: { pokemon: pokemonResult.limitHit, gyms: gymResult.limitHit }, + renderedPokemon: pokemonResult.points, } }