Skip to content

Upload-backed maps

Goal

Let a user add a map by uploading their own image, in addition to picking from the existing tile-set registry, and move the "add map" form off the project overview onto its own route.

Scope

In:

  • Project overview shows an "Add map" button instead of an inline form
  • A new /project/:projectId/map/new route hosts the add-map form
  • The form offers both existing options (unchanged): pick a name + an existing tile set, or pick a name + upload an image (PNG/JPEG/WebP, ≤20MB) — exactly one of the two
  • Uploaded images are stored under public/uploads/<uploadId>/image.<ext> and rendered as a flat, single-zoom Leaflet image overlay (no tile pyramid)

Out (later, already tracked in backlog.md or noted below):

  • Splitting an uploaded image into a tile pyramid ("split image into tiles")
  • Rescaling images, submaps, points of interest, routes
  • Editing, replacing, or deleting an uploaded image or a map
  • Any database or auth

Known limitation (accepted, not fixed by this version): the Dockerfile only copies the built build/ output into the runtime image, the same way data/projects.json already doesn't survive a redeploy today. Uploads written to public/uploads at request time have the same limitation — fine for this single-instance local tool, not fine for a multi-replica deployment.

Data model

MapRef moves from a bare tileSetId to a small discriminated union so a map's source is either a registry tile set or an uploaded image:

// app/lib/projects.server.ts
type MapSource =
  | { kind: "tileSet"; tileSetId: string }
  | { kind: "upload"; path: string } // public path, e.g. "/uploads/<uploadId>/image.png"

type MapRef = {
  id: string
  name: string
  source: MapSource
  createdAt: string
}

No width/height is persisted for uploads — the client reads the image's natural dimensions when it renders the overlay, so the server never needs to decode the image.

data/projects.json's three existing maps (tileSetId: "avesmaps" / "st1" / "st2") are hand-edited to the new { "source": { "kind": "tileSet", "tileSetId": "..." } } shape as part of this work — this is fixture data, not a live migration.

Routes

Path Purpose
/project/:projectId Project overview: list maps, link to add a map
/project/:projectId/map/new New. Add-map form (tile set or upload)
/project/:projectId/map/:mapId Map view — resolves either source kind

Component changes

  • Map (app/components/map.tsx) takes a discriminated source prop instead of a bare tileSet: { kind: "tileSet"; tileSet: TileSet } | { kind: "upload"; path: string }. The existing TileSetLayer effect is unchanged for the tileSet case. A new sibling layer component handles the upload case: it loads the image client-side to read its natural pixel size, then uses the standard Leaflet CRS.Simple flat-image recipe (L.imageOverlay(path, [[0, 0], [height, width]]) + fitBounds) instead of tile addressing.
  • New app/lib/uploads.server.ts holds the upload-specific filesystem logic (validate + save an uploaded File, return its public path), kept separate from projects.server.ts's project/map CRUD.
  • app/lib/projects.server.ts: MapRef/MapSource types as above; addMap(projectId, { name, source }) takes a MapSource instead of a bare tileSetId.

Why this shape

  • Keeping the upload as a MapSource variant (rather than a parallel field on MapRef) means every read site has to handle both cases explicitly — no silent "which one is set" guessing.
  • Resolving image dimensions client-side avoids adding an image-decoding dependency for something the browser already has to load anyway.
  • Splitting the route-move from the upload support means the repo type-checks and is shippable after each task, not just at the end.

Tasks

Ordered so each task can be built and verified on its own; later tasks depend on earlier ones.

T1 — Add-map route (mechanical move)

Goal: Move today's add-map form/action to its own route with no behavior change.

