Map View: Points of Interest¶
Goal¶
Let a GM mark named, typed locations directly on a map — add one by
clicking the map, view it in a popup, and edit or remove it later — with
the change persisted to data/projects.json.
Decisions¶
| Decision | Choice | Why |
|---|---|---|
| Plan file location | docs/v0.0.2/map-points-of-interest.md |
Follows the existing per-feature file precedent in this folder (e.g. map-upload.md) |
| Edit-mode entry point | A local mode: "view" \| "edit" toggle added directly to project-map.tsx's header, not the full sidebar system |
Confirmed with user this task ships assuming edit mode is "available" — the real sidebar shell is a separate backlog item ("Map View: Mode Switching"). This is the smallest stand-in that satisfies this task's criteria; it should be re-homed into that system once it ships, not built out further here |
| POI type values | Fixed enum: "city" \| "army" \| "character" |
Confirmed with user |
| Placement flow | Toggle "Add point of interest" tool (visible only in edit mode) → click the map → fill a form → save. Placement auto-disarms after one successful save | Confirmed with user (click-then-form); auto-disarm keeps one control doing one thing per interaction |
| Removal | "Delete" button inside the POI's popup, no confirm dialog | Confirmed with user |
| Editing | "Edit" button inside the POI's popup swaps it to a form (name, description, type); "Save"/"Cancel" | Matches the existing read-only/edit toggle pattern already used on the project settings page |
| Mutation transport | useFetcher per action (add / update / remove), not <Form> full navigation |
A redirect-driven full-page reload would remount <Map> and reset pan/zoom on every POI edit; fetchers submit and revalidate loader data without navigating |
| Position storage | Plain { lat: number; lng: number } on each POI, not a Leaflet LatLng instance |
Leaflet's LatLng isn't JSON-safe; converted to/from L.latLng(...) only at render time in map.tsx |
| Backward compatibility | readProjectsFile normalizes each map's pois to [] when absent |
Existing entries in data/projects.json predate this feature and have no pois field |
Scope¶
In:
- app/lib/projects.server.ts —
PoiType/PointOfInteresttypes,poisonMapRef,addPointOfInterest/updatePointOfInterest/removePointOfInterest - app/routes/project-map.tsx — loader returns
pois; action handlesadd/update/removeintents; header grows aview/editmode toggle - app/components/map.tsx — renders POI markers/popups, an "Add point of interest" tool, and the click-to-place flow
Out:
- The real edit-mode sidebar shell (left/bottom/top panels, layer/route tools, topbar-as-nav-bar) — separate backlog item, "Map View: Mode Switching"
- Routes between points of interest — separate backlog item ("define routes on maps")
- Custom/user-defined POI types beyond
city/army/character - Dragging an existing POI marker to relocate it (delete + re-add is the only way to move one, for now)
- Any settings/edit permissions or multi-user concerns — this app has no auth
Data model¶
// app/lib/projects.server.ts
export type PoiType = "city" | "army" | "character"
export type PointOfInterest = {
id: string
name: string
description?: string
type: PoiType
position: { lat: number; lng: number }
createdAt: string
}
// added to the existing MapRef type
export type MapRef = {
id: string
name: string
source: MapSource
pois: PointOfInterest[]
createdAt: string
}
export function addPointOfInterest(
projectId: string,
mapId: string,
input: {
name: string
description?: string
type: PoiType
position: { lat: number; lng: number }
}
): PointOfInterest | undefined
export function updatePointOfInterest(
projectId: string,
mapId: string,
poiId: string,
input: { name: string; description?: string; type: PoiType }
): PointOfInterest | undefined
export function removePointOfInterest(
projectId: string,
mapId: string,
poiId: string
): boolean
- All three follow
addMap's existing not-found convention:undefined(orfalsefor the boolean remove) whenprojectId/mapId/poiIddoesn't resolve, caller throws the 404 or ignores a no-op remove. readProjectsFilemaps over every project'smapsand defaultspoisto[]when the field is missing, so the rest of the codebase can always assumeMapRef["pois"]is an array.addMapis updated to initializepois: []on every newly created map.- No change to
MapSourceor any other existing field.
Why this shape¶
- POIs live on
MapRef(notMapProject) because a position is only meaningful within one map's own pixel/CRS.Simplecoordinate space. - Three focused server functions (add/update/remove) mirror the existing
addMap/setPreviewImageone-function-per-write-path convention rather than one combined "save everything" setter — add, edit, and delete are three distinct user actions here, not one form. - A local
modetoggle inproject-map.tsx, instead of waiting on or partially building the Mode Switching sidebar system, keeps this task independently shippable and avoids designing sidebar infrastructure that isn't scoped here. useFetcherreuses a React Router feature already in the dependency tree — no new library — while avoiding the full-navigation reset that the existing<Form>+redirect()pattern would cause on this particular (highly stateful, client-side) route.
Tasks¶
Ordered so each task type-checks and is shippable on its own; later tasks depend on earlier ones.
T1 — Data model: POI storage¶
Goal: Persist points of interest per map, backward-compatible with
existing data/projects.json entries.
Acceptance criteria:
app/lib/projects.server.tsexportsPoiType,PointOfInterest, and the updatedMapRefshape above.readProjectsFilenormalizes every map'spoisto[]when the field is absent, so no caller has to handleundefined.addMapinitializespois: []on every map it creates.addPointOfInterest,updatePointOfInterest,removePointOfInterestare exported with the signatures above, writing throughwriteProjectsFileand followingaddMap's not-found convention.data/projects.json's existing entries remain valid with no manual migration.pnpm typecheckpasses.
T2 — Route wiring: loader + action¶
Goal: project-map.tsx exposes the map's POIs to the client and
accepts add/update/remove submissions.
Acceptance criteria:
- The loader returns
pois: map.poisalongside the existingproject,map, andsource. - The action reads an
intentfield fromrequest.formData()and dispatches toaddPointOfInterest/updatePointOfInterest/removePointOfInterestaccordingly: intent: "add"readsname,description,type,lat,lng; returns{ error: "Name is required." }for a blank name and{ error: "Invalid type." }for atypeoutside thePoiTypeenum, without writing.intent: "update"readspoiId,name,description,typewith the same validation.intent: "remove"readspoiId.- On success each intent returns a plain
{ ok: true }(noredirect()— this route is driven by fetchers, per the Decisions table). - A non-resolving
params.projectId/params.mapIdstill throwsnew Response("Not Found", { status: 404 })(existing loader/action behavior, unchanged). pnpm typecheckpasses.
T3 — Render existing POIs as markers¶
Goal: Every stored POI shows on the map, in both view and edit mode, with a popup describing it.
Acceptance criteria:
Map(inmap.tsx) accepts a newpois: PointOfInterest[]prop fromproject-map.tsx's loader data.- Each POI renders as a
react-leaflet<Marker>atL.latLng(poi.position.lat, poi.position.lng), visually distinguished bytype(exact styling — color/icon — decided during implementation). - Clicking a marker opens a
<Popup>showing the POI's name, description (or none), and type — read-only, no Edit/Delete controls yet (added in T5). - This renders identically regardless of the
view/editmode toggle added in T4 (i.e. markers aren't gated behind edit mode). pnpm typecheckpasses.- Manual check:
pnpm dev, open a map whosedata/projects.jsonentry has a manually-addedpoisentry, confirm the marker and popup appear.
T4 — Edit mode + "Add point of interest" placement¶
Goal: From edit mode, a GM can arm an "Add point of interest" tool, click the map, fill in a form, and see the new POI appear without a full page reload.
Acceptance criteria:
project-map.tsx's header gains amode: "view" | "edit"toggle button (localuseState, default"view"), replacing no existing control.- In edit mode only, a second toggle button, "Add point of interest",
arms a
placingPoiboolean passed down toMap. - While
placingPoiis true, clicking the map (viauseMapEvent("click", ...)) opens a form — name (required), description (optional), type (Selectover the threePoiTypevalues) — anchored at the clicked position, plus "Save"/"Cancel". - "Save" submits via
useFetcherwithintent: "add"and the clickedlat/lng; on success the new marker/popup from T3 appears andplacingPoiresets tofalse(single-shot arm, per the Decisions table). "Cancel" discards the pending marker/form without submitting. - A failed submit (e.g. blank name) shows the returned
errorin the still-open form;placingPoistays armed. pnpm typecheckpasses.- Manual check:
pnpm dev, toggle edit mode, arm "Add point of interest", click the map, save, confirm the marker persists after a full page reload.
T5 — Edit and delete existing POIs¶
Goal: From an existing POI's popup, in edit mode, a GM can update its name/description/type or delete it.
Acceptance criteria:
- In edit mode, the read-only popup from T3 additionally shows "Edit" and "Delete" buttons.
- "Edit" swaps the popup's content to a form pre-filled with the POI's current name/description/type, plus "Save"/"Cancel" — same read-only/edit toggle shape already used on the project settings page.
- "Save" submits via
useFetcherwithintent: "update"and the POI'sid; on success the popup returns to its read-only view showing the new values. "Cancel" discards the in-progress edit without submitting. - "Delete" submits via
useFetcherwithintent: "remove"and the POI'sid, no confirmation dialog; on success the marker is removed from the map. - In view mode, "Edit"/"Delete" are not rendered (read-only popup only, per T3).
pnpm typecheckpasses.- Manual check:
pnpm dev, edit a POI's name and confirm it persists after reload; delete a POI and confirm it's gone fromdata/projects.json.