Move and reshape existing map features — implementation plan¶
Source idea: points of interest, lines, and areas can currently only be
placed once — moving a point, or reshaping a line/area after it's drawn,
isn't possible. Both map-lines-and-areas.md and map-points-of-interest.md
explicitly deferred this ("no vertex reshaping after creation in v1", "same
gap POIs have today").
Three sequential, independently shippable parts — points first (establishes the drag-and-persist interaction on the simplest geometry), then lines (establishes the multi-vertex handle pattern), then areas (reuses it for a closed ring). Confirmed in clarification rounds before this plan:
- Points: drag the existing marker to a new position.
- Lines/areas: drag an existing vertex to move it, drag a segment midpoint to insert a new vertex there, and use a dedicated per-vertex delete affordance to remove one — not just moving.
- All of the above only in edit mode, only once a feature is selected (the
same click that opens today's rename/delete sidebar form —
editingPoiId/editingLineId/editingAreaId). - A drag persists immediately on drag-end via the existing per-feature fetcher pattern — no separate Save/Cancel step.
- Removing a vertex is refused client- and server-side if it would drop a
line below 2 points or an area below 3 (same minimum
addLine/addAreaalready enforce).
Cross-cutting decisions¶
| Decision | Choice | Why |
|---|---|---|
| Handle element | Marker with a small divIcon, not CircleMarker |
Leaflet's draggable option only exists on Marker; CircleMarker/other Path layers have no built-in drag support. The existing draft-drawing vertices use CircleMarker because they're click targets, not drag targets — that precedent doesn't cover this case |
| Drag gate | A feature's handles only render when it's the one currently open in the edit sidebar (editingPoiId/editingLineId/editingAreaId), passed down as a new Map prop |
Matches the confirmed "draggable once selected" decision; reuses state that already exists instead of adding a second selection concept |
| Persistence | New intent values on the existing project-map.tsx action, submitted through a new useFetcher per feature kind, exactly like add-line/update-line/remove-line today |
Same one-route, intent-keyed dispatch precedent as every other mutation on this route |
| Revert on failure | Keep a ref to the underlying Leaflet layer and call .setLatLng/.setLatLngs back to the last-known-good geometry when the fetcher's action returns an error |
react-leaflet only calls setLatLng when its position/positions prop value changes; a failed save leaves that prop unchanged (loader data didn't move), so the already-dragged Leaflet layer would otherwise stay visually wrong |
| Data layer split | New position/points-only mutators (movePointOfInterest, updateLinePoints, updateAreaPoints), not overloads of updatePointOfInterest/updateLine/updateArea |
Those functions' inputs are exactly the sidebar rename form's fields; a move/reshape submission never carries name/description/type, so folding it in would make every call site pass fields it doesn't have. Mirrors this file's own precedent of removePointOfInterest being a separate function from updatePointOfInterest |
| Vertex removal affordance | A small dedicated delete control anchored at each vertex handle, visible whenever that line/area is selected in edit mode | Confirmed with user; keeps removal discoverable without a Leaflet popup (already ruled out project-wide) or a gesture that collides with the plain drag-to-move handle |
| Shared handle logic | A single component (e.g. EditableVertices) parameterized by whether the ring is closed, used by both the line and the area layer |
Lines and areas need identical handle/insert/delete behavior over a list of points; the only geometric difference is whether a wrap-around segment exists between the last and first point |
Part 1: Move a point of interest¶
Goal¶
From the edit sidebar, selecting a point of interest lets a GM drag its marker to a new position, with the new position persisted on drop.
Scope¶
In: dragging a selected POI's marker; persisting the new position on
drag-end; reverting the marker's visual position if the save fails.
Out: line/area handles (Parts 2–3); any change to add/rename/delete; a player-facing equivalent (edit mode only, per the cross-cutting decisions).
Data model¶
// app/lib/projects.server.ts — new export, alongside updatePointOfInterest
export function movePointOfInterest(
projectId: string,
mapId: string,
poiId: string,
position: { lat: number; lng: number }
): PointOfInterest | undefined
Same not-found convention as every existing write path (undefined when the
id doesn't resolve); no new validation needed (a lat/lng pair has no invalid
shape at this layer).
Tasks¶
P1 — Data layer: movePointOfInterest¶
Goal: The server module can persist a new position for an existing POI without touching its name/description/type.
Acceptance criteria:
app/lib/projects.server.tsexportsmovePointOfInterestwith the signature above; it writes onlyposition, leaving every other field untouched, and returnsundefinedwhen the project/map/poi id doesn't resolve (no write in that case).pnpm typecheckpasses.
P2 — Route + map wiring: drag, persist, revert¶
Goal: A GM can drag a selected POI's marker on the map and see the move persist.
Acceptance criteria:
project-map.tsx's action gains a"move"intent readingpoiId,lat,lng, callingmovePointOfInterest, returning{ ok: true }/{ error }(404 →Responseif the id doesn't resolve, same as every other intent).Map's props gaineditingPoiId: string | nullandonPoiMoveRequest: (poiId: string, position: { lat: number; lng: number }) => void;project-map.tsxpasses its existingeditingPoiIdstate and a new handler that submits the"move"intent through a newuseFetcher.PoiMarkerisdraggableonly when itspoi.id === editingPoiId; ondragendit callsonPoiMoveRequestwith the marker's newgetLatLng().- If the move fetcher's submission returns an error, the marker's Leaflet layer is reset to its last saved position (see the revert-on-failure decision above) rather than staying at the dropped position.
- A POI's marker is not draggable outside edit mode or while unselected.
pnpm typecheckpasses.- Manual check:
pnpm dev, select a POI, drag it, reload, confirm the new position persisted.
Part 2: Reshape an existing line¶
Depends on Part 1 (reuses the same selection-gated, fetcher-persisted drag pattern) but is independently shippable — no code from Part 1 is required by Part 2 beyond the pattern itself.
Goal¶
From the edit sidebar, selecting a line lets a GM drag a vertex to move it, drag a segment midpoint to insert a new vertex, or delete a vertex — with every change persisted immediately.
Scope¶
In: vertex/midpoint/delete handles on a selected line, gated on edit mode;
persisting the full replacement points array on each change; refusing a
removal that would drop a line below 2 points.
Out: point-of-interest dragging (Part 1, precedes this); area handles (Part 3); drawing brand-new lines, or editing name/description/type (both already shipped).
Data model¶
// app/lib/projects.server.ts — new export, alongside updateLine
export function updateLinePoints(
projectId: string,
mapId: string,
lineId: string,
points: { lat: number; lng: number }[]
): LineFeature | { error: string } | undefined
Rejects (via { error: "A line needs at least 2 points." }, no write) the
same way addLine already does, when points.length < 2.
Tasks¶
L1 — Data layer: updateLinePoints¶
Goal: The server module can persist a full replacement point list for an existing line, enforcing the existing 2-point minimum.
Acceptance criteria:
app/lib/projects.server.tsexportsupdateLinePointswith the signature above; writes onlypoints; returnsundefinedwhen the project/map/line id doesn't resolve; returns{ error: "A line needs at least 2 points." }without writing whenpoints.length < 2.pnpm typecheckpasses.
L2 — Route wiring¶
Goal: project-map.tsx's action accepts a full point-list replacement
for an existing line.
Acceptance criteria:
- The action gains an
"update-line-points"intent readinglineIdand a JSON-encodedpointsfield (same encoding conventionadd-linealready uses), callingupdateLinePoints, returning{ ok: true }/{ error }. pnpm typecheckpasses.
L3 — Map component: vertex, midpoint, and delete handles¶
Goal: A selected line renders draggable vertex handles, draggable midpoint-insert handles, and a delete affordance per vertex; all three persist through the new fetcher.
Acceptance criteria:
- A new
EditableVertices-style component (or equivalent) renders, for an open path of N points: N vertex handles (Marker,draggable) and N-1 midpoint handles positioned between consecutive points. LineShaperenders these handles only whenline.id === editingLineId(a new prop threaded the same wayeditingPoiIdwas in Part 1), gated on edit mode.- Dragging a vertex handle and releasing it submits the full updated
pointsarray via the"update-line-points"intent; dragging a midpoint handle inserts a new point at that index and submits the lengthened array. - Each vertex handle has a delete affordance that submits the point list with that index removed — except when the line has exactly 2 points, in which case the affordance is disabled/no-op and nothing is submitted.
- On a failed submission, the line's Leaflet layer (and its handles) reset to the last saved point list, same revert rule as Part 1.
- No handles render outside edit mode or while the line is unselected.
pnpm typecheckpasses.- Manual check:
pnpm dev, select a saved line, drag a vertex, insert one via a midpoint, delete one, reload after each and confirm persistence; confirm deleting down to 2 points disables further deletion.
Part 3: Reshape an existing area¶
Depends on Part 2 shipping — reuses EditableVertices, parameterized for a
closed ring (a wrap-around segment/midpoint between the last and first
point).
Goal¶
From the edit sidebar, selecting an area lets a GM drag a vertex to move it, insert a new vertex via any edge's midpoint (including the closing edge), or delete a vertex — with every change persisted immediately.
Scope¶
In: vertex/midpoint/delete handles on a selected area, including the edge
between the last and first point; persisting the replacement points
array; refusing a removal that would drop an area below 3 points.
Out: point/line handles (Parts 1–2); drawing brand-new areas, or editing name/description/type (already shipped); holes or self-intersection handling.
Data model¶
// app/lib/projects.server.ts — new export, alongside updateArea
export function updateAreaPoints(
projectId: string,
mapId: string,
areaId: string,
points: { lat: number; lng: number }[]
): AreaFeature | { error: string } | undefined
Rejects (via { error: "An area needs at least 3 points." }, no write) the
same way addArea already does, when points.length < 3.
Tasks¶
A1 — Data layer: updateAreaPoints¶
Goal: The server module can persist a full replacement point list for an existing area, enforcing the existing 3-point minimum.
Acceptance criteria:
app/lib/projects.server.tsexportsupdateAreaPointswith the signature above; writes onlypoints; returnsundefinedwhen the project/map/area id doesn't resolve; returns{ error: "An area needs at least 3 points." }without writing whenpoints.length < 3.pnpm typecheckpasses.
A2 — Route wiring¶
Goal: project-map.tsx's action accepts a full point-list replacement
for an existing area.
Acceptance criteria:
- The action gains an
"update-area-points"intent readingareaIdand a JSON-encodedpointsfield, callingupdateAreaPoints, returning{ ok: true }/{ error }. pnpm typecheckpasses.
A3 — Map component: closed-ring vertex, midpoint, and delete handles¶
Goal: A selected area renders draggable vertex handles, draggable midpoint-insert handles (including the closing edge), and a delete affordance per vertex; all three persist through the new fetcher.
Acceptance criteria:
EditableVerticesis reused/extended with a "closed ring" mode so an N-point area gets N vertex handles and N midpoint handles (the Nth spanning from the last point back to the first).AreaShaperenders these handles only whenarea.id === editingAreaId, gated on edit mode, following the same prop-threading pattern as Part 2.- Dragging a vertex handle, inserting via a midpoint (including the closing
edge), and deleting a vertex each submit the updated
pointsarray via"update-area-points", with deletion disabled/no-op at exactly 3 points. - On a failed submission, the area's Leaflet layer resets to the last saved point list, same revert rule as Parts 1–2.
- No handles render outside edit mode or while the area is unselected.
pnpm typecheckpasses.- Manual check:
pnpm dev, select a saved area, drag a vertex, insert one via the closing edge's midpoint, delete one, reload after each and confirm persistence; confirm deleting down to 3 points disables further deletion.