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:
- app/lib/projects.server.ts —
updatePointOfInterestaccepts aposition - app/routes/project-map.tsx —
updateintent reads/validateslat/lng; sidebar form carries current position; drag events submit the update fetcher - app/components/map.tsx — active marker becomes draggable with a halo; pending marker becomes draggable
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
inputgrows a requiredpositionfield; the not-found/return convention (undefinedwhenprojectId/mapId/poiIddoesn'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'sinputtype gainsposition: { lat: number; lng: number }(see Data model above).- The function assigns
poi.position = input.positionbeforewriteProjectsFile. - No change to the not-found (
undefined) convention or theadd/removefunctions. pnpm typecheckpasses (this will surface the now-requiredpositionargument 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 readslat/lngfromrequest.formData()the same way theaddbranch 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,
updatePointOfInterestis called withposition: { lat, lng }added to the existingname/description/typeinput. pnpm typecheckpasses.
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
updatePoiForm'shiddenFields(built inproject-map.tsx) includeslat/lngsourced fromeditingPoi.position, alongside the existingpoiId. - No visible UI change — these are hidden inputs, same pattern as the
existing
poiIdhidden 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 typecheckpasses.
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 anactive: booleanprop and, whenactive, renders withdraggableand adragendevent handler that reads the marker's newgetLatLng()and calls a newonMoveRequest(poiId, latlng)prop — same shape as the existingonSelectRequestcallback.PoiLayer/Mapthread anactivePoiId: string | nullandonMoveRequestprop down to eachPoiMarker, alongside the existingpois/onSelectRequest/onMapClickprops.project-map.tsxpasseseditingPoiIdasactivePoiId; itsonMoveRequesthandler submitsupdateFetcherwithintent: "update", the moved POI'sid, its currentname/description/type(fromeditingPoi, the loader data — not the live form inputs, per the Decisions table), and the newlat/lng.- Markers for any POI other than
activePoiIdare 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 typecheckpasses.
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) acceptsdraggableplus adragendhandler that reports the marker's newgetLatLng().Mapwires this to the same setterproject-map.tsxalready passes asonPlaceRequest(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
pendingPositionstate 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 typecheckpasses.
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
divIconHTML/className exactly when it is the active one —activePoiIdmatch inPoiMarker, or always forPendingPoiMarker— and absent otherwise. - Implemented with CSS only (a keyframe animation added to
app/app.cssor inline in the icon's HTML string, following howpoiIcon()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 typecheckpasses.