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/newroute 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 discriminatedsourceprop instead of a baretileSet:{ kind: "tileSet"; tileSet: TileSet } | { kind: "upload"; path: string }. The existingTileSetLayereffect is unchanged for thetileSetcase. A new sibling layer component handles theuploadcase: it loads the image client-side to read its natural pixel size, then uses the standard LeafletCRS.Simpleflat-image recipe (L.imageOverlay(path, [[0, 0], [height, width]])+fitBounds) instead of tile addressing.- New
app/lib/uploads.server.tsholds the upload-specific filesystem logic (validate + save an uploadedFile, return its public path), kept separate fromprojects.server.ts's project/map CRUD. app/lib/projects.server.ts:MapRef/MapSourcetypes as above;addMap(projectId, { name, source })takes aMapSourceinstead of a baretileSetId.
Why this shape¶
- Keeping the upload as a
MapSourcevariant (rather than a parallel field onMapRef) 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.tsaddsproject/:projectId/map/newpointing 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.tsxaction (namerequired,tileSetIdmust resolve viagetTileSet) and callsaddMapunchanged; on success it redirects to/project/:projectId; on failure it re-renders with the existing error message. app/routes/project.tsxdrops the inline form and its action entirely; its loader no longer returnstileSets.project.tsxrenders an "Add map" button/link to/project/:projectId/map/new(sameButton render={<Link ... />}pattern as "New project" on the home route).pnpm typecheckpasses.
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.tsexportsMapSourceand updatesMapRefas shown above;addMap(projectId, { name, source })replaces thetileSetId-only signature.add-map.tsx's action (from T1) is updated to calladdMap(projectId, { name, source: { kind: "tileSet", tileSetId } }).data/projects.json's three existing maps are hand-edited to the newsourceshape so existing fixture data keeps working.pnpm typecheckpasses; loading/project/:projectIdand 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.tsexports a function that takes aFile, validates it, and on success writes it topublic/uploads/<uploadId>/image.<ext>(a freshrandomUUID()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, orimage/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>setsencType="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()(nativeFile, 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 calladdMapor 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 onmap.source.kind: for"tileSet"it resolvesgetTileSet(tileSetId)and 404s if the id doesn't resolve (unchanged behavior); for"upload"it passes the storedpathstraight 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 typecheckpasses.
T6 — Flat image overlay rendering¶
Goal: An uploaded-image map renders as a pannable/zoomable flat image.
Acceptance criteria:
app/components/map.tsx'sMapcomponent takes the discriminatedsourceprop described above instead of a baretileSet.- 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 viaL.imageOverlaywithCRS.Simplebounds[[0, 0], [height, width]], and fits the map view to those bounds on mount. project-map.tsxpasses the resolvedsourcefrom T5 through toMap.- 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.