Skip to content

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 avesmaps for now)
  • Map view renders the generalized Map component 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

  • Map becomes generic: it takes a TileSet config as a prop instead of the hardcoded avesmaps-specific constants and AvesTileLayer class. 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/write data/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.ts exports a TileSet type (id, label, path, tileSize, minZoom, maxZoom, maxNativeZoom) and a registry array/map containing one entry for avesmaps with today's values (256, 2, 10, 7, /tiles/avesmaps).
  • A getTileSet(id) (or equivalent) lookup helper exists and returns undefined for an unknown id.

T2 — Generalize the Map component

Goal: Render any tile set, not just avesmaps.

Acceptance criteria:

  • Map accepts a tileSet: TileSet prop; AvesTileLayer is replaced by a generic tile layer class parameterized by tileSet.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 avesmaps tile 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.ts defines MapProject / MapRef types and functions: listProjects(), getProject(id), createProject({name, description}), addMap(projectId, {name, tileSetId}).
  • data/projects.json is created with { "projects": [] } if missing on first read.
  • Each create/add generates an id (crypto.randomUUID()) and createdAt timestamp, and persists by rewriting the JSON file.
  • getProject returns undefined for an unknown id (no throw).

T4 — Routing skeleton

Goal: Register the new routes so all pages are reachable.

Acceptance criteria:

  • app/routes.ts adds create, project/:projectId, and project/:projectId/map/:mapId, alongside the existing index and health routes.
  • Each route points at a new (initially minimal/placeholder) route module under app/routes/.
  • pnpm typecheck passes with the new route types generated.

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:

  • /create renders a form with name (required) and description (optional) fields.
  • Submitting calls createProject via an action; a missing name re-renders the form with a validation error and no project is created.
  • On success, the action redirects to /project/:projectId for 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 Router data/throw with status 404) if the project doesn't exist.
  • Project name/description are displayed.
  • Each map in project.maps is 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 name field and a tileSetId select populated from the tile set registry (T1).
  • Submitting calls addMap via an action; missing name or an unknown tileSetId re-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 Map component (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 Map rendering on / (from v0.0.1) is fully replaced by the project list (T5); the Map component 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.