Image overlay layers — implementation plan¶
Source idea (verbatim from the request):
for our aventurien-map, we want to overlay 4 images (from public/images/overlay-maps) that show different very detailed parts of the map (e.g. a city map of trallop and ferdok). to properly align them with our map, we need to be able to - scale the image - rotate the image - configure the minZoom for the image to appear
we aim for a lean implementation to quickly proof this approach (base tile map + detailed image layer maps on very high zoom).
Refined and scoped down (see prior clarification round) to: prove the
mechanism with one image (stadtplan_trallop.jpg) on the existing
Kontinent-Karte map, values set by hand-editing data/projects.json, no new
admin form. baustelle.jpg, grenze.jpg and stadtplan_ferdok.jpg are
deferred once the approach is proven.
Further refined (this round) to add two things needed to actually tune the
hand-edited values: a UI toggle to hide/show an overlay while comparing it
against the base map, and a persisted opacity value alongside scale,
rotation, and minZoom.
What's already there¶
data/projects.json→ projectaventurien→ mapKontinent-Karte(fdf566e4-9dbb-4677-b3ac-4a37b256990b),source.kind: "tileSet",tileSetId: "avesmaps".- app/lib/projects.server.ts's
MapRefhas a singlesource: MapSource("tileSet" | "upload", mutually exclusive) and apoisarray. There is no concept of a layer that sits on top of atileSetsource today. - app/components/map.tsx's
UploadImageLayeralready does the closest thing to what we need — it loads an image to get its natural size, builds anL.imageOverlayfrom[[0,0],[naturalHeight, naturalWidth]], and callsfitBounds. It has no scale, rotation, position-anchor, or zoom-gating; it's also only used for whole-mapkind: "upload"sources, never alongside a tile set. - Existing POI positions (
map.pois[].position, e.g. Greifenfurt{ lat: -109.696, lng: 126.953 }) are already plain LeafletCRS.Simplelat/lng units, independent of tile zoom level — the same coordinate system this task's overlay anchor will use, so no new coordinate transform is introduced. - Plain
L.ImageOverlayhas no rotation support, and_reset()(called on every pan/zoom) fully overwrites the image element's CSStransformwith its own translate/scale — so rotation can't be bolted on by mutating the element's style after the fact from outside the layer. The codebase already has precedent for subclassing a Leaflet layer to change its behavior (GridTileLayer extends L.TileLayer, overridinggetTileUrl); the same pattern (override the relevant protected method, callsuper, then extend what it set) is the intended shape for rotation here — see task 2. L.ImageOverlayalso has no built-inminZoom(that's aTileLayer-only option) — visibility has to be toggled manually on zoom change.L.ImageOverlaydoes have a built-inopacityoption — this one needs no custom handling beyond reading the field and passing it through.- There's already an established pattern for a view-only, per-map layer
toggle: app/components/avesmaps-layer-control.tsx's
AvesmapsLayerControl+useAvesmapsLayerSelection— a floating card toggled by a button, checkboxes per layer, selection persisted inlocalStoragekeyed by map id, explicitly not written intodata/projects.jsonbecause it's view state, not map data. The overlay visibility toggle follows the same shape.
Decisions taken¶
| Question | Decision |
|---|---|
| Which map | The existing Kontinent-Karte map (tileSetId: "avesmaps") in the aventurien project — no new map |
| Configuration method | Hand-edit data/projects.json; no new form, route, or upload flow |
| Position/anchor | In scope — each overlay gets a { lat, lng } center, same units as POI positions |
| Number of images wired up | One (stadtplan_trallop.jpg); the other 3 are deferred |
| Upper zoom bound | None — visible from the layer's minZoom up to the map's existing maxZoom |
| Rotation technique | Subclass L.ImageOverlay, override its reset hook to re-apply a CSS rotation after Leaflet repositions it (no new dependency) |
| Interactivity | Overlay is non-interactive (interactive: false), consistent with AvesmapsRouteLayer — it must not intercept POI-placement or map-deselect clicks |
| Visibility toggle | Client-side only, per overlay, persisted in localStorage — same pattern as AvesmapsLayerControl. Not written to data/projects.json; it's a positioning aid, not map data |
| Transparency | A persisted opacity (0–1) field on ImageOverlayLayer in data/projects.json, hand-edited like scale/rotation/minZoom — no slider or form in this task |
Prerequisites¶
None — this builds directly on the existing Kontinent-Karte map and the
public/images/overlay-maps/ files already in the repo.
1. Add the ImageOverlayLayer type and extend MapRef¶
Goal: A tileSet-sourced map can persist a list of image overlay layers,
each with an image path, a display label, center anchor, scale, rotation,
opacity, and minZoom.
Scope
- In: A new exported type in
app/lib/projects.server.ts, e.g.
shape
{ id: string; label: string; path: string; center: { lat: number; lng: number }; scale: number; rotation: number; opacity: number; minZoom: number }.labelis new versus the prior draft of this plan — the visibility toggle control (task 4) needs something human-readable to show per overlay, the same wayTileSetBase.labelnames a tile set. - In:
MapRefgains animageOverlays: ImageOverlayLayer[]field. - In:
readProjectsFilebackfillsimageOverlays: []for any map that doesn't have it yet, mirroring the existingpoisbackfill in the same function. - Out: No
add/update/removeserver functions for overlay layers (no UI will call them in this task) — entries are written directly intodata/projects.jsonin task 4. - Out: No changes to
MapSource— overlays are a property of the map, not a secondsource.
Acceptance criteria
-
ImageOverlayLayeris exported fromprojects.server.tsandMapRefincludesimageOverlays: ImageOverlayLayer[]. - Loading the existing
data/projects.json(which has noimageOverlayskey anywhere yet) does not throw, and every map'simageOverlaysresolves to[]afterreadProjectsFile. -
pnpm typecheckpasses.
Affected areas
- app/lib/projects.server.ts — type addition and backfill.
Open questions
- None.
2. Render image overlay layers on the map¶
Goal: A tileSet-sourced map renders each of its imageOverlays at the
configured position, scale, rotation, and opacity, hidden below its minZoom
and visible from there up to the map's maxZoom, and can be individually
hidden from the outside (task 4 drives this, this task just needs to accept
it).
Scope
- In: A new component in
app/components/map.tsx (e.g.
ImageOverlayLayerView, one instance perImageOverlayLayer), rendered alongsideTileSetLayerfortileSet.kind === "grid"maps. - In: Load the image to get
naturalWidth/naturalHeight(same technique asUploadImageLayer), then compute unrotatedL.latLngBoundscentered onlayer.center, sized bynaturalWidth * layer.scale/naturalHeight * layer.scale. - In: A small
L.ImageOverlaysubclass that re-applies a CSSrotate(...)transform (around the image's own center) after Leaflet's own reset, so rotation survives pan/zoom — following the existingGridTileLayersubclassing pattern in the same file. - In: Pass
layer.opacitystraight through as theL.ImageOverlayopacityoption — no custom handling needed, Leaflet supports this natively. - In: Manual show/hide keyed off the map's current zoom vs.
layer.minZoom(useMapEvent("zoomend", ...)), sinceL.ImageOverlayhas no nativeminZoomoption. - In: An extra
visible: booleanprop onImageOverlayLayerView(defaulttrue), ANDed with theminZoomcheck above, so task 4's toggle can hide a layer regardless of zoom. - In:
interactive: falseon the overlay so it never intercepts clicks meant forMapClickHandleror POI markers. - Out: Any change to the
kind: "upload"branch orUploadImageLayer— this is additive, for grid tile sets only. - Out: The toggle control itself and its state (task 4) — this task only
accepts the
visibleprop.
Acceptance criteria
- With a test entry in
imageOverlays(any image, any values), the image does not render below the configuredminZoomand appears once the map reaches that zoom, remaining visible up tomaxZoom. - Changing
scalechanges the rendered size; changingrotationvisibly rotates the image around its own center; changingopacitychanges how transparent it renders; all three survive panning and zooming without the image snapping back to unrotated/unscaled/opaque. - Setting
visible={false}hides the layer regardless of current zoom; setting it back totruerestores theminZoom-gated behavior. - Clicking on the overlay's rendered area does not place a POI and does not clear the current selection — the click passes through to the map exactly as it would over empty tile imagery.
- Existing behavior of
Kontinent-Karte(tiles, avesmaps dataset layers, POIs) is unchanged whenimageOverlaysis empty. -
pnpm typecheckpasses.
Affected areas
- app/components/map.tsx — new
component(s);
Map's grid-tileSet branch renders one per entry in a newimageOverlaysprop.
Open questions
- None.
3. Thread imageOverlays from data through to the map component¶
Goal: The overlay layers persisted on a map actually reach the rendered
<Map> component with no other behavior change.
Scope
- In: app/routes/project-map.tsx's
loader includes
map.imageOverlaysin its returned data (both thetileSetanduploadbranches can just pass through what's onmap, keeping theuploadbranch's array empty in practice since onlytileSet-sourced maps will have entries). - In: The route component passes
imageOverlaysthrough to<Map>. - Out: No action/intent for creating or editing overlays (task 1 already excludes server mutation functions).
- Out: The visibility-toggle state itself (task 4) — this task only makes the data reachable.
Acceptance criteria
- Opening
Kontinent-Karteafter task 5's data edit shows the overlay exactly as configured, purely fromdata/projects.json— no manual wiring elsewhere. - Opening any other existing map (
upload-sourced, or a different tile set) is visually unchanged. -
pnpm typecheckpasses.
Affected areas
- app/routes/project-map.tsx —
loader return shape and the
<Map>call site.
Open questions
- None.
4. Toggle image overlay visibility from the UI¶
Goal: While tuning an overlay's position, a user can hide and re-show it
from the map view to compare against the bare base map, without editing
data/projects.json or reloading.
Scope
- In: A floating control in view mode, following the existing
AvesmapsLayerControl/useAvesmapsLayerSelectionshape in app/components/avesmaps-layer-control.tsx: a button that opens a small card listing each of the map'simageOverlaysbylabel, each with a checkbox, rendered only when the map has at least one overlay. - In: A matching
useImageOverlaySelection(mapId, overlays)hook (or an extension of the existing hook, whichever keepsavesmaps-layer-control.tsxfrom mixing two unrelated concerns), persisting the enabled/disabled set tolocalStoragekeyed by map id — mirroringstorageKeyFor/useAvesmapsLayerSelection's "track what's OFF, default everything on" approach so a newly added overlay defaults to visible. - In: app/routes/project-map.tsx
wires the hook's
isEnabledinto thevisibleprop added toImageOverlayLayerViewin task 2. - Out: Any change to
data/projects.json— this state is client-side only, same rationale as the existing avesmaps layer selection. - Out: Edit mode controls for scale/rotation/opacity/minZoom — this is a visibility toggle only, not a positioning editor.
Acceptance criteria
- With one or more
imageOverlaysconfigured, a control is visible on the map letting each overlay be hidden and re-shown independently, without a page reload. - Hiding an overlay persists across a page reload (via
localStorage, keyed by map id) but does not touchdata/projects.json. - A map with no
imageOverlaysshows no such control. -
pnpm typecheckpasses.
Affected areas
- app/components/avesmaps-layer-control.tsx or a new sibling component — the toggle UI and its selection hook.
- app/routes/project-map.tsx — wires
the selection into the
<Map>call.
Open questions
- None.
5. Configure and align stadtplan_trallop.jpg¶
Goal: stadtplan_trallop.jpg renders on Kontinent-Karte, visually
aligned with the surrounding tile imagery near the existing Trallop POI, only
at high zoom.
Scope
- In: Adding one
ImageOverlayLayerentry to theKontinent-Kartemap indata/projects.jsonby hand:label,path: "/images/overlay-maps/stadtplan_trallop.jpg", acenternear the existing Trallop POI ({ lat: -90.248, lng: 147.787 }perdata/projects.json), andscale,rotation,opacity,minZoomvalues tuned by eye against the running app. - In: Using task 4's toggle to flip the overlay off/on while comparing its
position against the base tile map, and
opacityto see both at once while tuning. - In: Iterating those values against
pnpm devuntil the detail image sits plausibly over the Trallop area at high zoom. - Out: Pixel-perfect alignment to any real street layout — this is a proof-of-concept; "plausibly over the right area, right size, right orientation" is the bar, not survey-grade accuracy.
- Out: The other three images.
Acceptance criteria
-
data/projects.json'sKontinent-Kartemap has oneimageOverlaysentry forstadtplan_trallop.jpgwith real (non-placeholder)label,center,scale,rotation,opacity,minZoomvalues. - Manually verified in the running app: zoomed out, the image is absent;
zoomed to
minZoomor beyond near Trallop, the image appears reasonably sized, rotated, and positioned relative to the tile map; the task 4 toggle hides and re-shows it.
Affected areas
- data/projects.json — one new
imageOverlaysentry.
Open questions
- None.