Map primitives — tile layer (v0.0.6)¶
Source: refined task breakdown from a review comparing
app/components/ui/map.tsx (the
shadcn Leaflet abstraction: small composable pieces like Map,
MapTileLayer, MapMarker, each with one responsibility) against
app/components/map.tsx (this app's
actual map: a single ~1650-line file where every layer — tiles, POIs,
lines, areas, avesmaps dataset features, image overlays, draw/edit handles
— is hand-rolled inline, and the exported Map component takes a ~35-key
flat prop bag covering every one of those concerns at once).
This is task 1 of a five-task roadmap (chat session, 2026-08-24) to bring
map.tsx to a similarly composable structure. This plan covers only
task 1: add a small set of app-owned, domain-agnostic Leaflet primitives,
and prove them out by migrating the tile-set layer (GridTileLayer /
TileSetLayer) onto them. Tasks 2–5 (POI layer, line/area + draw/edit,
avesmaps/image-overlay layers, replacing Map's prop bag with composition)
are follow-up work, out of scope here.
Decisions¶
| Decision | Choice | Why |
|---|---|---|
| New primitives file location | app/components/map-primitives.tsx, sibling to map.tsx |
Keeps this task's diff small; map.tsx is a single file today (not a folder), and restructuring it into a folder is better done once, after tasks 2–4 have also moved layers out — not preempted here |
| Primitive generality | MapImperativeLayer and MapFitBounds (see below) take no domain types, only Leaflet's own (L.Map, L.Layer, L.LatLngBounds); MapTileLayer/GridTileLayer depend only on ~/features/tile-sets/tile-sets's TileSet union |
Matches the "no import from map-objects/avesmaps/projects.server" bar for this file; tile-set metadata is map configuration, not user-authored content, so depending on it doesn't reintroduce the coupling this task removes |
Map's external prop signature |
Unchanged in this task | Replacing the ~35-key prop bag with composition is task 5, done last once every layer has already moved off map.tsx's inline implementation; changing the public API now would conflate two independent risks |
UploadImageLayer / ImageOverlayLayerView |
Not migrated here, even though both already hand-roll the same "create a Leaflet layer imperatively in a useEffect, addTo(map), clean up on unmount" shape that MapImperativeLayer generalizes |
Explicitly deferred to task 4 of the wider roadmap; this task's job is to prove the primitive pattern on the least-entangled layer (tiles), not to sweep every imperative layer in one pass |
Where MapTileLayer/GridTileLayer live |
app/components/map-primitives.tsx, not map.tsx |
It's a reusable, non-editor-domain piece (any future map screen needing a tile pyramid could reuse it), same tier as MapImperativeLayer/MapFitBounds |
Prerequisites¶
- Clean working tree,
pnpm typecheckandpnpm buildpassing onmainbefore starting. - No change to
data/*.json, route URLs, or any server module — this is a client-component-only refactor.
New primitives (shape only)¶
For reference during implementation — signatures only, no bodies:
// app/components/map-primitives.tsx
// Generic escape hatch: creates a Leaflet layer imperatively, adds it to
// the current map, and removes it on unmount or when deps change.
// Generalizes the useEffect+addTo(map)+cleanup shape that TileSetLayer,
// UploadImageLayer, and ImageOverlayLayerView each hand-roll today.
function MapImperativeLayer(props: {
createLayer: (map: L.Map) => L.Layer
deps: React.DependencyList
}): null
// Generic: fits the map to `bounds` and (optionally) constrains panning
// to `maxBounds`, once per bounds identity. Generalizes the
// map.setMaxBounds(bounds); map.fitBounds(bounds) pair inlined in
// TileSetLayer's effect today.
function MapFitBounds(props: {
bounds: L.LatLngBounds
maxBounds?: L.LatLngBounds
}): null
// Moved from map.tsx, unchanged behavior: custom getTileUrl for the
// {path}/{z-minZoom}/map_{x}_{y}.webp grid convention.
class GridTileLayer extends L.TileLayer { /* ... */ }
// Replaces TileSetLayer: computes bounds for either tile-set kind, then
// composes MapFitBounds + MapImperativeLayer (grid → GridTileLayer,
// xyz → L.TileLayer).
function MapTileLayer(props: { tileSet: TileSet }): null
Steps¶
1. Add MapImperativeLayer and MapFitBounds¶
Goal: The two generic, domain-agnostic primitives exist in a new file, unused by anything yet.
- Create
app/components/map-primitives.tsx. - Add
MapImperativeLayerandMapFitBoundsper the shapes above. Both must work from inside<MapContainer>(i.e. calluseMap()internally), matching howTileSetLayeris used today as a plain JSX child. - Do not import from
~/features/map-objects/*,~/features/avesmaps/*,~/lib/projects.server, or~/features/tile-sets/*in this step — these two primitives take only Leaflet types.
Acceptance criteria
- [ ] app/components/map-primitives.tsx exists, exporting MapImperativeLayer and MapFitBounds with the signatures above.
- [ ] Neither primitive imports anything outside leaflet / react-leaflet / react.
- [ ] Nothing yet consumes this file — map.tsx's behavior is unchanged.
- [ ] pnpm typecheck and pnpm build pass.
2. Rebuild the tile-set layer on the new primitives¶
Goal: GridTileLayer and TileSetLayer move out of map.tsx and are rebuilt as MapTileLayer, composed from step 1's primitives, with identical runtime behavior.
- Move the
GridTileLayerclass from map.tsx (currently ~L46) intomap-primitives.tsx, unchanged. - Add
MapTileLayertomap-primitives.tsx: computes the same boundsTileSetLayercomputes today (grid: viamap.unprojectattileSet.maxZoom; xyz: fromtileSet.bounds), then renders/composesMapFitBounds(passing that same bounds as bothboundsandmaxBounds, matching today'ssetMaxBounds+fitBoundspair) andMapImperativeLayer(grid →new GridTileLayer(tileSet, layerOptions), xyz →new L.TileLayer(tileSet.urlTemplate, layerOptions)), keyed ontileSet.idexactly asTileSetLayer's effect is today (its comment on why — avoiding rebuild on loader-revalidation object identity churn — carries over unchanged). - In
map.tsx: deleteGridTileLayerandTileSetLayer, importMapTileLayerfrom~/components/map-primitives, and replace the single<TileSetLayer tileSet={tileSet} />call site (in the tileSet-source branch of the exportedMapcomponent) with<MapTileLayer tileSet={tileSet} />.
Acceptance criteria
- [ ] GridTileLayer and TileSetLayer no longer exist in map.tsx; MapTileLayer in map-primitives.tsx is the only definition of this behavior.
- [ ] Map's exported prop type is unchanged; project-map.tsx requires no edits.
- [ ] A grid tile set (e.g. avesmaps) and an xyz tile set (e.g. st1/st2, see tile-sets.ts) both still load tiles, pan, zoom, and respect maxBounds/initial fitBounds the same as before — manual smoke test on both.
- [ ] Switching between maps (which remounts MapTileLayer for a new tileSet.id) still resets pan/zoom correctly, with no leaked layer from the previous tile set.
- [ ] pnpm typecheck and pnpm build pass.
Out of scope¶
- POI, line, area, avesmaps dataset, and image-overlay layers — tasks 2–4 of the wider roadmap.
- Replacing
Map's ~35-key prop bag with composition — task 5, depends on tasks 2–4 landing first. - Migrating
UploadImageLayer/ImageOverlayLayerViewontoMapImperativeLayer, even though they'd fit it — deferred to task 4. - Any change to
app/components/ui/map.tsx— it's the shadcn-generated reference file, not touched by this work.