Skip to content

Map lines and areas — implementation plan

Source idea (refined from an app analysis request): points of interest today are the only markable feature on a map. Add two more: lines (roads, rivers, borders) and areas (forests, lakes, regions). These map to existing, still-unrefined backlog lines in docs/features/future/backlog.md — "define routes on maps", "regions/borders … e.g. for defining countries, provinces, etc.", "define regions and borders".

This is two sequential, independently shippable tasks — lines first, since it establishes the multi-vertex drawing interaction and the typed-registry pattern that areas then reuses. Both parts follow the exact precedent set by points of interest: a project-level type registry (poi-types.md) plus per-map feature storage and click-driven placement (map-points-of-interest.md).

Confirmed in a clarification round before this plan:

  • Split into two tasks, lines first.
  • Lines and areas each get their own typed registry (name + color), mirroring PoiTypeDefinition, not ad-hoc per-feature styling.
  • Drawing is click-per-vertex; a line finishes when the last-placed vertex is clicked again.
  • No vertex reshaping after creation in v1 (rename/re-type/delete only) — same gap POIs have today.
  • No GM/player visibility toggling in v1 — same as POIs today.

Part 1: Line features

Goal

Let a GM draw a multi-point line on a map (road, river, border, etc.), assign it a project-level line type (name + color), and have it persist and render — using the same sidebar-driven add/edit/delete flow already established for points of interest.

Decisions

