Skip to content

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/addArea already 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.ts exports movePointOfInterest with the signature above; it writes only position, leaving every other field untouched, and returns undefined when the project/map/poi id doesn't resolve (no write in that case).
  • pnpm typecheck passes.

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 reading poiId, lat, lng, calling movePointOfInterest, returning { ok: true } / { error } (404 → Response if the id doesn't resolve, same as every other intent).
  • Map's props gain editingPoiId: string | null and onPoiMoveRequest: (poiId: string, position: { lat: number; lng: number }) => void; project-map.tsx passes its existing editingPoiId state and a new handler that submits the "move" intent through a new useFetcher.
  • PoiMarker is draggable only when its poi.id === editingPoiId; on dragend it calls onPoiMoveRequest with the marker's new getLatLng().
  • 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 typecheck passes.
  • 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.ts exports updateLinePoints with the signature above; writes only points; returns undefined when the project/map/line id doesn't resolve; returns { error: "A line needs at least 2 points." } without writing when points.length < 2.
  • pnpm typecheck passes.

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 reading lineId and a JSON-encoded points field (same encoding convention add-line already uses), calling updateLinePoints, returning { ok: true } / { error }.
  • pnpm typecheck passes.

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.
  • LineShape renders these handles only when line.id === editingLineId (a new prop threaded the same way editingPoiId was in Part 1), gated on edit mode.
  • Dragging a vertex handle and releasing it submits the full updated points array 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 typecheck passes.
  • 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.ts exports updateAreaPoints with the signature above; writes only points; returns undefined when the project/map/area id doesn't resolve; returns { error: "An area needs at least 3 points." } without writing when points.length < 3.
  • pnpm typecheck passes.

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 reading areaId and a JSON-encoded points field, calling updateAreaPoints, returning { ok: true } / { error }.
  • pnpm typecheck passes.

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:

  • EditableVertices is 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).
  • AreaShape renders these handles only when area.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 points array 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 typecheck passes.
  • 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.