Skip to content

feat: add Overture Maps plugin under the Plugins menu#166

Merged
giswqs merged 4 commits into
mainfrom
feat/overture-maps-plugin
Jun 8, 2026
Merged

feat: add Overture Maps plugin under the Plugins menu#166
giswqs merged 4 commits into
mainfrom
feat/overture-maps-plugin

Conversation

@giswqs
Copy link
Copy Markdown
Member

@giswqs giswqs commented Jun 8, 2026

Summary

Adds maplibre-gl-overture-maps as a new Overture Maps plugin. It visualizes the six Overture Maps PMTiles themes (addresses, base, buildings, divisions, places, transportation) with per-theme visibility, opacity, styling, and feature inspection.

  • Listed in the Plugins menu directly after Esri Wayback.
  • Defaults to the top-left control position (selectable from the menu like other plugins).
  • Panel opens on activation and remembers its release, visibility, and opacity across reposition and project reload.

Theming

The upstream control themes itself from prefers-color-scheme (the OS theme), so a light OS would keep the panel light even in app dark mode. This remaps the upstream --ovt-* tokens to the app theme tokens (scoped, high-specificity, with a .dark override and color-scheme), keying the control, panel, and inspect popup to the app's light/dark toggle. This follows the same pattern already used for the FEMA / NASA / EnviroAtlas / National Map panels.

Dependency

Depends on maplibre-gl-overture-maps@^0.2.0. That release adds a style.load handler so the control re-applies its sources and layers after a basemap change (map.setStyle() otherwise wipes them). See opengeos/maplibre-gl-overture-maps#4.

Testing

  • npm run build (tsc + vite) and pre-commit run --all-files pass.
  • npm run test:frontend (113 tests) passes.
  • Verified in the running app:
    • Overture Maps appears after Esri Wayback; activating opens the panel at top-left.
    • Panel and toggle icon render correctly in both light and dark themes (panel background, text, native controls, and accents follow the app theme).
    • Enabling themes then switching basemaps re-adds all Overture sources and layers automatically (7 layers / 3 sources), with no console errors.

Summary by CodeRabbit

  • New Features

    • Overture Maps is now integrated as a map control, enable it from plugins/menu; position is configurable and panel state is persisted with projects.
    • App API adds an “export text file” action to save text content from the app.
  • Behavior

    • Overture layers and the Layers panel synchronize visibility and opacity bidirectionally.
  • Style

    • Overture control UI follows the app’s light/dark theme for consistent appearance.

Add maplibre-gl-overture-maps as a new Overture Maps plugin, listed
after Esri Wayback in the Plugins menu and defaulting to the top-left
control position. The panel opens on activation and remembers its
release, visibility, and opacity across reposition and project reload.

Theme the control, panel, and inspect popup from the app light/dark
toggle by remapping the upstream --ovt-* tokens to the app theme
tokens, overriding the package's prefers-color-scheme defaults.

Depends on maplibre-gl-overture-maps 0.2.0, which re-applies its
sources and layers after a basemap style change.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 8, 2026

Review Change Stack

Caution

Review failed

Pull request was closed or merged during review

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: b1b89cc6-ed02-41ae-add4-77511a055bf9

📥 Commits

Reviewing files that changed from the base of the PR and between 22c56af and 49c2444.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (4)
  • apps/geolibre-desktop/src/hooks/usePlugins.ts
  • packages/plugins/package.json
  • packages/plugins/src/plugins/maplibre-overture-maps.ts
  • packages/plugins/src/types.ts

📝 Walkthrough

Walkthrough

Adds maplibreOvertureMapsPlugin (control lifecycle, position, persistence, and bidirectional sync with Layers store), re-exports and registers the plugin in the desktop app, adds the overture stylesheet and dependency, maps Overture CSS tokens to the app theme, and exposes an app API method exportTextFile.

Changes

Overture Maps Plugin Integration

