Map projects (create & load)¶
Goal¶
Turn the single hardcoded map into a real (if minimal) tool: users can create map projects, add maps to them backed by existing tile sets, and open a map to view it — all without a database, auth, or uploads yet.
Scope¶
In:
- Home route lists existing projects and lets you create a new one
- Create a project (name + description)
- Project overview lists its maps and lets you add a new one by picking a
name and an existing tile set (only
avesmapsfor now) - Map view renders the generalized
Mapcomponent for the selected map - Persistence via a single JSON file on disk (
data/projects.json), read/written through React Router loaders/actions
Out (later versions, already tracked in backlog.md):
- Uploading images / splitting them into tiles
- Rescaling images
- Submaps
- Points of interest, routes on maps
- Editing/deleting projects or maps
- Auth / multi-user
- Any database
Data model¶
Kept intentionally flat — one JSON file, no relations beyond nesting maps under their project.
// data/projects.json
type MapProjectsFile = {
projects: MapProject[]
}
type MapProject = {
id: string
name: string
description?: string
createdAt: string
maps: MapRef[]
}
type MapRef = {
id: string
name: string
tileSetId: string // references a TileSet below
createdAt: string
}
Tile sets are a small static registry (not user-editable yet), since we only generate tile sets ourselves and always in the same layout:
// app/lib/tile-sets.ts
type TileSet = {
id: string // e.g. "avesmaps"
label: string // e.g. "Aves Maps"
path: string // e.g. "/tiles/avesmaps"
tileSize: number
minZoom: number
maxZoom: number
maxNativeZoom: number
}
This separates "which image is this" (project data) from "how do I render these tiles" (tile set config), so adding a new tile set later is just a new registry entry — no schema change.
Routes¶
Matches backlog.md:
| Path | Purpose |
|---|---|
/ |
List projects, link to open or create one |
/create |
Form to create a new project |
/project/:projectId |
Project overview: list maps, add a map |
/project/:projectId/map/:mapId |
Full-screen map view |
Component changes¶
Mapbecomes generic: it takes aTileSetconfig as a prop instead of the hardcodedavesmaps-specific constants andAvesTileLayerclass. The tile addressing scheme (map_{x}_{y}.webp, y flipped from the grid size) stays as the one scheme all our own tile sets use — no need to support other schemes yet.- New server-only module (e.g.
app/lib/projects.server.ts) with plain functions to read/writedata/projects.json(list, get by id, create project, add map). No caching, no locking — fine for single-user local use.
Why this shape¶
- No DB/auth work needed to get "create and load projects" actually usable.
- Reusing existing tiles (rather than upload/splitting) keeps this version small while still exercising the full project → map → view flow.
- The tile set registry keeps the door open for more tile sets and, later, user uploads, without reshaping project data again.
Tasks¶
Ordered so each task can be built and verified on its own; later tasks depend on earlier ones.
T1 — Tile set registry¶
Goal: Describe tile sets as data instead of hardcoding one in the Map
component.
Acceptance criteria:
app/lib/tile-sets.tsexports aTileSettype (id,label,path,tileSize,minZoom,maxZoom,maxNativeZoom) and a registry array/map containing one entry foravesmapswith today's values (256, 2, 10, 7,/tiles/avesmaps).- A
getTileSet(id)(or equivalent) lookup helper exists and returnsundefinedfor an unknown id.
T2 — Generalize the Map component¶
Goal: Render any tile set, not just avesmaps.
Acceptance criteria:
Mapaccepts atileSet: TileSetprop;AvesTileLayeris replaced by a generic tile layer class parameterized bytileSet.path.- Tile URL scheme (
{path}/{z-minZoom}/map_{x}_{y}.webp, y flipped by grid size) is unchanged in behavior. - Home route still renders the
avesmapstile set exactly as before (visual no-op) by passing that tile set explicitly.
T3 — Project data layer¶
Goal: Read/write data/projects.json from server code.
Acceptance criteria:
app/lib/projects.server.tsdefinesMapProject/MapReftypes and functions:listProjects(),getProject(id),createProject({name, description}),addMap(projectId, {name, tileSetId}).data/projects.jsonis created with{ "projects": [] }if missing on first read.- Each create/add generates an
id(crypto.randomUUID()) andcreatedAttimestamp, and persists by rewriting the JSON file. getProjectreturnsundefinedfor an unknown id (no throw).
T4 — Routing skeleton¶
Goal: Register the new routes so all pages are reachable.
Acceptance criteria:
app/routes.tsaddscreate,project/:projectId, andproject/:projectId/map/:mapId, alongside the existingindexandhealthroutes.- Each route points at a new (initially minimal/placeholder) route module
under
app/routes/. pnpm typecheckpasses with the new route types generated.
T5 — Home route: list + link to create¶
Goal: / shows existing projects instead of always rendering a map.
Acceptance criteria:
- Loader reads projects via
listProjects(). - Each project is rendered with its name and a link to
/project/:projectId. - An empty state is shown when there are no projects yet.
- A visible link/button navigates to
/create.
T6 — Create project route¶
Goal: Let a user create a new project via a form.
Acceptance criteria:
/createrenders a form withname(required) anddescription(optional) fields.- Submitting calls
createProjectvia an action; a missingnamere-renders the form with a validation error and no project is created. - On success, the action redirects to
/project/:projectIdfor the new project.
T7 — Project overview route: list maps¶
Goal: /project/:projectId shows a project's maps.
Acceptance criteria:
- Loader calls
getProject(projectId); responds with a 404 (React Routerdata/throwwith status 404) if the project doesn't exist. - Project name/description are displayed.
- Each map in
project.mapsis listed with a link to/project/:projectId/map/:mapId. - An empty state is shown when the project has no maps yet.
T8 — Add map to project¶
Goal: Let a user add a map to an existing project.
Acceptance criteria:
- The project overview route has a form with a
namefield and atileSetIdselect populated from the tile set registry (T1). - Submitting calls
addMapvia an action; missingnameor an unknowntileSetIdre-renders the form with a validation error and no map is added. - On success, the new map appears in the maps list without a full page reload (revalidated loader data).
T9 — Map view route¶
Goal: /project/:projectId/map/:mapId renders the selected map
full-screen.
Acceptance criteria:
- Loader resolves the project, the map within it, and the map's tile set; responds with a 404 if the project, map, or referenced tile set is missing.
- The page renders the generalized
Mapcomponent (T2) with the resolved tile set, matching current full-screen behavior. - A small header/back-link shows the project and map name and links back to
/project/:projectId.
T10 — Wire up / as the real entry point¶
Goal: Remove the old always-show-a-map behavior now that projects exist.
Acceptance criteria:
- The former direct
Maprendering on/(from v0.0.1) is fully replaced by the project list (T5); theMapcomponent is no longer imported directly in the home route. - Manually walking through create → add map → open map → back to project → back to home works end-to-end with no console errors.