Skip to content

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 typecheck and pnpm build passing on main before starting.
  • leaflet-draw and @types/leaflet-draw are already dependencies (package.json) — no new package installs needed for Steps 3–4.
  • No change to data/*.json shapes 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) — into app/features/map-objects/poi/poi-icons.ts (or similar), unchanged.
  • Add app/features/map-objects/map-object-layer.tsx exporting MapObjectLayer, a discriminated union on kind (mirroring MapObjectForm's pattern) covering what PoiMarker/PoiLayer (map.tsx:350/454), LineShape (:713), and AreaShape (:885) do today: zoom-range visibility filtering, click-to-select, and — for POI only — draggable move with revert-on-error.
  • Delete PoiMarker, PoiLayer, LineShape, AreaShape from map.tsx. LineLayer/AreaLayer (:813/:1010) keep their draft-rendering half for now (Step 4 removes it) but delegate saved-shape rendering to MapObjectLayer.

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, and LABEL_ICON to app/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 own L.FeatureGroup/toolbar instances, following the lifecycle shown in app/components/ui/map.tsx's MapDrawControl/MapDrawEdit (context + EditToolbar.Edit/Delete refs), but without rendering any toolbar buttons — no MapDrawControl-style UI, since Step 4 drives them from the existing EditSidebar.
  • 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 the drawingLine/drawingArea branches of MapClickHandler (:403) — leaflet-draw captures map clicks itself while a creation tool is active.
  • Add a hook (e.g. app/features/map-objects/use-map-object-draw.ts) that wires useDrawGeometry/useEditGeometry to Line/Area semantics: on onCreated, replace today's "click points accumulate in draftLinePoints/draftAreaPoints, then onFinishLineRequest/onFinishAreaRequest" flow (currently driven by use-map-editing-state.ts) with leaflet-draw's own finish gesture; on onPointsChange, submit through the existing updateLinePoints/updateAreaPoints fetchers unchanged.
  • Update app/features/map-editor/use-map-editing-state.ts: lineFinished/areaFinished/draftLinePoints/draftAreaPoints are replaced or repurposed to reflect "tool active" / "geometry produced by leaflet-draw" instead of manually accumulated click points. Update app/features/map-editor/edit-detail-panel.tsx and edit-sidebar.tsx wherever 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 to useEditGeometry.

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 (the MapContainer/crs/source-branch setup) and stops accepting POI/line/area/dataset/overlay props directly.
  • project-map.tsx assembles the map as <Map source={source}><MapTileLayer .../><MapObjectLayer kind="poi" .../><MapObjectLayer kind="line" .../><MapObjectLayer kind="area" .../><FeatureLabelLayer .../> ...</Map> (or Map accepts a children slot 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/*.json shapes, route URLs, or server modules.
  • Migrating POI creation/movement onto leaflet-draw (see Decisions).
  • app/components/ui/map.tsx itself — reference file only, not touched.
  • Any new visual design for the edit sidebar/detail panel beyond what Step 4's data-flow change requires.