Map primitives — finishing the migration (v0.0.6)¶
Follow-up to map-primitives.md (task 1: MapImperativeLayer,
MapFitBounds, MapTileLayer — already landed, see
app/components/map-primitives.tsx).
This plan covers the remaining work to get every layer in
app/components/map.tsx off its current
hand-rolled, ~1650-line inline implementation and onto composable primitives,
finishing with the exported Map component itself.
Source: refined task breakdown + clarifying questions (chat session, 2026-08-24).
Decisions¶
| Decision | Choice | Why |
|---|---|---|
| Plan scope | One document, 7 sequenced steps, covering POI/line/area rendering, drawing/vertex-editing, avesmaps + image-overlay layers, and the Map prop-bag replacement |
Confirmed with you: finish the whole migration rather than drip it out one task at a time |
| Per-kind layer file location | Kind-specific pieces (POI icon building, per-kind styling) move into their feature folders (app/features/map-objects/poi/, line/, area/); kind-generic pieces (MapObjectLayer, FeatureLabelLayer) live at app/features/map-objects/ root |
Confirmed with you; matches the existing precedent — map-object-form.tsx/map-object-detail-panel.tsx already live at the folder root and branch on kind, while poi/poi.ts keeps POI-only exports like POI_TYPE_ICON_COMPONENTS |
| POI/line/area layers: one generic component vs three | One generic MapObjectLayer, branching on kind internally (marker vs polyline vs polygon) |
Confirmed with you; mirrors MapObjectForm's existing kind-branching pattern rather than introducing a fourth code-sharing style |
| Draw/edit UX for lines & areas | Adopt leaflet-draw (already a dependency — see package.json, and already used as the reference pattern in app/components/ui/map.tsx's MapDraw* family) to replace the hand-rolled DraftLine/DraftArea/EditableVertices/VertexHandle/MidpointHandle |
Confirmed with you (chose "explore switching to leaflet-draw"); removes ~350 lines of hand-built vertex/midpoint/delete-glyph code in favor of a maintained library already in the dependency tree |
| POI creation/move | Stays on the existing plain click-to-place + draggable-Marker flow — not migrated to leaflet-draw's marker tool |
A single point has no vertices to edit; leaflet-draw has nothing to offer here, and moving it would be pure risk for no gain |
leaflet-draw's own floating toolbar |
Not used. The existing EditSidebar buttons keep driving placement/drawing start-stop; new primitives expose imperative start/stop instead of shadcn's self-contained toolbar buttons |
The app's UX is "toggle a mode from the sidebar, fill in the name/type form there once done" — introducing leaflet-draw's own on-map toolbar (as app/components/ui/map.tsx's MapDrawControl renders it) would be a second, redundant control surface |
Accepted UX changes from the leaflet-draw switch |
Vertex delete and midpoint-insert gestures will follow leaflet-draw's own conventions instead of today's always-visible × glyph and draggable midpoint dot |
Rebuilding leaflet-draw's vertex/midpoint handling by hand to match pixel-for-pixel defeats the point of adopting it. Called out explicitly here since it's a real interaction change, not asserted to be identical — confirm the new gestures are acceptable during Step 4's manual test |
| Avesmaps dataset layers | Move into app/features/avesmaps/ |
Colocation answer; pairs with the existing avesmaps-dataset.ts/avesmaps-dataset.server.ts |
| Image overlay layers | Move into app/features/map-controls/, next to the existing image-overlay-control.tsx toggle UI |
Colocation answer; the control and the layer it toggles belong together, and there's no separate "image overlays" feature folder to invent |
Map's prop bag |
Replaced last (Step 7), once every layer is already its own module | Only feasible after Steps 1–6; matches the ordering from the original roadmap |
Prerequisites¶
- Clean working tree,
pnpm typecheckandpnpm buildpassing onmainbefore starting. leaflet-drawand@types/leaflet-draware already dependencies (package.json) — no new package installs needed for Steps 3–4.- No change to
data/*.jsonshapes or route URLs anywhere in this plan.
Step 1 — Generic MapObjectLayer for saved POIs, lines, and areas¶
Goal: One kind-branching component renders saved map objects (marker for POI, styled polyline for line, styled polygon for area), replacing PoiMarker/PoiLayer/LineShape/AreaShape and the non-draft parts of LineLayer/AreaLayer. Drafting/vertex-editing for line/area is explicitly deferred to Steps 3–4; this step only touches saved geometry.
- Move POI icon logic —
buildPoiIcon(map.tsx:296),resolvePoiIcon(:325),usePoiIcons(:335) — intoapp/features/map-objects/poi/poi-icons.ts(or similar), unchanged. - Add
app/features/map-objects/map-object-layer.tsxexportingMapObjectLayer, a discriminated union onkind(mirroringMapObjectForm's pattern) covering whatPoiMarker/PoiLayer(map.tsx:350/454),LineShape(:713), andAreaShape(:885) do today: zoom-range visibility filtering, click-to-select, and — for POI only — draggable move with revert-on-error. - Delete
PoiMarker,PoiLayer,LineShape,AreaShapefrommap.tsx.LineLayer/AreaLayer(:813/:1010) keep their draft-rendering half for now (Step 4 removes it) but delegate saved-shape rendering toMapObjectLayer.
Acceptance criteria
- [ ] PoiMarker, PoiLayer, LineShape, AreaShape, buildPoiIcon, resolvePoiIcon, usePoiIcons no longer exist in map.tsx.
- [ ] MapObjectLayer is the only place saved POI/line/area geometry is rendered.
- [ ] Zoom-range visibility, click-to-select, and POI drag-to-move (with revert on a failed save) behave identically to before — manual smoke test.
- [ ] pnpm typecheck and pnpm build pass.
Step 2 — FeatureLabelLayer as its own module¶
Goal: The cross-kind label-collision layer (map.tsx:1141, plus its lineMidpoint/:1095 and areaCentroid/:1128 helpers) moves out of map.tsx into its own module, unchanged.
- Move
FeatureLabelLayer,lineMidpoint,areaCentroid,LabelCandidate,LABEL_COLLISION_RADIUS_PX, andLABEL_ICONtoapp/features/map-objects/feature-label-layer.tsx.
Acceptance criteria
- [ ] None of the moved symbols remain defined in map.tsx.
- [ ] Label placement and collision avoidance (POI/line/area labels, per-type/per-object zoom ranges) behave identically — manual smoke test at several zoom levels with overlapping labels.
- [ ] pnpm typecheck and pnpm build pass.
Step 3 — Generic leaflet-draw primitives¶
Goal: Domain-agnostic wrappers around leaflet-draw's polyline/polygon creation and single-FeatureGroup vertex editing exist, usable by any future geometry-drawing feature — no Line/Area imports.
New file app/components/map-draw-primitives.tsx — shapes only, no bodies:
// Starts/stops a leaflet-draw creation tool (L.Draw.Polyline | L.Draw.Polygon)
// imperatively; fires onCreated with the finished layer's latlngs when the
// user completes the shape (leaflet-draw's own DrawEvents.Created).
function useDrawGeometry(options: {
active: boolean
shape: "polyline" | "polygon"
onCreated: (points: L.LatLng[]) => void
}): void
// Hosts a single external layer inside a private FeatureGroup and drives
// leaflet-draw's EditToolbar.Edit over just that layer while `active`;
// fires onPointsChange on every edit (drag of a vertex or a
// leaflet-draw-provided midpoint).
function useEditGeometry(options: {
active: boolean
positions: L.LatLng[]
closed: boolean
onPointsChange: (points: L.LatLng[]) => void
}): void
- Both hooks call
useMap()internally and manage their ownL.FeatureGroup/toolbar instances, following the lifecycle shown inapp/components/ui/map.tsx'sMapDrawControl/MapDrawEdit(context +EditToolbar.Edit/Deleterefs), but without rendering any toolbar buttons — noMapDrawControl-style UI, since Step 4 drives them from the existingEditSidebar. - Import
leaflet-draw's CSS (leaflet-draw/dist/leaflet.draw.css) once, in this new file.
Acceptance criteria
- [ ] app/components/map-draw-primitives.tsx exists, exporting useDrawGeometry and useEditGeometry (or equivalently named hooks/components with these responsibilities) with no import from ~/features/map-objects/*.
- [ ] Nothing yet consumes this file — map.tsx's behavior is unchanged.
- [ ] pnpm typecheck and pnpm build pass.
Step 4 — Wire line & area drawing/editing onto the new primitives¶
Goal: Line and area creation and vertex editing run through Step 3's primitives instead of DraftLine/DraftArea/EditableVertices/VertexHandle/MidpointHandle, with the EditSidebar's existing buttons still the only way to start/stop placing or editing.
- Delete
DraftLine(map.tsx:766),DraftArea(:944),EditableVertices(:653),VertexHandle(:553),MidpointHandle(:608), and thedrawingLine/drawingAreabranches ofMapClickHandler(:403) —leaflet-drawcaptures map clicks itself while a creation tool is active. - Add a hook (e.g.
app/features/map-objects/use-map-object-draw.ts) that wiresuseDrawGeometry/useEditGeometrytoLine/Areasemantics: ononCreated, replace today's "click points accumulate indraftLinePoints/draftAreaPoints, thenonFinishLineRequest/onFinishAreaRequest" flow (currently driven byuse-map-editing-state.ts) with leaflet-draw's own finish gesture; ononPointsChange, submit through the existingupdateLinePoints/updateAreaPointsfetchers unchanged. - Update
app/features/map-editor/use-map-editing-state.ts:lineFinished/areaFinished/draftLinePoints/draftAreaPointsare replaced or repurposed to reflect "tool active" / "geometry produced byleaflet-draw" instead of manually accumulated click points. Updateapp/features/map-editor/edit-detail-panel.tsxandedit-sidebar.tsxwherever they read those fields. MapObjectLayer(Step 1) keeps owning saved, non-editing-mode line/area rendering; the currently-editing line/area's layer is the one handed touseEditGeometry.
Acceptance criteria
- [ ] None of DraftLine, DraftArea, EditableVertices, VertexHandle, MidpointHandle remain in map.tsx.
- [ ] Drawing a new line and a new area, and editing an existing line's/area's vertices (add via midpoint, move a vertex, delete a vertex), all work end-to-end and save through the existing add-line/add-area/update-line-points/update-area-points intents in project-map.tsx — manual test, noting the accepted gesture differences from the Decisions table.
- [ ] Only the currently-editing line or area is vertex-editable at a time (matches today's one-at-a-time behavior).
- [ ] pnpm typecheck and pnpm build pass.
Step 5 — Avesmaps dataset layers move into their feature folder¶
Goal: AvesmapsFeatureLayer (map.tsx:53) and AvesmapsRouteLayer (:122) move to app/features/avesmaps/, unchanged otherwise.
- Move both to
app/features/avesmaps/avesmaps-map-layers.tsx(or split into two files — implementer's call).
Acceptance criteria
- [ ] Neither component remains defined in map.tsx.
- [ ] Avesmaps point/route features still render, cull by viewport and per-subtype min-zoom, and remain click-selectable identically to before.
- [ ] pnpm typecheck and pnpm build pass.
Step 6 — Image overlay layers move into map-controls¶
Goal: UploadImageLayer (map.tsx:170), RotatedImageOverlay (:201), and ImageOverlayLayerView (:233) move next to the control that toggles them.
- Move all three to
app/features/map-controls/image-overlay-layer.tsx.
Acceptance criteria
- [ ] None of the three remain defined in map.tsx.
- [ ] An uploaded single-image map, and a configured image overlay on a tile-set map (position, scale, rotation, opacity, per-layer min-zoom, and the ImageOverlayControl visibility toggle), all render identically to before.
- [ ] pnpm typecheck and pnpm build pass.
Step 7 — Replace Map's prop bag with composition¶
Goal: Map's ~35-key flat prop interface is replaced by composing the now-independent layer components as JSX children, and project-map.tsx is updated to match.
Map(map.tsx:1372) keeps only what's inherently shared (theMapContainer/crs/source-branch setup) and stops accepting POI/line/area/dataset/overlay props directly.project-map.tsxassembles the map as<Map source={source}><MapTileLayer .../><MapObjectLayer kind="poi" .../><MapObjectLayer kind="line" .../><MapObjectLayer kind="area" .../><FeatureLabelLayer .../> ...</Map>(orMapaccepts achildrenslot with these pre-composed) instead of passing 35 flat props.- State/callbacks that today flow through
Map's prop bag are passed directly to the specific layer component that consumes them.
Acceptance criteria
- [ ] Map's prop type no longer has one flat interface covering every domain concern.
- [ ] project-map.tsx's JSX shows which layers compose the map, in what order, rather than one opaque prop bag.
- [ ] Full manual pass of view mode and edit mode (place/select/edit/delete for POI/line/area, dataset layer toggles, image overlay toggles, mode switching) shows no regression beyond the Step 4 gesture changes already agreed.
- [ ] pnpm typecheck and pnpm build pass.
Out of scope¶
- Any change to
data/*.jsonshapes, route URLs, or server modules. - Migrating POI creation/movement onto
leaflet-draw(see Decisions). app/components/ui/map.tsxitself — reference file only, not touched.- Any new visual design for the edit sidebar/detail panel beyond what Step 4's data-flow change requires.