Layer / File(s) Summary
Plugin core implementation
packages/plugins/src/plugins/maplibre-overture-maps.ts
maplibreOvertureMapsPlugin wires OvertureMapsControl into GeoLibre with creation/pending-state restore, activate/deactivate, getMapControlPosition/setMapControlPosition, deferred panel expansion, getProjectState/applyProjectState with runtime guards, and bidirectional sync between the Overture control and the Layers store using stable IDs and deterministic comparisons.
App integration, API, and registration
packages/plugins/src/index.ts, apps/geolibre-desktop/src/hooks/usePlugins.ts, packages/plugins/package.json, apps/geolibre-desktop/src/main.tsx
Re-exports maplibreOvertureMapsPlugin, registers it via manager.registerAll(...), adds exportTextFile(filename, content) to the runtime app API (implemented via saveTextFileWithFallback), adds maplibre-gl-overture-maps to dependencies, and imports its stylesheet in the app entry.
App CSS tokens and theme mapping
apps/geolibre-desktop/src/index.css
Maps upstream --ovt-* tokens to the app theme and adds light/dark color-scheme variants for the Overture control, panel, and popup content.
App API types update
packages/plugins/src/types.ts
Adds optional exportTextFile?: (filename: string, content: string) => void to GeoLibreAppAPI.
sequenceDiagram
  participant PluginsIndex as packages/plugins/src/index.ts
  participant Desktop as apps/geolibre-desktop
  participant PluginManager as PluginManager
  participant OverturePlugin as maplibreOvertureMapsPlugin
  participant OvertureControl as OvertureMapsControl
  participant LayersStore as Layers Store

  PluginsIndex->>Desktop: export plugin
  Desktop->>PluginManager: registerAll([...maplibreOvertureMapsPlugin])
  PluginManager->>OverturePlugin: activate(app)
  OverturePlugin->>OvertureControl: attach control
  OvertureControl->>OverturePlugin: statechange (visibility/opacity)
  OverturePlugin->>LayersStore: reconcileStore (mirror layers)
  LayersStore->>OverturePlugin: user edits -> reverseSync
  Desktop->>OverturePlugin: createAppAPI.exportTextFile(filename, content)
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐇 I hopped through code to stitch a pane,
Controls that remember sun and rain,
Tokens tuned to light and night,
Layers whisper, hide, and light,
A tiny rabbit claps — plugins reign.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 17.65% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and accurately summarizes the main change: adding the Overture Maps plugin to the application and making it accessible via the Plugins menu.
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 feat/overture-maps-plugin

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

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 8, 2026

Netlify preview: https://pr-166--opengeos.netlify.app

Comment thread packages/plugins/src/plugins/maplibre-overture-maps.ts Outdated
Comment thread packages/plugins/src/plugins/maplibre-overture-maps.ts Outdated
Comment thread packages/plugins/src/plugins/maplibre-overture-maps.ts Outdated
Comment thread apps/geolibre-desktop/src/index.css
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 8, 2026

Code review

Reviewed maplibre-overture-maps.ts (new plugin), index.css (theming), main.tsx, usePlugins.ts, index.ts, package.json, and package-lock.json. Cross-checked against the patterns in maplibre-esri-wayback.ts, maplibre-national-map.ts, maplibre-nasa-earthdata.ts, and types.ts.

Bugs

  • State read from detached control in setMapControlPosition failure path (medium confidence) — line 88 of maplibre-overture-maps.ts. When addMapControl fails, overtureControl.getState() is called after removeMapControl, whereas deactivate reads state before removal. If the control's getState depends on map attachment, the saved pendingState may be empty or stale, silently discarding the user's theme/opacity selections on next activation. Inline comment includes a suggested fix that mirrors deactivate's snapshot-before-remove ordering.

Performance

Nothing notable.

Security

Nothing notable. The plugin wraps a third-party MapLibre control with no direct DOM manipulation, URL construction, or user-supplied input paths in the new code.

Quality

  • createOvertureControl applies state twice (low confidence) — lines 40–46. getOvertureControlOptions() already folds collapsed, panelWidth, and release from pendingState into the constructor call; control.setState(pendingState) then re-applies the full state. Redundant but harmless; worth cleaning up to avoid a double reconcile cycle.
  • isOvertureMapsState type guard is overly permissive (low confidence) — line 51. Any non-null, non-array object passes; a corrupted or schema-changed project entry would silently reach control.setState(). Adding a check for one or two distinctive OvertureMapsState fields would make the guard more defensive.
  • --ovt-fg and --ovt-text both map to --popover-foreground (nit, CSS) — these may serve distinct roles in the upstream stylesheet (icon tint vs. body text). Same value is likely fine here, but worth a quick check against the upstream CSS.

CLAUDE.md

