Skip to content

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 → project aventurien → map Kontinent-Karte (fdf566e4-9dbb-4677-b3ac-4a37b256990b), source.kind: "tileSet", tileSetId: "avesmaps".
  • app/lib/projects.server.ts's MapRef has a single source: MapSource ("tileSet" | "upload", mutually exclusive) and a pois array. There is no concept of a layer that sits on top of a tileSet source today.
  • app/components/map.tsx's UploadImageLayer already does the closest thing to what we need — it loads an image to get its natural size, builds an L.imageOverlay from [[0,0],[naturalHeight, naturalWidth]], and calls fitBounds. It has no scale, rotation, position-anchor, or zoom-gating; it's also only used for whole-map kind: "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 Leaflet CRS.Simple lat/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.ImageOverlay has no rotation support, and _reset() (called on every pan/zoom) fully overwrites the image element's CSS transform with 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, overriding getTileUrl); the same pattern (override the relevant protected method, call super, then extend what it set) is the intended shape for rotation here — see task 2.
  • L.ImageOverlay also has no built-in minZoom (that's a TileLayer-only option) — visibility has to be toggled manually on zoom change.
  • L.ImageOverlay does have a built-in opacity option — 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 in localStorage keyed by map id, explicitly not written into data/projects.json because 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 }. label is new versus the prior draft of this plan — the visibility toggle control (task 4) needs something human-readable to show per overlay, the same way TileSetBase.label names a tile set.
  • In: MapRef gains an imageOverlays: ImageOverlayLayer[] field.
  • In: readProjectsFile backfills imageOverlays: [] for any map that doesn't have it yet, mirroring the existing pois backfill in the same function.
  • Out: No add/update/remove server functions for overlay layers (no UI will call them in this task) — entries are written directly into data/projects.json in task 4.
  • Out: No changes to MapSource — overlays are a property of the map, not a second source.

Acceptance criteria

  • ImageOverlayLayer is exported from projects.server.ts and MapRef includes imageOverlays: ImageOverlayLayer[].
  • Loading the existing data/projects.json (which has no imageOverlays key anywhere yet) does not throw, and every map's imageOverlays resolves to [] after readProjectsFile.
  • pnpm typecheck passes.

Affected areas

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 per ImageOverlayLayer), rendered alongside TileSetLayer for tileSet.kind === "grid" maps.
  • In: Load the image to get naturalWidth/naturalHeight (same technique as UploadImageLayer), then compute unrotated L.latLngBounds centered on layer.center, sized by naturalWidth * layer.scale / naturalHeight * layer.scale.
  • In: A small L.ImageOverlay subclass that re-applies a CSS rotate(...) transform (around the image's own center) after Leaflet's own reset, so rotation survives pan/zoom — following the existing GridTileLayer subclassing pattern in the same file.
  • In: Pass layer.opacity straight through as the L.ImageOverlay opacity option — no custom handling needed, Leaflet supports this natively.
  • In: Manual show/hide keyed off the map's current zoom vs. layer.minZoom (useMapEvent("zoomend", ...)), since L.ImageOverlay has no native minZoom option.
  • In: An extra visible: boolean prop on ImageOverlayLayerView (default true), ANDed with the minZoom check above, so task 4's toggle can hide a layer regardless of zoom.
  • In: interactive: false on the overlay so it never intercepts clicks meant for MapClickHandler or POI markers.
  • Out: Any change to the kind: "upload" branch or UploadImageLayer — this is additive, for grid tile sets only.
  • Out: The toggle control itself and its state (task 4) — this task only accepts the visible prop.

Acceptance criteria

  • With a test entry in imageOverlays (any image, any values), the image does not render below the configured minZoom and appears once the map reaches that zoom, remaining visible up to maxZoom.
  • Changing scale changes the rendered size; changing rotation visibly rotates the image around its own center; changing opacity changes 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 to true restores the minZoom-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 when imageOverlays is empty.
  • pnpm typecheck passes.

Affected areas

  • app/components/map.tsx — new component(s); Map's grid-tileSet branch renders one per entry in a new imageOverlays prop.

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.imageOverlays in its returned data (both the tileSet and upload branches can just pass through what's on map, keeping the upload branch's array empty in practice since only tileSet-sourced maps will have entries).
  • In: The route component passes imageOverlays through 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-Karte after task 5's data edit shows the overlay exactly as configured, purely from data/projects.json — no manual wiring elsewhere.
  • Opening any other existing map (upload-sourced, or a different tile set) is visually unchanged.
  • pnpm typecheck passes.

Affected areas

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 / useAvesmapsLayerSelection shape in app/components/avesmaps-layer-control.tsx: a button that opens a small card listing each of the map's imageOverlays by label, 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 keeps avesmaps-layer-control.tsx from mixing two unrelated concerns), persisting the enabled/disabled set to localStorage keyed by map id — mirroring storageKeyFor / 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 isEnabled into the visible prop added to ImageOverlayLayerView in 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 imageOverlays configured, 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 touch data/projects.json.
  • A map with no imageOverlays shows no such control.
  • pnpm typecheck passes.

Affected areas

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 ImageOverlayLayer entry to the Kontinent-Karte map in data/projects.json by hand: label, path: "/images/overlay-maps/stadtplan_trallop.jpg", a center near the existing Trallop POI ({ lat: -90.248, lng: 147.787 } per data/projects.json), and scale, rotation, opacity, minZoom values 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 opacity to see both at once while tuning.
  • In: Iterating those values against pnpm dev until 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's Kontinent-Karte map has one imageOverlays entry for stadtplan_trallop.jpg with real (non-placeholder) label, center, scale, rotation, opacity, minZoom values.
  • Manually verified in the running app: zoomed out, the image is absent; zoomed to minZoom or 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

Open questions

  • None.