Skip to content

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/PointOfInterest types, pois on MapRef, addPointOfInterest / updatePointOfInterest / removePointOfInterest
  • app/routes/project-map.tsx — loader returns pois; action handles add / update / remove intents; header grows a view/edit mode 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 (or false for the boolean remove) when projectId/mapId/poiId doesn't resolve, caller throws the 404 or ignores a no-op remove.
  • readProjectsFile maps over every project's maps and defaults pois to [] when the field is missing, so the rest of the codebase can always assume MapRef["pois"] is an array.
  • addMap is updated to initialize pois: [] on every newly created map.
  • No change to MapSource or any other existing field.

Why this shape

  • POIs live on MapRef (not MapProject) because a position is only meaningful within one map's own pixel/CRS.Simple coordinate space.
  • Three focused server functions (add/update/remove) mirror the existing addMap/setPreviewImage one-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 mode toggle in project-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.
  • useFetcher reuses 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.ts exports PoiType, PointOfInterest, and the updated MapRef shape above.
  • readProjectsFile normalizes every map's pois to [] when the field is absent, so no caller has to handle undefined.
  • addMap initializes pois: [] on every map it creates.
  • addPointOfInterest, updatePointOfInterest, removePointOfInterest are exported with the signatures above, writing through writeProjectsFile and following addMap's not-found convention.
  • data/projects.json's existing entries remain valid with no manual migration.
  • pnpm typecheck passes.

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.pois alongside the existing project, map, and source.
  • The action reads an intent field from request.formData() and dispatches to addPointOfInterest / updatePointOfInterest / removePointOfInterest accordingly:
  • intent: "add" reads name, description, type, lat, lng; returns { error: "Name is required." } for a blank name and { error: "Invalid type." } for a type outside the PoiType enum, without writing.
  • intent: "update" reads poiId, name, description, type with the same validation.
  • intent: "remove" reads poiId.
  • On success each intent returns a plain { ok: true } (no redirect() — this route is driven by fetchers, per the Decisions table).
  • A non-resolving params.projectId/params.mapId still throws new Response("Not Found", { status: 404 }) (existing loader/action behavior, unchanged).
  • pnpm typecheck passes.

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 (in map.tsx) accepts a new pois: PointOfInterest[] prop from project-map.tsx's loader data.
  • Each POI renders as a react-leaflet <Marker> at L.latLng(poi.position.lat, poi.position.lng), visually distinguished by type (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/edit mode toggle added in T4 (i.e. markers aren't gated behind edit mode).
  • pnpm typecheck passes.
  • Manual check: pnpm dev, open a map whose data/projects.json entry has a manually-added pois entry, 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 a mode: "view" | "edit" toggle button (local useState, default "view"), replacing no existing control.
  • In edit mode only, a second toggle button, "Add point of interest", arms a placingPoi boolean passed down to Map.
  • While placingPoi is true, clicking the map (via useMapEvent("click", ...)) opens a form — name (required), description (optional), type (Select over the three PoiType values) — anchored at the clicked position, plus "Save"/"Cancel".
  • "Save" submits via useFetcher with intent: "add" and the clicked lat/lng; on success the new marker/popup from T3 appears and placingPoi resets to false (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 error in the still-open form; placingPoi stays armed.
  • pnpm typecheck passes.
  • 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 useFetcher with intent: "update" and the POI's id; 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 useFetcher with intent: "remove" and the POI's id, 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 typecheck passes.
  • Manual check: pnpm dev, edit a POI's name and confirm it persists after reload; delete a POI and confirm it's gone from data/projects.json.