Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
22ef7bf
docs: plan the 2.0 map engine
TurtIeSocks Aug 24, 2026
55b2000
feat(map): define the source interface and its fixtures
TurtIeSocks Aug 24, 2026
0eb934c
docs: name the right id field in the atlas task
TurtIeSocks Aug 24, 2026
a4dc45f
refactor(map): make subscription the primary map source operation
TurtIeSocks Aug 24, 2026
43395b3
refactor(map): rename entity ids so species cannot be misread
TurtIeSocks Aug 24, 2026
073975d
fix(map): derive fixture expiry from a fixed epoch, not the clock
TurtIeSocks Aug 24, 2026
6557b2f
refactor(map): narrow team and gender to their closed sets
TurtIeSocks Aug 24, 2026
ad87569
feat(map): composite marker icons behind an lru cache
TurtIeSocks Aug 24, 2026
fd96392
fix(map): draw fixture appearance from a realistic distribution
TurtIeSocks Aug 24, 2026
bc85b60
test(map): assert a real hit rate instead of a comparison that cannot…
TurtIeSocks Aug 24, 2026
7a368a4
feat(map): render the basemap and sync the camera
TurtIeSocks Aug 24, 2026
1d24244
feat(map): add deck.gl overlay with pokemon and gym layers
TurtIeSocks Aug 24, 2026
e82dda8
fix(build): keep deck.gl's engine out of the shared vendor chunk
TurtIeSocks Aug 24, 2026
e0bbdb6
feat(map): wire deck.gl picking to a single anchored popup
TurtIeSocks Aug 24, 2026
6f3baf1
feat(map): dismiss the popup on escape
TurtIeSocks Aug 24, 2026
e7adedf
fix(map): bound forcedLimit at every zoom, not just below maxZoom
TurtIeSocks Aug 24, 2026
188cb1f
feat(map): recover from WebGL context loss
TurtIeSocks Aug 24, 2026
43cc9f4
fix(map): cap the total rendered set, not just the loose points
TurtIeSocks Aug 24, 2026
cba20ff
fix(map): carry the limitHit signal out of buildMapLayers
TurtIeSocks Aug 24, 2026
cee6a76
fix(map): pass the live viewport into buildMapLayers
TurtIeSocks Aug 24, 2026
91ddfa9
chore(dev): add a vite launch config for browser verification
TurtIeSocks Aug 24, 2026
7247a69
fix(map): move cluster bubble colours into the data palette
TurtIeSocks Aug 24, 2026
5e685d6
fix(build): keep deck.gl's gesture and logging deps out of the 1.0 bu…
TurtIeSocks Aug 24, 2026
52d6d49
fix(map): clear every icon cache on restore, and stop re-clustering o…
TurtIeSocks Aug 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .claude/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "reactmap-app",
"runtimeExecutable": "bunx",
"runtimeArgs": ["vite", "--port", "5273", "--strictPort", "--no-open"],
"port": 5273
}
]
}
90 changes: 90 additions & 0 deletions app/build-isolation.test.ts
Original file line number Diff line number Diff line change
@@ -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,
)
})
262 changes: 262 additions & 0 deletions app/map/MapCanvas.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
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,
resetSharedIconCaches,
} from './draw-icon'
import { FIXTURE_EPOCH } from './fixtures'
import type { MapLayersResult } from './layers'
import {
buildMapLayers,
buildPokemonTextLayer,
POKEMON_LABEL_LAYER_ID,
} from './layers'
import { Popup } from './Popup'
import { createFixtureSource } from './source'
import type {
GymEntity,
MapEntity,
MapQuery,
PokemonEntity,
Viewport,
} 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
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'
}

/** What the layer memo falls back to before the atlas exists. */
const EMPTY_LAYERS: MapLayersResult = {
layers: [],
limitHit: { pokemon: false, gyms: false },
renderedPokemon: [],
}

/** 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
* 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) {
// 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<ReturnType<typeof createAtlas> | null>(null)
if (atlasRef.current === null) {
atlasRef.current = createAtlas({ draw: drawPokemonIcon })
}
const sourceRef = useRef<ReturnType<typeof createFixtureSource> | null>(null)
if (sourceRef.current === null) {
sourceRef.current = createFixtureSource()
}

const [pokemon, setPokemon] = useState<PokemonEntity[]>([])
const [gyms, setGyms] = useState<GymEntity[]>([])
// 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.
// 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
// 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<MapEntity | null>(null)

// Null only until the map mounts and reports its first frame.
const [viewport, setViewport] = useState<Viewport | null>(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()
// 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)
}, [])

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.
const anchor = useMemo(() => anchorFor(selected), [selected])

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(FIXTURE_EPOCH + (Date.now() - timeOrigin.current)),
CLOCK_TICK_MS,
)
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.
// 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 null
return buildMapLayers({
pokemon,
gyms,
getIconFor: atlas.getIconFor,
getGymIcon: drawGymIcon,
getClusterIcon: drawClusterIcon,
// 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, 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

const { containerRef, screenPosition } = useMapLibre({
initialCamera,
...(onCameraChange ? { onCameraChange } : {}),
layers,
onPick: handlePick,
anchor,
onViewportChange: setViewport,
})

const { restoring } = useWebglContextRecovery(containerRef, {
onRestore: handleContextRestore,
})

return (
<div
className="relative h-[calc(100dvh-4rem)] w-full"
role="application"
aria-label="Map"
>
<div ref={containerRef} className="h-full w-full" />
{restoring && (
<div
role="status"
className="absolute inset-0 flex items-center justify-center bg-black/60 text-white"
>
Restoring map…
</div>
)}
{capped && !restoring && (
<div
role="status"
className="pointer-events-none absolute inset-x-0 top-2 mx-auto w-fit rounded-md bg-black/70 px-3 py-1 text-sm text-white"
>
Too much to draw here. Zoom in for detail.
</div>
)}
{selected && screenPosition && (
<Popup
entity={selected}
x={screenPosition.x}
y={screenPosition.y}
onClose={closePopup}
/>
)}
</div>
)
}
Loading
Loading