No CLAUDE.md file exists in this repository; no guidelines to check against.


Overall the implementation is well-structured and consistent with existing plugin patterns. The state-ordering issue in setMapControlPosition is the one finding worth addressing before merge; the others are low-risk quality items.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/plugins/src/plugins/maplibre-overture-maps.ts`:
- Around line 26-38: The getOvertureControlOptions function uses inconsistent
null-checks: change the release branch from a truthy check to the same non-null
check pattern used for collapsed and panelWidth so empty-string or
falsy-but-valid values are preserved; specifically, update the conditional that
references pendingState?.release in getOvertureControlOptions to use
pendingState?.release != null and spread { release: pendingState.release } when
present, keeping the rest of the return (OVERTURE_OPTIONS and position:
overturePosition) unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 32c96382-9473-4a9d-96ea-4c16c9edc0c4

📥 Commits

Reviewing files that changed from the base of the PR and between c34459e and fc6ed31.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (6)
  • apps/geolibre-desktop/src/hooks/usePlugins.ts
  • apps/geolibre-desktop/src/index.css
  • apps/geolibre-desktop/src/main.tsx
  • packages/plugins/package.json
  • packages/plugins/src/index.ts
  • packages/plugins/src/plugins/maplibre-overture-maps.ts

Comment thread packages/plugins/src/plugins/maplibre-overture-maps.ts Outdated
Mirror each visible Overture source layer (e.g. building, building_part)
into the GeoLibre layer store as a single external-native custom layer so
it appears in the Layers panel, combining its fill/line/circle native
layers into one entry that matches the Overture Maps control structure.

Sync is bidirectional: control changes update the panel, and panel
visibility/opacity edits drive the control. Entries persist across
visibility toggles, and the layers and plugin state round-trip through
saved projects. Default to showing only the buildings theme on
activation.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/plugins/src/plugins/maplibre-overture-maps.ts`:
- Around line 185-192: attachStoreSync currently calls useAppStore.subscribe(()
=> handleStoreChange(control)) causing handleStoreChange to run on every store
mutation; change this to subscribe only to layer changes (e.g., use a
selector-based subscription if supported) or update handleStoreChange to
early-return by shallow-comparing the incoming store.layers to a cached
previousLayers before doing work (keep reference to previousLayers inside the
module), and ensure you still respect the existing syncing guard; update
storeUnsubscribe accordingly and reference attachStoreSync,
useAppStore.subscribe, handleStoreChange, and storeUnsubscribe when making the
change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 46b65152-21bf-42e7-a4dc-669de4bd02c9

📥 Commits

Reviewing files that changed from the base of the PR and between fc6ed31 and a455297.

📒 Files selected for processing (1)
  • packages/plugins/src/plugins/maplibre-overture-maps.ts

Comment thread packages/plugins/src/plugins/maplibre-overture-maps.ts
Comment thread packages/plugins/src/plugins/maplibre-overture-maps.ts Outdated
Comment thread packages/plugins/src/plugins/maplibre-overture-maps.ts Outdated
Comment thread packages/plugins/src/plugins/maplibre-overture-maps.ts
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 8, 2026

Code review

Reviewed the new maplibre-overture-maps.ts plugin, the CSS theme overrides in index.css, the main.tsx import, and usePlugins.ts registration. Also compared against the established maplibre-esri-wayback.ts pattern since this PR deliberately follows it. No CLAUDE.md found. 113 tests continue to pass per the PR description.


Bugs

Finding Confidence
attachStoreSync placed outside the if (!overtureControl) null-guard — Unlike maplibre-esri-wayback.ts (which puts attachStoreSync inside the guard and calls detachStoreSync in the error path), this plugin calls attachStoreSync unconditionally after addMapControl succeeds. If activate is ever called with a non-null overtureControl the statechange handler is orphaned on the emitter and the previous Zustand subscription leaks. The !added error branch also skips both detachStoreSync and the pendingState snapshot that setMapControlPosition's failure path correctly captures. See inline comment. Medium

Performance

Finding Confidence
JSON.stringify used for deep equality in shouldUpdateStoreLayer — Key insertion order differences across code paths would produce false positives and trigger spurious updateLayer calls. In practice the objects are built by a single factory so order is stable, but worth watching if the factory is ever split. Also: existing.type !== next.type widens the update predicate but type is not included in the subsequent updateLayer call, so the check is dead for that field. See inline comment. Low

