Skip to content

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 typecheck and pnpm build passing on main before 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 MapImperativeLayer and MapFitBounds per the shapes above. Both must work from inside <MapContainer> (i.e. call useMap() internally), matching how TileSetLayer is 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 GridTileLayer class from map.tsx (currently ~L46) into map-primitives.tsx, unchanged.
  • Add MapTileLayer to map-primitives.tsx: computes the same bounds TileSetLayer computes today (grid: via map.unproject at tileSet.maxZoom; xyz: from tileSet.bounds), then renders/composes MapFitBounds (passing that same bounds as both bounds and maxBounds, matching today's setMaxBounds+fitBounds pair) and MapImperativeLayer (grid → new GridTileLayer(tileSet, layerOptions), xyz → new L.TileLayer(tileSet.urlTemplate, layerOptions)), keyed on tileSet.id exactly as TileSetLayer's effect is today (its comment on why — avoiding rebuild on loader-revalidation object identity churn — carries over unchanged).
  • In map.tsx: delete GridTileLayer and TileSetLayer, import MapTileLayer from ~/components/map-primitives, and replace the single <TileSetLayer tileSet={tileSet} /> call site (in the tileSet-source branch of the exported Map component) 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 / ImageOverlayLayerView onto MapImperativeLayer, 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.