Skip to content

fix(map): sync basemap visibility/opacity from the layer control to the store#464

Open
giswqs wants to merge 4 commits into
mainfrom
fix/basemap-visibility-icon-sync
Open

fix(map): sync basemap visibility/opacity from the layer control to the store#464
giswqs wants to merge 4 commits into
mainfrom
fix/basemap-visibility-icon-sync

Conversation

@giswqs

@giswqs giswqs commented Jun 18, 2026

Copy link
Copy Markdown
Member

Summary

Fixes #450 — the basemap visibility icon (and opacity slider) in the left layer panel did not update when the basemap was toggled from the layer control on the right.

Root cause

The basemap Background group is rendered in two independent places:

  • The left layer panel reads/writes the store fields basemapVisible / basemapOpacity.
  • The right layer control (maplibre-gl-layer-control) toggled the Background group entirely internally — it mutated the MapLibre style and its own checkbox/slider but never wrote back to the store, and exposed no callback for it.

So right→left never synced. Per-layer toggles worked because they round-trip through the store via the custom-layer adapter (setVisibility/setOpacity); the Background group had no equivalent path.

Fix

Upstream adds two callbacks (opengeos/maplibre-gl-layer-control#59, released in 0.17.2): onBackgroundVisibilityChange and onBackgroundOpacityChange. This PR wires them in MapController.addLayerControl() to setBasemapVisible / setBasemapOpacity, so the store remains the single source of truth and the left panel stays in sync. Dependency bumped to ^0.17.2.

No feedback loop: the store write triggers MapCanvascontroller.setBasemapVisiblesyncLayerControlBackgroundState, which writes the control's internal state directly (it does not re-enter the user-driven handler that fires the callback).

Verification

Driven in the real app with Playwright:

Action in right layer control Left panel before Left panel after fix
Uncheck Background stayed "Hide background" (visible) ❌ flips to "Show background" (hidden) ✅
Re-check Background flips back to "Hide background" ✅
Background opacity 1 → 0.4 stayed "Opacity 1" ❌ updates to "Opacity 0.4" ✅

0 console errors. The map package typechecks cleanly with the change.

Dependency / CI note

Summary by CodeRabbit

  • Bug Fixes
    • Improved synchronization of basemap visibility and opacity between the layer control and the application state for consistent UI behavior.
  • Tests
    • Updated end-to-end save flows to handle browsers that don’t support the File System Access picker by explicitly confirming the save action in the dialog before validating the resulting download.

…he store

The maplibre-gl-layer-control Background (basemap) group toggled the basemap
internally without writing back to the store, so the basemap visibility icon
and opacity slider in the left layer panel did not update when the basemap was
toggled from the layer control on the right (issue #450). Per-layer toggles
already round-trip through the store via the custom-layer adapter; the
Background group had no such path.

Wire the new onBackgroundVisibilityChange / onBackgroundOpacityChange callbacks
(maplibre-gl-layer-control >=0.17.2) to setBasemapVisible / setBasemapOpacity so
the store stays the single source of truth and external basemap UI stays in
sync. Bump the dependency to ^0.17.2.

Fixes #450
@netlify

netlify Bot commented Jun 18, 2026

Copy link
Copy Markdown

Deploy Preview for geolibre-app ready!

Name Link
🔨 Latest commit 97eaf63
🔍 Latest deploy log https://app.netlify.com/projects/geolibre-app/deploys/6a335f3192a2db00085afa95
😎 Deploy Preview https://deploy-preview-464--geolibre-app.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai

coderabbitai Bot commented Jun 18, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 240a3aed-fc73-43a1-8cd3-9d4a15b20f82

📥 Commits

Reviewing files that changed from the base of the PR and between 115e8dd and 97eaf63.

📒 Files selected for processing (2)
  • e2e/layer-groups.spec.ts
  • e2e/storymap.spec.ts

📝 Walkthrough

Walkthrough

In map-controller.ts, addLayerControl() is extended with two new callbacks: onBackgroundVisibilityChange and onBackgroundOpacityChange that sync LayerControl background UI changes (visibility toggle and opacity slider) to the app store via useAppStore.getState().setBasemapVisible() and setBasemapOpacity(). E2E tests for layer-groups and storymap save functionality are updated to handle browsers without File System Access picker support by explicitly clicking the "Save" button in the fallback filename dialog.

Changes

LayerControl Background State Sync

Layer / File(s) Summary
Background visibility and opacity callbacks
packages/map/src/map-controller.ts
addLayerControl() receives two new callbacks — onBackgroundVisibilityChange and onBackgroundOpacityChange — that update the app store via useAppStore.getState().setBasemapVisible and setBasemapOpacity when the LayerControl background UI changes.

E2E Save Dialog Fallback Handling

Layer / File(s) Summary
Save dialog confirmation in e2e tests
e2e/layer-groups.spec.ts, e2e/storymap.spec.ts
Save tests for grouped layers and story maps now click the "Save" button in the filename dialog (using the prefilled default) to handle browsers without native File System Access picker support, ensuring downloads complete before assertions.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Possibly related PRs

  • opengeos/GeoLibre#317: Also updates e2e/storymap.spec.ts to handle the File System Access picker fallback by clicking the save dialog's Save button before awaiting downloads.
  • opengeos/GeoLibre#364: Also modifies basemap control behavior in MapController, adding unit tests for basemap visibility and opacity handling.

Poem

🐇 A toggle, a slider, a store to keep,
The basemap's state no longer asleep.
Two callbacks wired with a flick of the paw,
And tests that save without a flaw.
Through fallback dialogs the downloads now flow,
Hop hop, the sync dance steals the show! 🗺️

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and clearly summarizes the main change: syncing basemap visibility and opacity from layer control to the store, which is the primary focus of the PR.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/basemap-visibility-icon-sync

Comment @coderabbitai help to get the list of available commands and usage tips.

Comment thread packages/map/package.json Outdated
Comment thread packages/map/src/map-controller.ts Outdated
@github-actions

Copy link
Copy Markdown
Contributor

Code review

Bugs

  • package-lock.json not updated (high confidence — merge blocker).
    package-lock.json still resolves maplibre-gl-layer-control to 0.16.0. npm ci (CI) uses the lock file, not package.json, so it installs 0.16.0, which has no onBackgroundVisibilityChange / onBackgroundOpacityChange callbacks. In any CI-built artefact the fix is silently a no-op. The PR description flags this as a "CI note" but it is a merge blocker: the lock file must be regenerated against a published 0.17.2 before this lands. [Inline comment on package.json:18]

Quality

  • Callbacks placed before ...layerControlConfig spread (low confidence — latent fragility).
    The two new callbacks sit before ...layerControlConfig. If createLayerControlConfig ever returns onBackgroundVisibilityChange or onBackgroundOpacityChange (e.g. from a future plugin API), the spread would silently override the fix with no TypeScript error. Conventional practice is to put non-overridable callbacks after the spread so they always win — the suggestion in the inline comment reorders them. [Inline comment on map-controller.ts:1077–1083]

Performance

  • Redundant double-write to MapLibre style (very low confidence — informational).
    When triggered from the right layer control, the library has already applied the visibility/opacity change to the MapLibre style before firing the callback. The callback → store update → MapCanvascontroller.setBasemapVisible/Opacity path then calls applyBasemapVisibility() / applyBasemapOpacity() a second time. The calls are idempotent, so there is no user-visible effect; this is just an extra setLayoutProperty / setPaintProperty round-trip per interaction that could be avoided with a "source" flag. Not worth fixing in this PR.

Checked and found clean

  • Feedback-loop risk: syncLayerControlBackgroundState writes to checkbox.checked and opacity.value directly — programmatic DOM writes do not fire the browser change event, so the library callback is not re-entered.
  • Store-as-source-of-truth invariant: the callbacks only write to the Zustand store; MapCanvas owns the round-trip back to the controller, consistent with the existing architecture.
  • LayerControlConfig interface (excludeLayers, customLayerAdapters) does not currently include the callback keys, so the spread-before concern is latent, not active.
  • Opacity bounds: values come from a library-managed slider and are expected to be in [0,1]; no clamping is needed here.
  • Security: no user-controlled input enters the callbacks; no injection surface.

…y-icon-sync

# Conflicts:
#	packages/map/package.json
Comment thread packages/map/src/map-controller.ts Outdated
@github-actions

Copy link
Copy Markdown
Contributor

Code review

Summary

The change is small, focused, and correct. The root cause analysis in the PR description is accurate: the right-panel layer control was toggling the basemap purely internally without writing back to the store, so the left panel's visibility icon and opacity slider fell out of sync. Wiring onBackgroundVisibilityChange / onBackgroundOpacityChange into the store's setters is the right fix, and the feedback-loop concern is properly avoided — syncLayerControlBackgroundState() mutates the control's internal DOM state directly and does not re-fire those callbacks.


Findings

Quality

  • Spread order: callbacks before ...layerControlConfig — Low confidence.
    The two new callbacks are placed before ...layerControlConfig in the LayerControl constructor options object. Any key in layerControlConfig that matches onBackgroundVisibilityChange or onBackgroundOpacityChange would silently override the wired store callbacks. LayerControlConfig today only carries excludeLayers and customLayerAdapters (private interface, lines 136–139), so this cannot happen now. Placing the callbacks after the spread is the safer, more future-proof order at zero cost. See inline comment.

CLAUDE.md

  • No violations. The added comment explains why the callbacks are needed (the hidden-constraint pattern CLAUDE.md encourages), rather than narrating what the code does.

What I checked

  • addLayerControl() full constructor call and surrounding context (map-controller.ts ~1057–1115)
  • setBasemapVisible / setBasemapOpacity on both the controller (lines 554–564) and the store (core/src/store.ts lines 469–472) — confirms isDirty: true is set, consistent with the left-panel path
  • syncLayerControlBackgroundState() (lines 1171–1197) — direct DOM mutation, no callback re-entry; the claimed feedback-loop prevention holds
  • MapCanvas.tsx reactive effects (lines 751–756) — the useEffect chain that calls back into the controller after a store change
  • LayerControlConfig interface (lines 136–139) — confirmed it has no callback fields, making the spread-order risk theoretical
  • packages/map/package.json — resolved at ^0.17.3 (the PR description mentions ^0.17.2; the lock file landed on 0.17.3, absorbed into the merge commit)

- Move onBackgroundVisibilityChange / onBackgroundOpacityChange after the
  ...layerControlConfig spread so the wired basemap callbacks can never be
  silently overridden by a future key in the config object.
Comment on lines +1082 to +1084
onBackgroundOpacityChange: (opacity) => {
useAppStore.getState().setBasemapOpacity(opacity);
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The opacity value coming from the control is passed straight to the store without any bounds check. setBasemapPaintOpacity handles >= 1 by restoring the original paint value, but if the library ever emits a value below 0 (floating-point edge at the slider boundary), original * this.basemapOpacity would produce a negative opacity — an invalid MapLibre paint value. setLayerOpacity for regular layers has the same gap, so this is consistent, but since we're adding a new path it's a good moment to add a clamp:

Suggested change
onBackgroundOpacityChange: (opacity) => {
useAppStore.getState().setBasemapOpacity(opacity);
},
onBackgroundOpacityChange: (opacity) => {
useAppStore.getState().setBasemapOpacity(Math.min(1, Math.max(0, opacity)));
},

@github-actions

Copy link
Copy Markdown
Contributor

Code review

The change is small and well-reasoned. I traced the full round-trip and checked the surrounding context. Here is what I found.


Bugs

None confirmed.

The feedback-loop concern raised in the PR description was verified: syncLayerControlBackgroundState writes to the control's internal state via direct property assignment and sets DOM checkbox.checked / opacity.value programmatically. Browser specs do not fire change events on programmatic .checked/.value writes, so onBackgroundVisibilityChange/onBackgroundOpacityChange will not re-enter. The round-trip through React (useEffect([basemapVisible/basemapOpacity])controller.setBasemapVisible/OpacityapplyBasemapVisibility/Opacity) causes the MapLibre style to be written twice when the user acts on the right control, but both writes are idempotent and harmless.


Security

None.


Performance

None beyond the harmless double-write noted above.


Quality

Opacity value not clamped — low confidence. onBackgroundOpacityChange passes the library's value directly to setBasemapOpacity without clamping to [0, 1]. setBasemapPaintOpacity handles >= 1 correctly, but a value below 0 would produce a negative original * this.basemapOpacity (invalid for MapLibre). The existing setLayerOpacity path for regular layers has the same gap, so this is consistent rather than a regression, but since a new call path is being added it is a good moment to clamp. Inline suggestion posted on line 1082–1084.

PR description / package.json discrepancy — informational. The description says "Dependency bumped to ^0.17.2", but the diff contains only the 10 TypeScript lines — no package.json change appears. The current main already has ^0.17.3, which satisfies the ^0.17.2 requirement, so there is no functional gap; but the description could mislead a reviewer into thinking the dependency change is missing from the PR. Consider clarifying that the version requirement was already satisfied on main.


CLAUDE.md

No violations. The change is confined to @geolibre/map, does not touch i18n strings, does not commit to main, and does not mutate MapLibre directly from UI — it routes the right-panel action through the store exactly as the architecture section prescribes.

#458 made Save prompt for a file name (via a dialog) in browsers without the
File System Access picker — which these specs delete in addInitScript. The
save-and-reopen specs clicked Project → Save and waited for a download that
never fired, because the name-prompt dialog now intercepts the save, so both
tests hit the 60s timeout (failing on main too). Accept the pre-filled default
name and confirm the dialog so the download proceeds.
Comment on lines +1079 to +1084
onBackgroundVisibilityChange: (visible) => {
useAppStore.getState().setBasemapVisible(visible);
},
onBackgroundOpacityChange: (opacity) => {
useAppStore.getState().setBasemapOpacity(opacity);
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The callbacks are correct and the "no feedback loop" claim holds: updateLayerControlItem sets checkbox.checked / opacity.value via direct property assignment, which does not dispatch change events, so the handler never re-enters.

One minor inefficiency worth being aware of: after the callback fires, the store update causes React to call controller.setBasemapVisible / setBasemapOpacity, which calls applyBasemapVisibility / applyBasemapOpacity again. The library has already applied the change to MapLibre at that point, so this is a redundant (but idempotent) pass over all basemap paint properties. syncLayerControlState() is also re-invoked, iterating all user layers via syncLayerControlLayerStates even though only the background changed. None of this is incorrect — just slightly wasteful on every slider tick or toggle. A guard like early-returning from setBasemapVisible / setBasemapOpacity when the value hasn't changed would avoid it, though the current behaviour is safe.

Also: the comment says the callbacks are placed after the spread "so these wired callbacks always win," implying layerControlConfig could contain competing callbacks. LayerControlConfig is currently typed with only excludeLayers and customLayerAdapters, so there is no real risk — the placement is a forward-compatibility precaution rather than fixing an actual conflict. The comment is accurate but slightly overstated; a future reader adding callbacks to LayerControlConfig might not realise they'd be silently overridden here.

Comment thread e2e/layer-groups.spec.ts
Comment on lines +84 to +89
// Browsers without the File System Access picker (deleted above) prompt for a
// file name before downloading; accept the pre-filled default and confirm.
await page
.getByRole("dialog")
.getByRole("button", { name: "Save", exact: true })
.click();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pattern is correct — waitForEvent("download") is registered before the menu click so the listener is ready, and the dialog button click gates the actual download, so there's no race.

Small brittleness note: { name: "Save", exact: true } inside the dialog is a hardcoded label. If the button text is ever changed (e.g., translated, renamed to "OK" or "Confirm"), the locator will silently time out instead of giving a clear failure. The same applies to the parallel change in storymap.spec.ts. Not a current bug — just worth noting for future-proofing.

Also, page.getByRole("dialog") will match any open dialog. At this point in the test the only open dialog should be the filename prompt, but if the selector were to match an unexpected dialog (e.g., an error toast styled as a dialog), the click would fail non-obviously. A { name: /save/i } constraint on the dialog role, or a dedicated data-testid, would make the selector tighter — though given the controlled test environment this is low priority.

Comment thread e2e/storymap.spec.ts
Comment on lines +54 to +59
// Browsers without the File System Access picker (deleted above) prompt for a
// file name before downloading; accept the pre-filled default and confirm.
await page
.getByRole("dialog")
.getByRole("button", { name: "Save", exact: true })
.click();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same brittleness as the parallel change in layer-groups.spec.ts: { name: "Save", exact: true } inside a generic getByRole("dialog") is a hardcoded label and an unscoped dialog selector. Both are low-risk given the isolated test environment, but worth keeping in mind if these tests become flaky in CI or if the UI gets internationalised.

@github-actions

Copy link
Copy Markdown
Contributor

Code review

Bugs

  • None found. The no-feedback-loop guarantee holds: updateLayerControlItem writes checkbox.checked and opacity.value via direct property assignment, which does not fire change events, so the callbacks never re-enter. The round-trip (callback → store → setBasemapVisiblesyncLayerControlState → DOM) is safe and idempotent.

Security

  • Nothing to flag.

Performance

  • Redundant double-application on each toggle/slider tick (low confidence, low severity): after onBackgroundVisibilityChange/onBackgroundOpacityChange fires, the library has already mutated MapLibre's state; the subsequent store update → React render → controller.setBasemapVisible/setBasemapOpacityapplyBasemapVisibility/applyBasemapOpacity applies the same paint property changes again. syncLayerControlState also iterates all user layers via syncLayerControlLayerStates on each background-only change. All of it is idempotent; no visual artifact occurs. An equality guard at the top of setBasemapVisible/setBasemapOpacity would eliminate the extra work, but the current approach is correct.

Quality

  • Overstated placement comment (low confidence, nitpick): the comment "Placed after the spread so these wired callbacks always win" implies layerControlConfig might contain competing callbacks, but LayerControlConfig is typed with only excludeLayers and customLayerAdapters. The ordering is a sound forward-compatibility precaution; the comment would benefit from noting that risk is hypothetical rather than current, so a future developer doesn't silently add callbacks to LayerControlConfig expecting them to take effect.
  • Hardcoded dialog button label in E2E tests (low-medium confidence): both layer-groups.spec.ts and storymap.spec.ts use getByRole("dialog").getByRole("button", { name: "Save", exact: true }). If the label is ever renamed or the dialog gains an accessible name, the locator times out without a useful failure message. Adding a dialog name constraint (e.g., getByRole("dialog", { name: /save/i })) or a data-testid would make the selector more self-documenting. Not a current defect.

CLAUDE.md

  • No violations observed. The change correctly goes through the store rather than mutating MapLibre directly from UI, consistent with the one-way data-flow convention documented in the architecture section.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Issue: The basemap visibility icon on the left side is not updated when toggling the layer visibility in the layer menu on the right side.

1 participant