Quality

Finding Confidence
Opacity not forwarded while a layer is hiddenreverseSync gates setLayerOpacity on storeLayer.visible, so a Layers-panel opacity change on a hidden layer is silently deferred until the layer is made visible again. This can cause a one-frame opacity flash on reveal if the upstream control re-renders before the sync fires. See inline comment. Low
version: "0.2.0" is hardcoded — consistent with every other plugin in the repo, but it will drift if ^0.2.0 resolves to 0.2.x in future installs. Not actionable now; just worth noting next time the dependency bumps. Low / known pattern
isOvertureMapsState is a very permissive type guard — any non-null, non-array plain object passes, so a malformed project file won't be rejected before reaching control.setState. Consistent with how other plugins validate state; acceptable if the upstream control handles unexpected fields gracefully. Low

Security

Nothing to flag. The plugin does not handle user-supplied strings in ways that could cause injection; all data flows through the upstream OvertureMapsControl and the app's own store APIs.


CLAUDE.md

No CLAUDE.md file found in the repository; no guidelines to check against.


Overall: the implementation is well-structured and the bidirectional sync logic (including the three-way last / control / store comparison to avoid write-echo) is thoughtful. The main actionable item is aligning the activate null-guard and error-path cleanup with the maplibre-esri-wayback.ts pattern.

- activate: attach store sync inside the control-creation guard and clean up
  on the addMapControl failure path (Claude) to avoid double-attach/leaks
- setMapControlPosition: snapshot pending state before removing the control
  so a failed re-add keeps the latest state (Claude)
- createOvertureControl: rely on setState for restoration instead of also
  spreading collapsed/panelWidth/release into the constructor (Claude); this
  also removes the inconsistent release null-check (CodeRabbit)
- isOvertureMapsState: require a distinctive OvertureMapsState field so an
  unrelated stored object is not forwarded to setState (Claude)
- store subscription: ignore updates that do not change the layers array
  (CodeRabbit)
- reverseSync: forward opacity to the control even while a source layer is
  hidden so the value persists (Claude)
- shouldUpdateStoreLayer: compare source/metadata with a key-order-insensitive
  stringify to avoid spurious updates for project-deserialized layers (Claude)
Comment on lines +79 to +88
if (!overtureControl) {
overtureControl = createOvertureControl();
attachStoreSync(overtureControl);
}
const added = app.addMapControl(overtureControl, overturePosition);
if (!added) {
detachStoreSync();
overtureControl = null;
return false;
}
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.

Bug (medium): store sync is attached before addMapControl succeeds

attachStoreSync is called before app.addMapControl, so if addMapControl returns false, the detachStoreSync() call on the failure path removes any Overture layers that handleStoreChange (invoked inside attachStoreSync) just added to the store — including project-restored layers from applyProjectState. This leaves the store in a worse state than when activate was entered.

The maplibre-gl-enviroatlas and maplibre-gl-national-map plugins attach the store sync only after addMapControl succeeds, which avoids this window. (The maplibre-gl-esri-wayback plugin uses the same ordering as this PR, so this is a pre-existing inconsistency, but it's still worth fixing here.)

Suggested change
if (!overtureControl) {
overtureControl = createOvertureControl();
attachStoreSync(overtureControl);
}
const added = app.addMapControl(overtureControl, overturePosition);
if (!added) {
detachStoreSync();
overtureControl = null;
return false;
}
if (!overtureControl) {
overtureControl = createOvertureControl();
}
const added = app.addMapControl(overtureControl, overturePosition);
if (!added) {
overtureControl = null;
return false;
}
attachStoreSync(overtureControl);

Comment on lines +63 to +72
): value is Partial<OvertureMapsState> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
// Require at least one distinctive OvertureMapsState field so an unrelated
// object stored under this plugin's key is not forwarded to setState.
return (
"themes" in value || "release" in value || "collapsed" in value
);
}
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.

Quality (low-medium): type guard is too permissive

The union check passes for any object that has any of the three keys. The field "collapsed" is common in serialised UI state, and "release" could appear in version objects. A stale or mismatched save entry under this plugin's project key would silently pass validation and get forwarded to setState, potentially corrupting the control's state.

Conjuncting the checks (or requiring at least the most discriminating field) makes the guard more robust:

Suggested change
): value is Partial<OvertureMapsState> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
// Require at least one distinctive OvertureMapsState field so an unrelated
// object stored under this plugin's key is not forwarded to setState.
return (
"themes" in value || "release" in value || "collapsed" in value
);
}
return (
"themes" in value && ("release" in value || "collapsed" in value)
);

Comment on lines +297 to +304
if (!storeLayer) {
// The entry was removed from the Layers panel: hide the source layer.
// Source layers we never mirrored (no last value) are left untouched.
if (lastControlValues.has(key) && layerState.visible) {
control.setLayerVisible(unit.theme, unit.sourceLayer, false);
}
lastControlValues.delete(key);
continue;
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.

Quality (low): key deleted by reverseSync is unconditionally re-added by the reconcileStore that immediately follows

After the key is deleted here, reconcileStore (called next in handleStoreChange) reads visible: false from the control and existing = null from the store, so the !visible && !existing branch fires and re-inserts the key with {visible: false}. On every subsequent unrelated store mutation, reverseSync deletes the key again and reconcileStore re-adds it, resulting in repeated churn.

The intent of the deletion is to record "this unit was never actively mirrored", but since reconcileStore always re-records whatever the control currently reports, the semantics are never preserved for more than one cycle.

One fix is to skip the delete when the control already reports visible: false for the unit (nothing actually changed that needed recording). This keeps the existing entry and stops the cycle:

Suggested change
if (!storeLayer) {
// The entry was removed from the Layers panel: hide the source layer.
// Source layers we never mirrored (no last value) are left untouched.
if (lastControlValues.has(key) && layerState.visible) {
control.setLayerVisible(unit.theme, unit.sourceLayer, false);
}
lastControlValues.delete(key);
continue;
if (!storeLayer) {
// The entry was removed from the Layers panel: hide the source layer.
// Source layers we never mirrored (no last value) are left untouched.
if (lastControlValues.has(key) && layerState.visible) {
control.setLayerVisible(unit.theme, unit.sourceLayer, false);
lastControlValues.delete(key);
}
continue;
}

Comment on lines +1972 to +1975
classes outrank the upstream `.overture-control.ovt-theme-*` and
prefers-color-scheme blocks regardless of stylesheet order. */
.overture-control.overture-control.overture-control,
.overture-control-panel.overture-control-panel.overture-control-panel,
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.

Quality (low): triple-class specificity trick is fragile

Repeating the same class three times (.overture-control.overture-control.overture-control) is a well-known hack to boost specificity without !important. The comment explains the motivation, which is good. However, if the upstream package ever restructures its specificity (e.g. adds an extra class or switches to :where()/layers), this rule will silently win or lose unexpectedly, and the triple-repeat is not immediately obvious to readers.

A slightly more future-proof alternative is a :is() no-op (which is also a specificity bump trick) or just one extra app-level ancestor selector — but the real fix is upstream. For now, leaving a note that this must be re-evaluated on each maplibre-gl-overture-maps version bump would help maintainers.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 8, 2026

Code review

Reviewed packages/plugins/src/plugins/maplibre-overture-maps.ts, apps/geolibre-desktop/src/index.css, apps/geolibre-desktop/src/main.tsx, apps/geolibre-desktop/src/hooks/usePlugins.ts, packages/plugins/src/index.ts, packages/plugins/package.json, and package-lock.json. The overall structure is solid and consistent with the existing plugin patterns. Three specific concerns follow.

Bugs

  • attachStoreSync called before addMapControl succeeds (medium confidence) — If addMapControl returns false, the failure path calls detachStoreSync(), which invokes removeOvertureStoreLayers() and removes any Overture store layers that handleStoreChange just added — including those already present from a restored project. The maplibre-gl-enviroatlas and maplibre-gl-national-map plugins avoid this by attaching store sync only after the add succeeds. See inline comment on lines 79–88 for a suggested fix.

Quality

  • isOvertureMapsState type guard is overly permissive (low-medium confidence) — The guard uses a disjunction (||) over "themes", "release", and "collapsed". Any serialised object with a "collapsed" key passes, which could forward a stale or mismatched project entry to setState and silently corrupt the control. See inline comment on lines 63–72.

  • lastControlValues key deleted then immediately re-added (low confidence) — When a layer is removed from the Layers panel, reverseSync deletes its key from lastControlValues, but the reconcileStore call that follows in the same handleStoreChange tick re-inserts it (the control now reports visible: false and there is no existing store layer, so the !visible && !existing branch fires). On every subsequent unrelated store mutation the cycle repeats. The fix is to skip the delete when the control already reports visible: false (no hide call is needed and no state changed). See inline comment on lines 297–304.

CSS

  • Triple-class specificity trick (low confidence) — .overture-control.overture-control.overture-control is correct and the comment explains it. The fragility is that any upstream specificity restructuring could silently break theming; worth noting in a changelog or review checklist entry when bumping maplibre-gl-overture-maps. Inline comment on lines 1972–1975.

Security / Performance / CLAUDE.md

Nothing to flag. The package-lock.json integrity hash and version pin look correct. The stableStringify / sortKeysDeep comparison is called once per source layer per state event (bounded set), so the cost is negligible.

The control's export button downloads GeoJSON via an anchor element, which
does nothing in the Tauri webview (no download manager), so the button
appeared broken in the desktop app while working on the web.

- add exportTextFile to the plugin app API, implemented with the host's
  saveTextFileWithFallback (native save dialog under Tauri, download on web)
- wire the Overture control's new onExport handler (maplibre-gl-overture-maps
  0.3.0) to exportTextFile so saving goes through the host
- bump maplibre-gl-overture-maps to ^0.3.0
@giswqs giswqs merged commit 6fe9c09 into main Jun 8, 2026
7 of 8 checks passed
@giswqs giswqs deleted the feat/overture-maps-plugin branch June 8, 2026 01:31
...(app.exportTextFile
? {
onExport: (filename, data) =>
app.exportTextFile?.(filename, JSON.stringify(data)),
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.

Redundant optional chain: this code path is only reached when app.exportTextFile is truthy (the surrounding conditional spread guarantees it), so the ?. is a no-op. Use a plain call instead.

Suggested change
app.exportTextFile?.(filename, JSON.stringify(data)),
app.exportTextFile(filename, JSON.stringify(data)),

Comment on lines +198 to +209
function attachStoreSync(control: OvertureMapsControl): void {
controlEventHandler = () => handleControlEvent(control);
control.on("statechange", controlEventHandler);
// Only react to layer changes; the store also updates on unrelated mutations
// (basemap, UI state) that cannot affect the Overture mirror.
storeUnsubscribe = useAppStore.subscribe((state, prev) => {
if (state.layers === prev.layers) return;
handleStoreChange(control);
});
// Mirror the control's current source layers and adopt any layers restored
// from a project, pushing their state back into the control.
handleStoreChange(control);
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.

Potential lost state on first activation with a pre-loaded project.

attachStoreSync finishes by calling handleStoreChange(control), which calls reverseSync and in turn control.setLayerVisible / control.setLayerOpacity. At this point in activate (line 91 precedes line 93), the control hasn't been added to the map yet via app.addMapControl.

If OvertureMapsControl.setLayerVisible / setLayerOpacity only buffer state changes when the control has a live map reference (i.e. after onAdd), those calls silently do nothing. Any project-restored visibility/opacity that differs from the control's initial state would then be overwritten by reconcileStore reading back the (unchanged) initial state.

The ordering in activate is:

91:  attachStoreSync(overtureControl);   // ← handleStoreChange fires here
93:  app.addMapControl(overtureControl, …); // ← onAdd fires here

Consider calling handleStoreChange after addMapControl succeeds, or defer the initial sync via a statechange event once the control has been added and has settled. Compare with how syncCurrentWaybackLayer / syncPersistentWaybackLayers are called inside the setTimeout in maplibre-esri-wayback.ts — after the control is on the map.

Comment on lines +262 to +276
exportTextFile: (filename: string, content: string) => {
void saveTextFileWithFallback(content, {
defaultName: filename,
filters: [{ name: "GeoJSON", extensions: ["geojson", "json"] }],
browserTypes: [
{
description: "GeoJSON",
accept: { "application/geo+json": [".geojson", ".json"] },
},
],
mimeType: "application/geo+json",
}).catch((error) => {
console.error(`Could not export ${filename}.`, error);
});
},
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 exportTextFile API in GeoLibreAppAPI is intentionally generic (filename: string, content: string), but this implementation hardcodes GeoJSON MIME type and file-extension filters for every call regardless of the actual content type. That works fine today because the only caller is the Overture Maps export, but if a future plugin uses this API to export a non-GeoJSON format (e.g. GPX, CSV), the save dialog will show the wrong type hint.

Consider threading the MIME type through the API:

exportTextFile?: (filename: string, content: string, mimeType?: string) => void;

and deriving the filters from mimeType in this implementation. Alternatively, document that the API is currently GeoJSON-only.

Comment on lines +272 to +290
if (shouldUpdateStoreLayer(existing, nextLayer)) {
store.updateLayer(id, {
name: nextLayer.name,
type: nextLayer.type,
source: nextLayer.source,
sourcePath: nextLayer.sourcePath,
metadata: nextLayer.metadata,
});
}
// Push opacity/visibility only when the control changed them, so a value
// set through the Layers panel is not reverted by an unrelated event.
if (last && opacity !== last.opacity && opacity !== existing.opacity) {
store.updateLayer(id, { opacity });
}
if (last && visible !== last.visible && visible !== existing.visible) {
store.updateLayer(id, { visible });
}
}
lastControlValues.set(unitKey(unit), { visible, 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.

Nits (grouped):

  1. Up to three separate store.updateLayer calls per layer per sync pass. Metadata, opacity, and visibility are each dispatched independently. React should batch these in practice, but a single composite update would be cleaner and guarantee one re-render:

    const patch: Partial<GeoLibreLayer> = {
      name: nextLayer.name, type: nextLayer.type,
      source: nextLayer.source, sourcePath: nextLayer.sourcePath,
      metadata: nextLayer.metadata,
    };
    if (last && opacity !== last.opacity && opacity !== existing.opacity) patch.opacity = opacity;
    if (last && visible !== last.visible && visible !== existing.visible) patch.visible = visible;
    store.updateLayer(id, patch);
  2. existing is stale after the first updateLayer call. The opacity/visibility delta check on lines 283–288 reads existing.opacity / existing.visible from the snapshot taken before the metadata update. This is intentional (it must compare against the user-set value, not the just-written one), but worth a brief comment so the next reader doesn't "fix" it.

  3. Plugin version field is "0.2.0" but the installed package is 0.3.0 (lock file). If this field tracks the wrapper version, that's fine — but the PR description mentions ^0.2.0 while package.json actually pins ^0.3.0, so the description appears outdated.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 8, 2026

Code review

Overall the implementation is solid and follows the existing plugin patterns well. The bidirectional store sync design is thoughtful, the CSS specificity workaround is correctly documented, and the Tauri export fallback is a clean abstraction. Four findings below, one medium-confidence bug and three quality items.


Bugs

# Location Finding Confidence
1 maplibre-overture-maps.ts lines 198–209 handleStoreChange fires before control is map-attached. attachStoreSync calls handleStoreChange at line 209, which calls control.setLayerVisible / setLayerOpacity via reverseSync. In the activate path this happens before app.addMapControl (line 93) fires onAdd. If the upstream control only applies those mutations once it has a live map reference, project-restored visibility/opacity values are silently dropped and then overwritten by reconcileStore reading back the original defaults. The maplibre-esri-wayback plugin avoids this by deferring its initial sync calls into the setTimeout after the control is added. Medium

Quality

# Location Finding Confidence
2 maplibre-overture-maps.ts line 61 Redundant optional chain. app.exportTextFile?.(…) inside the conditional spread app.exportTextFile ? {…} : {} — the ?. is a no-op. Plain call suffices. High
3 usePlugins.ts lines 262–276 exportTextFile implementation hardcodes GeoJSON MIME type. The API signature is generic but every call goes through GeoJSON filters. Fine today (only one caller), but worth documenting or threading a mimeType parameter through for future plugins. Medium
4 maplibre-overture-maps.ts lines 272–290 Up to three separate store.updateLayer calls per layer per sync pass (metadata, opacity, visibility). A composite patch would be cleaner. Grouped with two nits: the intentionally-stale existing reference deserves an inline comment, and the plugin version: "0.2.0" field is inconsistent with the installed package 0.3.0. Low

Security / Performance

Nothing worth raising. The isOvertureMapsState guard is appropriately lightweight (duck-typing on distinctive fields), the stableStringify approach is correct for key-order-insensitive comparison, and the syncing reentrancy guard is safe for synchronous store operations.

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.

1 participant