Decision Choice Why
New lib module app/lib/line.ts, mirroring app/lib/poi.ts Same shape of problem (a typed registry + a positioned feature) gets the same file layout; keeps poi.ts untouched
Type registry shape LineTypeDefinition = { slug, name, color } — no icon field Lines render as a colored stroke, not a marker glyph; an icon field would be dead data
Color palette Reuse POI_TYPE_COLORS from poi.ts (import, don't duplicate) One preset palette across POIs/lines/areas avoids three lists drifting out of sync
Default line types Road (#78716c stone), River (#3b82f6 blue), Border (#ef4444 red) Common RPG-map line kinds, distinct colors from the default POI types
Geometry storage points: { lat: number; lng: number }[], minimum 2 Same plain, JSON-safe shape as PointOfInterest.position; converted to L.LatLng only at render time
Drawing interaction Click to place each vertex; the most-recently-placed vertex renders as its own clickable hit target, and clicking it again finishes the line Confirmed with user; needs no new UI chrome (no separate "Finish" button)
Minimum shape 2 points; finishing with fewer is a no-op (button stays in drawing mode) A 1-point "line" isn't a line; mirrors the existing "Name is required" style of silent-until-valid validation
Sidebar entry point The already-present but empty "Routes" heading in the edit-mode sidebar (project-map.tsx) gains the "Add line" control That section exists today as an unused stub — the natural, zero-new-chrome home for this tool, same slot pattern as "Points of interest" → "Add point of interest"
Mutation transport useFetcher per action (add / update / remove), new intent values (add-line, update-line, remove-line) alongside the existing add/update/remove (POI) values on the same route action Exact precedent from POIs; avoids a second route or a full navigation that would reset map pan/zoom
Editing surface Sidebar panel (rename, re-type, edit description, delete) — never a Leaflet popup Matches the existing POI rule; <Select> inside a Leaflet popup has known interaction issues here
Reshaping Not in v1 — only rename/re-type/delete after creation Confirmed with user; consistent with POIs, which also can't be dragged yet
Visibility All users see all lines, no GM/player toggle Confirmed with user; consistent with current POI behavior
Migration readProjectsFile backfills project.lineTypes to DEFAULT_LINE_TYPES and map.lines to [] when absent Same in-memory, no-forced-write backfill pattern already used for poiTypes/pois/imageOverlays

Scope

In:

  • app/lib/line.ts (new) — LineTypeDefinition, LineType, LINE_DEFAULT... constants, UNKNOWN_LINE_TYPE, DEFAULT_LINE_TYPES, LineFeature
  • app/lib/projects.server.ts — lineTypes on MapProject, lines on MapRef, addLine/updateLine/removeLine, addLineType/updateLineType/removeLineType, migration backfill, addMap/createProject initialization
  • app/routes/project.tsx — a "Line Types" settings card (list + add/edit/delete), action gains the three new intent branches
  • app/routes/project-map.tsx — loader returns lines; action gains add-line/update-line/remove-line; sidebar gains drawing state, a LineForm, and select/edit/delete wiring
  • app/components/map.tsx — a LineLayer (draft-drawing + finished-line rendering), using Polyline (already imported) and CircleMarker (already imported) for per-vertex hit targets

Out:

  • Vertex reshaping/dragging after a line is saved (separate follow-up, alongside the existing POI-drag backlog item)
  • GM/player visibility toggling
  • Area/polygon features (Part 2, below)
  • Routes-between-points, pathfinding (separate, later backlog items)

Data model

// app/lib/line.ts
import { POI_TYPE_COLORS } from "~/lib/poi"

export type LineType = string // a LineTypeDefinition's slug

export type LineTypeDefinition = {
  slug: string
  name: string
  color: string // one of POI_TYPE_COLORS
}

export const UNKNOWN_LINE_TYPE: { color: string } = { color: "#94a3b8" }

export const DEFAULT_LINE_TYPES: LineTypeDefinition[] = [
  { slug: "road", name: "Road", color: "#78716c" },
  { slug: "river", name: "River", color: "#3b82f6" },
  { slug: "border", name: "Border", color: "#ef4444" },
]

export type LineFeature = {
  id: string
  name: string
  description?: string
  type: LineType
  points: { lat: number; lng: number }[] // >= 2, in draw order
  createdAt: string
}
// app/lib/projects.server.ts — additions
export type MapRef = {
  // ...existing fields
  lines: LineFeature[]
}

export type MapProject = {
  // ...existing fields
  lineTypes: LineTypeDefinition[]
}

export function addLine(
  projectId: string,
  mapId: string,
  input: {
    name: string
    description?: string
    type: LineType
    points: { lat: number; lng: number }[]
  }
): LineFeature | undefined

export function updateLine(
  projectId: string,
  mapId: string,
  lineId: string,
  input: { name: string; description?: string; type: LineType }
): LineFeature | undefined

export function removeLine(
  projectId: string,
  mapId: string,
  lineId: string
): boolean

export function addLineType(
  projectId: string,
  input: { name: string; color: string }
): LineTypeDefinition | { error: string } | undefined

export function updateLineType(
  projectId: string,
  slug: string,
  input: { name: string; color: string }
): LineTypeDefinition | undefined

export function removeLineType(projectId: string, slug: string): boolean
  • Same not-found convention as every existing write path: undefined (or false for the boolean remove) when an id doesn't resolve.
  • readProjectsFile backfills project.lineTypes to DEFAULT_LINE_TYPES and map.lines to [] in-memory, mirroring the existing poiTypes/pois backfill a few lines above.
  • addMap initializes lines: []; createProject seeds lineTypes: DEFAULT_LINE_TYPES.
  • Deleting a line type never touches existing lines — an orphaned type slug falls back to UNKNOWN_LINE_TYPE at render time, exactly like UNKNOWN_POI_TYPE.

Why this shape

  • A new line.ts (not folding lines into poi.ts) keeps each feature kind's registry independently deletable/extensible — a "line" is not a specialization of a "point of interest," so sharing one module would couple two unrelated concerns for no benefit.
  • Reusing POI_TYPE_COLORS instead of a second hardcoded palette avoids two lists of the same 10 hex values silently drifting apart.
  • Vertex placement reuses CircleMarker (already imported in map.tsx for the read-only Avesmaps feature layer) as the per-vertex hit target — no new dependency, and it already renders as a small, clickable dot, which is exactly what a draggable-free vertex marker needs to look like.
  • New intent values on the same project-map.tsx action (rather than a new route) follow the exact dispatch pattern already used to add POI type-independent behavior in project.tsx's action — one action, one intent-keyed switch, no new route surface for a feature that lives on the same map.

Tasks

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

L1 — Data model: line types + line storage

Goal: Projects can store line types and per-map line features, with defaults seeded for new projects and backfilled for existing ones.

Acceptance criteria:

  • app/lib/line.ts exports LineTypeDefinition, LineType, LineFeature, UNKNOWN_LINE_TYPE, and DEFAULT_LINE_TYPES, per the shapes above.
  • app/lib/projects.server.ts's MapProject gains lineTypes: LineTypeDefinition[]; MapRef gains lines: LineFeature[].
  • createProject sets lineTypes: DEFAULT_LINE_TYPES; addMap sets lines: [].
  • readProjectsFile backfills project.lineTypes and map.lines in-memory when absent, so every caller can assume both are always populated.
  • addLine/updateLine/removeLine and addLineType/updateLineType/removeLineType are exported with the signatures above; addLine/addLineType reject invalid input (fewer than 2 points; a duplicate derived slug) via a returned { error: string } or undefined, without writing.
  • data/projects.json's existing entries remain valid and load with populated lineTypes/lines, with no manual edits to the file.
  • pnpm typecheck passes.

L2 — Project settings: line type CRUD

Goal: A GM can view, add, edit, and delete a project's line types from project settings.

Acceptance criteria:

  • project.tsx's action gains "add-line-type", "update-line-type", and "delete-line-type" intent branches, calling the L1 functions and returning { ok: true } / { error }, leaving all existing intents unchanged.
  • A new "Line Types" card lists every entry in project.lineTypes (color swatch, name, a live "used by N lines" count across the project's maps), with the same add/edit/delete-without-confirmation interaction already used by the POI Types card.
  • Deleting a line type in use is allowed unconditionally (the usage count is the only warning, same as POI types).
  • pnpm typecheck passes.
  • Manual check: pnpm dev, add a line type, edit its color, delete one that's in use (added in L4), confirm the count was visible beforehand.

L3 — Route wiring: loader + action for lines

Goal: project-map.tsx exposes the map's lines and line types to the client and accepts add/update/remove submissions.

Acceptance criteria:

  • The loader returns lines: map.lines alongside the existing pois, imageOverlays, etc. (project.lineTypes is already available via the existing project field).
  • The action's intent dispatch gains:
  • "add-line" — reads name, description, type, and a points field (JSON-encoded array of { lat, lng }); returns { error: "Name is required." } / { error: "Invalid type." } / { error: "A line needs at least 2 points." } as appropriate, without writing on any of them.
  • "update-line" — reads lineId, name, description, type (points aren't editable post-creation, per Scope).
  • "remove-line" — reads lineId.
  • Each succeeds with a plain { ok: true } (no redirect(), consistent with every other intent on this route).
  • pnpm typecheck passes.

L4 — Drawing: place vertices, finish, render saved lines

Goal: From edit mode, a GM can draw a line vertex-by-vertex and finish it, and every saved line renders on the map in both view and edit mode.

Acceptance criteria:

  • The edit-mode sidebar's existing "Routes" heading gains an "Add line" toggle button, following the same on/off styling as "Add point of interest."
  • While drawing, each map click (that isn't a hit on the current last vertex) appends a point to the in-progress line and renders it as a dashed/preview Polyline with a CircleMarker at each vertex.
  • Clicking the current last-placed vertex again finishes the line: with fewer than 2 points this is a no-op (drawing stays armed); with 2+ points it opens the "Add line" form in the sidebar (name, description, a type Select sourced from project.lineTypes), submitted via add-line.
  • On successful save, drawing mode disarms and the in-progress preview clears (same auto-disarm behavior as POI placement).
  • Every map.lines entry renders as an interactive Polyline styled by its type's color (falling back to UNKNOWN_LINE_TYPE's color if its type doesn't resolve), visible in both view and edit mode, unaffected by the mode toggle.
  • pnpm typecheck passes.
  • Manual check: pnpm dev, draw a 3-point line, confirm it renders after save and survives a page reload.

L5 — Select, edit, and delete a saved line

Goal: A GM can click a saved line to rename it, change its type, edit its description, or delete it — the same sidebar-driven flow as POIs.

Acceptance criteria:

  • Clicking a rendered line (in edit mode) opens an edit form in the sidebar (mirroring PoiForm/editingPoiId), pre-filled with its name, description, and type, submitted via update-line; a "Delete" button submits remove-line with no confirmation dialog.
  • Clicking a rendered line in view mode opens the same read-only detail panel treatment POIs get (name, type name — "Unknown" if unresolved, description if present).
  • Selecting a line and selecting a POI/dataset feature are mutually exclusive in the sidebar (same "only one detail slot at a time" rule already enforced for POIs vs. dataset features).
  • pnpm typecheck passes.
  • Manual check: pnpm dev, rename a line, delete a line, confirm both persist after reload.

Part 2: Area (polygon) features

Depends on Part 1 shipping — reuses its multi-vertex drawing interaction, typed-registry pattern, and sidebar wiring conventions.

Goal

Let a GM draw a closed-shape area on a map (forest, lake, region/border, etc.), assign it a project-level area type (name + color), and have it persist and render as a filled polygon.

Decisions

Decision Choice Why
New lib module app/lib/area.ts, mirroring app/lib/line.ts Same registry-plus-feature shape as lines
Type registry shape AreaTypeDefinition = { slug, name, color } — one color, used for both stroke and a fixed semi-transparent fill (fillOpacity constant, not per-type) Keeps the registry as lean as lines'/POIs'; a separate fill/stroke/opacity trio per type is more knobs than this app's "lean" bar asks for
Default area types Forest (#22c55e green), Lake (#3b82f6 blue), Region (#8b5cf6 violet) Common RPG-map area kinds, distinct from default line/POI colors
Geometry storage points: { lat: number; lng: number }[], minimum 3, not explicitly closed (first/last point not duplicated) react-leaflet's Polygon closes the ring automatically; matches LineFeature.points' shape for consistency
Drawing interaction Click to place each vertex (as with lines); clicking the first-placed vertex again (once ≥3 points exist) closes the loop and finishes the area Direct extension of the line convention ("click an already-placed vertex to finish") — closing a loop naturally means reconnecting to the start rather than the end. Inferred, not separately confirmed with the user — reconfirm this specific gesture before or during implementation.
Everything else (reshaping, visibility, mutation transport, editing surface, migration) Same decisions as Part 1, applied to areas No new product decision needed — areas are geometrically different from lines, not behaviorally different

Scope

In:

  • app/lib/area.ts (new) — AreaTypeDefinition, AreaType, UNKNOWN_AREA_TYPE, DEFAULT_AREA_TYPES, AreaFeature
  • app/lib/projects.server.ts — areaTypes on MapProject, areas on MapRef, addArea/updateArea/removeArea, addAreaType/updateAreaType/removeAreaType, migration backfill, addMap/createProject initialization
  • app/routes/project.tsx — an "Area Types" settings card, action gains the three new intent branches
  • app/routes/project-map.tsx — loader returns areas; action gains add-area/update-area/remove-area; sidebar gains a new "Areas" section (drawing state, AreaForm, select/edit/delete wiring)
  • app/components/map.tsx — an AreaLayer (draft-drawing + finished-area rendering), using react-leaflet's Polygon (not yet imported in this file — add to the existing react-leaflet import) and CircleMarker for per-vertex hit targets

Out:

  • Vertex reshaping/dragging after an area is saved
  • GM/player visibility toggling
  • Self-intersecting geometry or holes (a simple closed ring only)
  • Routes/pathfinding across areas

Data model

// app/lib/area.ts
export type AreaType = string // an AreaTypeDefinition's slug

export type AreaTypeDefinition = {
  slug: string
  name: string
  color: string // one of POI_TYPE_COLORS, reused from poi.ts
}

export const UNKNOWN_AREA_TYPE: { color: string } = { color: "#94a3b8" }

export const DEFAULT_AREA_TYPES: AreaTypeDefinition[] = [
  { slug: "forest", name: "Forest", color: "#22c55e" },
  { slug: "lake", name: "Lake", color: "#3b82f6" },
  { slug: "region", name: "Region", color: "#8b5cf6" },
]

export type AreaFeature = {
  id: string
  name: string
  description?: string
  type: AreaType
  points: { lat: number; lng: number }[] // >= 3, ring not explicitly closed
  createdAt: string
}
// app/lib/projects.server.ts — additions
export type MapRef = {
  // ...existing fields (including lines, from Part 1)
  areas: AreaFeature[]
}

export type MapProject = {
  // ...existing fields (including lineTypes, from Part 1)
  areaTypes: AreaTypeDefinition[]
}

export function addArea(
  projectId: string,
  mapId: string,
  input: {
    name: string
    description?: string
    type: AreaType
    points: { lat: number; lng: number }[]
  }
): AreaFeature | undefined

export function updateArea(
  projectId: string,
  mapId: string,
  areaId: string,
  input: { name: string; description?: string; type: AreaType }
): AreaFeature | undefined

export function removeArea(
  projectId: string,
  mapId: string,
  areaId: string
): boolean

export function addAreaType(
  projectId: string,
  input: { name: string; color: string }
): AreaTypeDefinition | { error: string } | undefined

export function updateAreaType(
  projectId: string,
  slug: string,
  input: { name: string; color: string }
): AreaTypeDefinition | undefined

export function removeAreaType(projectId: string, slug: string): boolean
  • Same not-found convention, same in-memory readProjectsFile backfill pattern, same "deletion never touches existing features, orphaned type falls back to UNKNOWN_AREA_TYPE" rule as Part 1.

Why this shape

  • A single color per area type (not separate stroke/fill/opacity fields) keeps the settings form identical in structure to the line/POI type forms — one swatch picker, one name field — rather than introducing a fill-specific control this app has no other precedent for.
  • Closing the loop on the first vertex (not a separate "Finish" button) keeps the interaction consistent with lines: in both cases, "click an already-placed vertex" is the one gesture that ends drawing, it's just the other end of the shape for a closed ring.
  • Polygon needs adding to the existing react-leaflet import in map.tsx rather than a new dependency — react-leaflet already ships it alongside Polyline.

Tasks

Ordered so each task type-checks and is shippable on its own; depends on all of Part 1 (L1–L5) shipping first.

A1 — Data model: area types + area storage

Goal: Projects can store area types and per-map area features, with defaults seeded for new projects and backfilled for existing ones.

Acceptance criteria:

  • app/lib/area.ts exports AreaTypeDefinition, AreaType, AreaFeature, UNKNOWN_AREA_TYPE, and DEFAULT_AREA_TYPES, per the shapes above.
  • MapProject gains areaTypes: AreaTypeDefinition[]; MapRef gains areas: AreaFeature[].
  • createProject seeds areaTypes: DEFAULT_AREA_TYPES; addMap sets areas: [].
  • readProjectsFile backfills project.areaTypes and map.areas in-memory when absent.
  • addArea/updateArea/removeArea and addAreaType/updateAreaType/removeAreaType are exported with the signatures above; addArea/addAreaType reject invalid input (fewer than 3 points; a duplicate derived slug).
  • data/projects.json's existing entries remain valid with no manual edits.
  • pnpm typecheck passes.

A2 — Project settings: area type CRUD

Goal: A GM can view, add, edit, and delete a project's area types from project settings.

Acceptance criteria:

  • project.tsx's action gains "add-area-type", "update-area-type", and "delete-area-type" intent branches.
  • A new "Area Types" card mirrors the "Line Types" card from L2 (swatch, name, live "used by N areas" count, unconfirmed delete).
  • pnpm typecheck passes.
  • Manual check: pnpm dev, add/edit/delete an area type.

A3 — Route wiring: loader + action for areas

Goal: project-map.tsx exposes the map's areas and area types to the client and accepts add/update/remove submissions.

Acceptance criteria:

  • The loader returns areas: map.areas alongside lines, pois, etc.
  • The action's intent dispatch gains "add-area" (name, description, type, JSON-encoded points; rejects fewer than 3 points with { error: "An area needs at least 3 points." }), "update-area" (areaId, name, description, type), and "remove-area" (areaId) — same validation and { ok: true } conventions as L3.
  • pnpm typecheck passes.

A4 — Drawing: place vertices, close the loop, render saved areas

Goal: From edit mode, a GM can draw an area vertex-by-vertex, close it, and every saved area renders as a filled polygon in both view and edit mode.

Acceptance criteria:

  • The edit-mode sidebar gains an "Areas" section with an "Add area" toggle, same styling/placement convention as "Add line."
  • While drawing, each map click appends a point and renders a live preview (an open Polyline while under 3 points, a translucent Polygon preview once 3+ points exist), with a CircleMarker at each vertex; the first-placed vertex is visually distinguishable once 3+ points exist (so the GM can see where clicking would close the loop).
  • Clicking the first-placed vertex again (once ≥3 points exist) finishes the area and opens the "Add area" form in the sidebar, submitted via add-area; on success, drawing mode disarms and the preview clears.
  • Every map.areas entry renders as an interactive, filled Polygon styled by its type's color (fixed fill opacity constant; fallback to UNKNOWN_AREA_TYPE's color), visible in both view and edit mode.
  • pnpm typecheck passes.
  • Manual check: pnpm dev, draw a 4-point area, confirm it renders filled after save and survives a page reload.

A5 — Select, edit, and delete a saved area

Goal: A GM can click a saved area to rename it, change its type, edit its description, or delete it — the same sidebar-driven flow as lines/POIs.

Acceptance criteria:

  • Clicking a rendered area (in edit mode) opens an edit form in the sidebar, pre-filled with name/description/type, submitted via update-area; a "Delete" button submits remove-area with no confirmation.
  • Clicking a rendered area in view mode opens the same read-only detail panel treatment as lines/POIs.
  • Selecting an area is mutually exclusive with selecting a line, POI, or dataset feature in the sidebar (only one detail slot at a time).
  • pnpm typecheck passes.
  • Manual check: pnpm dev, rename an area, delete an area, confirm both persist after reload.