Skip to content

Base layer visibility control

Base Layer Visibility Control

Goal: Users can hide or show a map's current base layer from the map view, whether that base is a tile layer or an uploaded image, while keeping the interaction ready for multiple future tile layers.

Decisions

Decision Choice Why
Layer types shown The control lists the active tile-set layer on tile-backed maps and the uploaded-image base on upload-backed maps Every map type gets a meaningful base-layer toggle while the control remains focused on base imagery
Control label Tile layers Matches the requested feature and leaves room for additional tile-layer entries; the uploaded-image entry is included in the same control for consistency
Initial state Enabled when no saved preference exists Existing maps must retain their current appearance by default
State ownership Per-map client-side state persisted in localStorage Visibility is view state, not project configuration, and must not alter data/projects.json or Directus data
Leaflet lifecycle Keep map bounds and the layer component mounted; add/remove only the imperative base layer Unmounting the component that calls fitBounds could reset the user's current pan/zoom
Future layer model The control consumes a list of layer entries, even though each current map supplies one Additional tile layers can become additional checkbox rows without changing the UI contract
Directus schema No schema field or Directus collection change Visibility is deliberately local-only; no directus/schema/ snapshot exists in this workspace and no Directus field is required

Prerequisites

  • Confirm the current MapRef.source remains the source of truth for the active base layer: tileSet maps use source.tileSetId, and upload maps use source.path.
  • Preserve the existing localStorage selection conventions used by image-overlay-control.tsx and avesmaps-layer-control.tsx: track disabled ids so newly introduced layers default to visible.
  • Keep MapFitBounds mounted for tile-backed maps and the upload image's existing map-fit behavior unchanged while only toggling the underlying Leaflet layer.
  • No route-table change, server action, persisted project-data migration, or Directus schema change is required.

Steps

1. Define the base-layer view-state and control contract

Goal: Add a reusable map-control module that represents base layers as labeled entries and exposes per-map enabled state and toggles, following the existing checkbox/popover/card conventions.

Scope - In: app/features/map-controls/tile-layer-control.tsx with a layer-entry shape containing a stable id and human-readable label, a useTileLayerSelection(mapId, layers) hook, and a checkbox-based control component. - In: Persist disabled layer ids under a tile-layer-specific, map-specific localStorage key; tolerate malformed or unavailable storage by using all-enabled state. - In: Reconcile saved ids against the current entry list so stale ids do not affect future layers, while newly added entries remain enabled by default. - Out: Any server persistence, action intent, project-data field, tile-set selection, layer creation, or layer ordering.

Acceptance criteria - [ ] The hook defaults every supplied entry to enabled and exposes isEnabled plus a toggle operation. - [ ] State is isolated by map id and persists across reloads without writing project data. - [ ] A new layer entry is enabled when it is absent from the saved disabled-id set. - [ ] The control renders one checkbox per supplied entry using the existing Popover, Card, Button, and Checkbox patterns. - [ ] pnpm typecheck passes.

2. Make the tile-set base layer visibility-aware

Goal: The existing imperative tile layer can be removed from and restored to Leaflet without rebuilding the map or refitting its bounds.

Scope - In: app/components/map/map-primitives.tsx, extend the tile-layer rendering contract with a visible prop defaulting to true. - In: Keep MapFitBounds independent of visibility and change only the MapImperativeLayer lifecycle or equivalent Leaflet add/remove handling for the tile layer. - In: Ensure changing visibility does not recreate the tile layer because of unrelated route renders or reset the current pan/zoom. - Out: Changes to tile URL generation, CRS, bounds, zoom limits, or tile-set registry data.

Acceptance criteria - [ ] visible={false} removes tile imagery from the map while map bounds, CRS, current center, and zoom remain unchanged. - [ ] visible={true} restores the same tile layer and current view without calling fitBounds again. - [ ] Existing tile rendering is unchanged when visible is omitted or true. - [ ] pnpm typecheck passes.

3. Make the uploaded-image base layer visibility-aware

Goal: Upload-backed maps support the same hide/show behavior without changing their existing image sizing or map-fit behavior.

Scope - In: app/features/map-controls/image-overlay-layer.tsx (the current owner of UploadImageLayer), add the same visibility contract used by the tile layer and remove/add only the image overlay instance when it changes. - In: Keep image loading, natural-size bounds, map fitting, and the upload source URL behavior unchanged. - In: Ensure the hidden image does not intercept clicks and restoring it does not refit or recreate the map container. - Out: The separately configured image-overlay collection and its existing visibility control.

Acceptance criteria - [ ] visible={false} hides the uploaded image without changing the current map view. - [ ] visible={true} restores the uploaded image without refitting the map. - [ ] Existing upload-backed map behavior is unchanged when visible is omitted or true. - [ ] pnpm typecheck passes.

4. Wire base-layer entries, state, and rendering in the project-map route

Goal: The map route derives the correct base-layer entry from its loader data, connects selection state to the control, and passes visibility to the appropriate Leaflet layer.

Scope - In: app/routes/project-map.tsx, derive a stable tile-set label from source.tileSet.label for tile-backed maps and a human-readable uploaded-image label for upload-backed maps. - In: Initialize the new selection hook with map.id and the current entry list, render the control in MapControlContainer, and pass the corresponding enabled value to MapTileLayer or UploadImageLayer. - In: Preserve the existing lazy-loading boundary and layer ordering: base imagery remains below AvesMaps, configured image overlays, map objects, and interaction handlers. - Out: Changes to app/routes.ts, loader shape, route actions, MapRef, or Directus data.

Acceptance criteria - [ ] Tile-set-backed maps show one checked base-layer checkbox with the tile set's human-readable label. - [ ] Upload-backed maps show one checked checkbox for the uploaded-image base. - [ ] Unchecking and rechecking each base type hides and restores only that base layer. - [ ] The route does not add an action or loader field for visibility. - [ ] Existing AvesMaps, image-overlay, POI, route, selection, and editing controls continue to work. - [ ] pnpm typecheck passes.

5. Verify the feature and regression boundaries

Goal: Confirm the control works across both source kinds and that local-only visibility does not disturb map behavior.

Acceptance criteria - [ ] pnpm typecheck passes. - [ ] pnpm format completes successfully. - [ ] pnpm build passes. - [ ] With no saved preference, a tile-backed map loads with tiles visible and an upload-backed map loads with its image visible. - [ ] Toggling each base layer preserves the current center and zoom, and toggling it back restores the imagery. - [ ] Reloading the same map restores its saved visibility state; opening a different map does not inherit it. - [ ] Hiding the base does not change POI placement, selection clearing, AvesMaps feature selection, route rendering, configured image overlays, or edit-mode interactions. - [ ] Project JSON and Directus-backed project data remain unchanged by visibility toggles.

Risks / open questions

  • Leaflet layer components currently use imperative effects, so an implementation that couples visibility to component unmounting may accidentally rerun fitBounds; verify the map view before and after every toggle.
  • There is no directus/schema/ directory or JSON snapshot in the workspace. This plan intentionally adds no Directus field, so that absence does not block implementation.