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 —
lineTypesonMapProject,linesonMapRef,addLine/updateLine/removeLine,addLineType/updateLineType/removeLineType, migration backfill,addMap/createProjectinitialization - app/routes/project.tsx — a "Line Types" settings card (list + add/edit/delete), action gains the three new
intentbranches - app/routes/project-map.tsx — loader returns
lines; action gainsadd-line/update-line/remove-line; sidebar gains drawing state, aLineForm, and select/edit/delete wiring - app/components/map.tsx — a
LineLayer(draft-drawing + finished-line rendering), usingPolyline(already imported) andCircleMarker(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(orfalsefor the boolean remove) when an id doesn't resolve. readProjectsFilebackfillsproject.lineTypestoDEFAULT_LINE_TYPESandmap.linesto[]in-memory, mirroring the existingpoiTypes/poisbackfill a few lines above.addMapinitializeslines: [];createProjectseedslineTypes: DEFAULT_LINE_TYPES.- Deleting a line type never touches existing lines — an orphaned
typeslug falls back toUNKNOWN_LINE_TYPEat render time, exactly likeUNKNOWN_POI_TYPE.
Why this shape¶
- A new
line.ts(not folding lines intopoi.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_COLORSinstead of a second hardcoded palette avoids two lists of the same 10 hex values silently drifting apart. - Vertex placement reuses
CircleMarker(already imported inmap.tsxfor 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
intentvalues on the sameproject-map.tsxaction (rather than a new route) follow the exact dispatch pattern already used to add POI type-independent behavior inproject.tsx's action — one action, oneintent-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.tsexportsLineTypeDefinition,LineType,LineFeature,UNKNOWN_LINE_TYPE, andDEFAULT_LINE_TYPES, per the shapes above.app/lib/projects.server.ts'sMapProjectgainslineTypes: LineTypeDefinition[];MapRefgainslines: LineFeature[].createProjectsetslineTypes: DEFAULT_LINE_TYPES;addMapsetslines: [].readProjectsFilebackfillsproject.lineTypesandmap.linesin-memory when absent, so every caller can assume both are always populated.addLine/updateLine/removeLineandaddLineType/updateLineType/removeLineTypeare exported with the signatures above;addLine/addLineTypereject invalid input (fewer than 2 points; a duplicate derived slug) via a returned{ error: string }orundefined, without writing.data/projects.json's existing entries remain valid and load with populatedlineTypes/lines, with no manual edits to the file.pnpm typecheckpasses.
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 typecheckpasses.- 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.linesalongside the existingpois,imageOverlays, etc. (project.lineTypesis already available via the existingprojectfield). - The action's
intentdispatch gains: "add-line"— readsname,description,type, and apointsfield (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"— readslineId,name,description,type(points aren't editable post-creation, per Scope)."remove-line"— readslineId.- Each succeeds with a plain
{ ok: true }(noredirect(), consistent with every other intent on this route). pnpm typecheckpasses.
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
Polylinewith aCircleMarkerat 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
typeSelectsourced fromproject.lineTypes), submitted viaadd-line. - On successful save, drawing mode disarms and the in-progress preview clears (same auto-disarm behavior as POI placement).
- Every
map.linesentry renders as an interactivePolylinestyled by its type's color (falling back toUNKNOWN_LINE_TYPE's color if itstypedoesn't resolve), visible in both view and edit mode, unaffected by themodetoggle. pnpm typecheckpasses.- 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 viaupdate-line; a "Delete" button submitsremove-linewith 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 typecheckpasses.- 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 —
areaTypesonMapProject,areasonMapRef,addArea/updateArea/removeArea,addAreaType/updateAreaType/removeAreaType, migration backfill,addMap/createProjectinitialization - app/routes/project.tsx — an "Area Types" settings card, action gains the three new
intentbranches - app/routes/project-map.tsx — loader returns
areas; action gainsadd-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'sPolygon(not yet imported in this file — add to the existingreact-leafletimport) andCircleMarkerfor 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
readProjectsFilebackfill pattern, same "deletion never touches existing features, orphanedtypefalls back toUNKNOWN_AREA_TYPE" rule as Part 1.
Why this shape¶
- A single
colorper 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.
Polygonneeds adding to the existingreact-leafletimport inmap.tsxrather than a new dependency — react-leaflet already ships it alongsidePolyline.
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.tsexportsAreaTypeDefinition,AreaType,AreaFeature,UNKNOWN_AREA_TYPE, andDEFAULT_AREA_TYPES, per the shapes above.MapProjectgainsareaTypes: AreaTypeDefinition[];MapRefgainsareas: AreaFeature[].createProjectseedsareaTypes: DEFAULT_AREA_TYPES;addMapsetsareas: [].readProjectsFilebackfillsproject.areaTypesandmap.areasin-memory when absent.addArea/updateArea/removeAreaandaddAreaType/updateAreaType/removeAreaTypeare exported with the signatures above;addArea/addAreaTypereject invalid input (fewer than 3 points; a duplicate derived slug).data/projects.json's existing entries remain valid with no manual edits.pnpm typecheckpasses.
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 typecheckpasses.- 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.areasalongsidelines,pois, etc. - The action's
intentdispatch gains"add-area"(name, description, type, JSON-encodedpoints; 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 typecheckpasses.
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
Polylinewhile under 3 points, a translucentPolygonpreview once 3+ points exist), with aCircleMarkerat 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.areasentry renders as an interactive, filledPolygonstyled by its type's color (fixed fill opacity constant; fallback toUNKNOWN_AREA_TYPE's color), visible in both view and edit mode. pnpm typecheckpasses.- 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 submitsremove-areawith 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 typecheckpasses.- Manual check:
pnpm dev, rename an area, delete an area, confirm both persist after reload.