Acceptance criteria:

  • app/routes.ts adds project/:projectId/map/new pointing at a new route module (e.g. app/routes/add-map.tsx).
  • The new route's loader resolves the project (404 if missing) and returns { project, tileSets: listTileSets() }.
  • The new route's action performs the same validation as today's project.tsx action (name required, tileSetId must resolve via getTileSet) and calls addMap unchanged; on success it redirects to /project/:projectId; on failure it re-renders with the existing error message.
  • app/routes/project.tsx drops the inline form and its action entirely; its loader no longer returns tileSets.
  • project.tsx renders an "Add map" button/link to /project/:projectId/map/new (same Button render={<Link ... />} pattern as "New project" on the home route).
  • pnpm typecheck passes.

T2 — MapSource data model

Goal: Represent a map's source as a tile set or an upload, without committing to upload mechanics yet.

Acceptance criteria:

  • app/lib/projects.server.ts exports MapSource and updates MapRef as shown above; addMap(projectId, { name, source }) replaces the tileSetId-only signature.
  • add-map.tsx's action (from T1) is updated to call addMap(projectId, { name, source: { kind: "tileSet", tileSetId } }).
  • data/projects.json's three existing maps are hand-edited to the new source shape so existing fixture data keeps working.
  • pnpm typecheck passes; loading /project/:projectId and the existing map views still works exactly as before (visual no-op).

T3 — Upload validation and storage

Goal: Accept and persist an uploaded image file, independent of the form UI.

Acceptance criteria:

  • app/lib/uploads.server.ts exports a function that takes a File, validates it, and on success writes it to public/uploads/<uploadId>/image.<ext> (a fresh randomUUID() per upload, independent of the map's own id) and returns the public path (/uploads/<uploadId>/image.<ext>).
  • Validation rejects anything whose type is not exactly image/png, image/jpeg, or image/webp, and anything over 20MB, without writing a file; the extension used on disk is derived from the validated MIME type, not from the client-supplied filename (avoids path traversal / unsafe characters).
  • public/uploads/ is created if missing.

T4 — Add-map form: upload option

Goal: Let the add-map form accept either a tile set or an uploaded image.

Acceptance criteria:

  • add-map.tsx's form gains a file input (name="image") alongside the existing tile-set <select>; the <Form> sets encType="multipart/form-data".
  • The tile-set <select> gains a blank/"none" option so a submission can omit a tile set choice.
  • The action reads the file via request.formData() (native File, no extra multipart-parsing dependency needed); if both a tile set and a file are provided, or neither is, it re-renders the form with a validation error and does not call addMap or write a file.
  • On a valid upload, the action calls the T3 save function, then addMap(projectId, { name, source: { kind: "upload", path } }), then redirects to /project/:projectId — matching the existing tile-set success path.
  • On a save/validation failure (bad type, too large), the form re-renders with an error message and no map is created.

T5 — Map view resolves either source

Goal: /project/:projectId/map/:mapId loads correctly for both source kinds.

Acceptance criteria:

  • app/routes/project-map.tsx's loader switches on map.source.kind: for "tileSet" it resolves getTileSet(tileSetId) and 404s if the id doesn't resolve (unchanged behavior); for "upload" it passes the stored path straight through, no extra resolution.
  • The loader's returned shape carries the same discriminant through to the component (e.g. { project, map, source: { kind, tileSet } | { kind, path } }).
  • pnpm typecheck passes.

T6 — Flat image overlay rendering

Goal: An uploaded-image map renders as a pannable/zoomable flat image.

Acceptance criteria:

  • app/components/map.tsx's Map component takes the discriminated source prop described above instead of a bare tileSet.
  • For kind: "tileSet", rendering is visually unchanged from today.
  • For kind: "upload", the component loads the image client-side to read its natural width/height, then renders it via L.imageOverlay with CRS.Simple bounds [[0, 0], [height, width]], and fits the map view to those bounds on mount.
  • project-map.tsx passes the resolved source from T5 through to Map.
  • Manual check: creating a map from an uploaded image and opening it shows the image, pannable and zoomable, with no console errors; existing tile-set-backed maps still render as before.