feat: 2.0 map engine, MapLibre and deck.gl - #1249
Merged
Merged
Conversation
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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.
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.
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.
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.
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.
… 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.
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 <noreply@anthropic.com>
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.
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 <noreply@anthropic.com>
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.
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 <noreply@anthropic.com>
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.
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.
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.
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.
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.
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.
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.
…ndle 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 <noreply@anthropic.com>
…n 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 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Plan 4 of 5. The 2.0 client gets a map: MapLibre owning the basemap and camera, deck.gl riding it interleaved so markers sit beneath street labels, an atlas that composites marker icons off the main thread, text as layer data rather than as thousands of React components, picking with one anchored popup, clustering whose limit actually bounds, and recovery from WebGL context loss.
Nothing in
src/orserver/changes and the per-user flag stays off, so no current user sees any of this yet.What is here
A source interface with
subscribereturning an unsubscribe, backed by deterministic fixtures. There is no server behind it. Transport belongs to a later session, and this exists so it has somewhere to land. The interface is deliberately small: every field on it is a field the wire has to carry and the server has to produce.An icon atlas keyed on appearance, cached behind an LRU. This is the piece the whole rewrite turns on, since compositing per marker per frame is what made 1.0 choppy.
Pokemon and gyms only. Routes, S2 cells, scan areas and interaction ranges follow the same shape and arrive with the features that need them, against entity shapes a later session defines rather than shapes invented here.
Text lives in a
TextLayer, one array rebuilt per second for every visible countdown. There is no per-marker timer, and adding one would reintroduce exactly the 1 Hz re-render storm this replaces.What the reviews caught
Almost none of this was visible to a passing test suite, which is the part worth reading.
The atlas cache hit 1.4% of the time. 5000 entities produced 4649 distinct keys. The key function was correct; the fixtures generated maximally diverse entities, drawing species uniformly across all 493 with uniform forms and costumes, so near-total uniqueness was arithmetic. The test asserted only that distinct keys were fewer than entities, which is true at 4649 out of 5000 and would be true at 4999. It could not fail for the reason it existed. With a realistic distribution the hit rate is 73% at full scale and 42% over a viewport-sized batch.
The cluster limit was fixed twice. 1.0 builds its clusterer with supercluster's
maxZoomset to a config zoom level, andmaxZoomis the zoom above which supercluster stops clustering. So the limit was enforced by a mechanism that switched itself off exactly when a user zoomed in, which is when the most markers are on screen. That got fixed, and then failed again for a different data shape: many well-separated small groups, where each forms its own cluster, the point budget goes to zero and the cluster count runs unbounded. 3500 towns with five gyms each rendered 3500 markers against a limit of 2500. The first test used uniform scatter, which is why it passed.The second fix drops nothing in the ordinary case. It re-queries the same index at coarser zooms until the total fits, so the count falls while every entity stays represented behind some marker. Dropping the smallest clusters would have bounded the count by making whole regions vanish with nothing on screen saying so, and it would have done it to exactly the small towns the failing shape was about.
Worth noting what the earlier version did at zoom 16: it rendered 2500 markers for 17500 entities, inside the limit, with the other 15000 merged into nothing and simply absent. The bound held while five sixths of the map disappeared. Asking whether the limit binds turned out to be the wrong question; whether the data is still represented is the right one.
Five things leaked into the bundle 1.0 users download. The chunking rule matches package paths, and across this project it has now let through Tailwind's preflight reset, the 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. That last one, 43,773 bytes of
mjolnir.js,probe.glandtiny-sdf, shipped inside the commit whose comment predicted a fifth leak. Every one was silent.So this adds a guard that asserts the property against the built output rather than trusting the rule to produce it. It reads sourcemaps rather than the chunks, because minification strips module paths and a package name does not survive into bundled code. The first version of that guard grepped the chunks, and passed cleanly against a build that had been broken on purpose.
Verification
The 1.0 bundle was compared against the base commit by content rather than filename. Its entry chunk is byte identical at 472,893 bytes after normalising the chunk hashes it embeds, its stylesheet is unchanged, and Tailwind's reset is still absent from it. The 1.0 hub downloads no maplibre, deck.gl, luma.gl, math.gl or loaders.gl, checked in the actual chunk bytes.
Known limitations
Nothing here has been seen rendered. There is no browser, GPU or network in the environment this was built in, so tiles never loaded. Every claim about what appears on screen is structural. Interleaving is verified as configuration, not as markers actually sitting under labels.
About 4.9 KB of 2.0-only UI code, Radix's
Slotandclass-variance-authority, reaches 1.0 visitors through the same catch-all. It grows with every 2.0 feature and nothing gates it. The durable fix is to decide chunk membership by which entry reaches a module rather than by matching paths. Removing the catch-all outright was measured twice and is not that fix: it inlines 1.0's own dependencies into its version chunk, taking it from 472,893 to 1,963,561 bytes.Coarsening changes what a marker means at a given zoom. A user at street level on a capped view sees clusters where they expected individual markers, and only the status line says why. 1.0 did not behave that way. If it feels wrong in use, the lever is
forcedLimitper category rather than the algorithm.The capped notice is not routed through i18n, because 2.0 has no i18n wiring yet. 1.0 has a
cluster_limitkey for the same message, so 2.0 had quietly regressed on behaviour it once had.MapCanvasstill subscribes with world bounds at a fixed zoom. Only rendering is viewport-aware, so the clustering is doing work the source could avoid.The popup has no
roleoraria-live, so assistive technology gets no announcement when it opens. Picking is mouse and touch only today, so this is a whole-feature limitation rather than a regression.🤖 Generated with Claude Code