Skip to content

Map View: Dragging Points of Interest

Goal

In edit mode, let a GM reposition a point of interest by dragging its marker — instead of delete-and-re-add — with the marker being dragged (or the pending new-POI marker) visually highlighted so it's obvious what's active.

Prerequisites

None beyond what's already shipped in map-points-of-interest.md: POI storage, the view/edit mode toggle, marker rendering, and the add/update/ remove fetcher actions in project-map.tsx.

Decisions

Decision Choice Why
Plan file location docs/v0.0.2/map-poi-dragging.md Follows the existing per-feature file precedent in this folder (e.g. map-points-of-interest.md)
Draggable scope Only the POI whose edit form is currently open (editingPoiId) Confirmed with user — matches how selection already gates the sidebar form in edit mode; avoids every marker being draggable at once
Persistence Auto-save on dragend, no extra "Save" click Confirmed with user — matches the react-leaflet draggable-marker reference pattern
Action reuse Drag reuses the existing intent: "update" action, resending name/description/type alongside the new lat/lng Confirmed with user — "reuse same update action intent, to keep it lean and consistent"; avoids a second server code path for one field
Source of name/description/type on a drag-triggered save The last-loaded POI values (editingPoi from loader data), not the sidebar form's live uncontrolled input state The sidebar form is uncontrolled (defaultValue); a drag can fire while the user is mid-typing. Reading the loader's last-saved values means a drag never loses an in-progress edit, but also doesn't carry it — the user must still press Save for text changes. Acceptable edge case per "lean" scope
Plain (no-drag) Save round-trips position The update PoiForm gains hidden lat/lng inputs defaulted to editingPoi.position The update action now always expects a position; without this, a plain Save (editing only text fields) would need special-casing to omit position
Pending (unsaved) marker Also draggable; dragging it updates local pendingPosition state only, no fetcher submit Confirmed with user — in scope; a pending POI has no id to save against yet, so this is client-side only until the Add form is submitted
Halo styling A CSS pulsing-ring effect on the active marker's divIcon, exact colors/timing decided during implementation Confirmed with user — "start with a sensible, neat variant"; no new dependency, styled the same way POI_STYLES/poiIcon already are
Auth None Confirmed with user — app runs locally with no auth, consistent with the existing unauthenticated add/update/remove actions

Scope

In:

Out:

  • Making every POI draggable at once (only the one being edited)
  • Any change to view-mode behavior (markers stay read-only)
  • A new/separate "move" action or intent
  • Custom POI types, routes, layers, project settings — untouched

Data model

// app/lib/projects.server.ts
export function updatePointOfInterest(
  projectId: string,
  mapId: string,
  poiId: string,
  input: {
    name: string
    description?: string
    type: PoiType
    position: { lat: number; lng: number }
  }
): PointOfInterest | undefined
  • Only input grows a required position field; the not-found/return convention (undefined when projectId/mapId/poiId doesn't resolve) is unchanged.

Tasks

Ordered so each task type-checks and is shippable on its own; later tasks depend on earlier ones.

T1 — Server: updatePointOfInterest accepts a position

Goal: The server write path for updating a POI can persist a new position alongside its existing fields.

Acceptance criteria:

  • updatePointOfInterest's input type gains position: { lat: number; lng: number } (see Data model above).
  • The function assigns poi.position = input.position before writeProjectsFile.
  • No change to the not-found (undefined) convention or the add/remove functions.
  • pnpm typecheck passes (this will surface the now-required position argument at the one existing call site, fixed in T2).

T2 — Action: update intent reads and validates lat/lng

Goal: project-map.tsx's action persists a POI's new position whenever it handles an update submission.

Acceptance criteria:

  • The intent === "update" branch reads lat/lng from request.formData() the same way the add branch already does (Number(formData.get("lat")) / Number(formData.get("lng"))).
  • If either value isn't a finite number, the action returns { error: "Invalid position." } without writing — mirroring the existing "Invalid type." guard.
  • On success, updatePointOfInterest is called with position: { lat, lng } added to the existing name/description/ type input.
  • pnpm typecheck passes.

T3 — Sidebar form: plain Save round-trips the current position

Goal: Editing only name/description/type from the sidebar (no drag involved) still saves successfully and leaves the POI's position unchanged.

Acceptance criteria:

  • The update PoiForm's hiddenFields (built in project-map.tsx) includes lat/lng sourced from editingPoi.position, alongside the existing poiId.
  • No visible UI change — these are hidden inputs, same pattern as the existing poiId hidden field.
  • Manual check: pnpm dev, edit a POI's name only (don't touch the marker), Save, reload, confirm the position is unchanged and the name updated.
  • pnpm typecheck passes.

T4 — Draggable marker + auto-save, scoped to the editing POI

Goal: In edit mode, the POI currently open in the sidebar's edit form becomes draggable on the map; dropping it persists the new position immediately via the same update action.

Acceptance criteria:

  • PoiMarker (map.tsx) accepts an active: boolean prop and, when active, renders with draggable and a dragend event handler that reads the marker's new getLatLng() and calls a new onMoveRequest(poiId, latlng) prop — same shape as the existing onSelectRequest callback.
  • PoiLayer/Map thread an activePoiId: string | null and onMoveRequest prop down to each PoiMarker, alongside the existing pois/onSelectRequest/onMapClick props.
  • project-map.tsx passes editingPoiId as activePoiId; its onMoveRequest handler submits updateFetcher with intent: "update", the moved POI's id, its current name/description/type (from editingPoi, the loader data — not the live form inputs, per the Decisions table), and the new lat/lng.
  • Markers for any POI other than activePoiId are not draggable.
  • Manual check: pnpm dev, edit mode, open a POI's edit form, drag its marker, release, reload the page, confirm the new position persisted and the form's name/description/type are unchanged.
  • pnpm typecheck passes.

T5 — Pending (unsaved) marker becomes draggable

Goal: While placing a new POI, the pending marker can be dragged to fine-tune its position before saving.

Acceptance criteria:

  • PendingPoiMarker (map.tsx) accepts draggable plus a dragend handler that reports the marker's new getLatLng().
  • Map wires this to the same setter project-map.tsx already passes as onPlaceRequest (i.e. setPendingPosition), so dragging updates the same state the initial map click sets.
  • No fetcher submit happens from this drag — position only changes in local pendingPosition state until the Add form's own Save is submitted.
  • Manual check: pnpm dev, arm "Add point of interest", click the map, drag the pending marker to a new spot, Save, confirm the saved POI is at the dragged position.
  • pnpm typecheck passes.

T6 — Active-marker halo highlight

Goal: The one marker that's currently draggable (the editing POI, or the pending new-POI marker) is visually distinguished with a pulsing halo.

Acceptance criteria:

  • A halo (CSS keyframe pulse, e.g. an expanding/fading ring) is applied to a marker's divIcon HTML/className exactly when it is the active one — activePoiId match in PoiMarker, or always for PendingPoiMarker — and absent otherwise.
  • Implemented with CSS only (a keyframe animation added to app/app.css or inline in the icon's HTML string, following how poiIcon() already builds marker HTML) — no new dependency.
  • At most one marker pulses at a time; never in view mode.
  • Manual check: pnpm dev, edit mode, open a POI's edit form and confirm only its marker pulses; arm "Add point of interest" and confirm the pending marker pulses too.
  • pnpm typecheck passes.