maplibre-gl-layer-control 0.15.1 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,7 +14,7 @@ A comprehensive layer control for MapLibre GL with advanced styling capabilities
14
14
  - ✅ **Layer visibility toggle** - Checkbox control for each layer
15
15
  - ✅ **Layer opacity control** - Smooth opacity slider with type-aware property mapping; **double-click the slider to type an exact percentage (0-100%)**
16
16
  - ✅ **Layer symbols** - Visual type indicators (colored shapes) next to layer names, auto-detected from layer paint properties
17
- - ✅ **Resizable panel** - **Drag either edge of the panel to resize it**, plus a width slider and keyboard support
17
+ - ✅ **Resizable panel** - **Drag either edge of the panel to resize it**; double-click an edge to reset to the default width. The panel also grows to fill the available height so long layer lists only scroll once they exceed the map.
18
18
  - ✅ **Advanced style editor** - Per-layer-type styling controls:
19
19
  - **Fill layers**: color, opacity, outline-color
20
20
  - **Line layers**: color, width, opacity, blur
@@ -24,6 +24,7 @@ A comprehensive layer control for MapLibre GL with advanced styling capabilities
24
24
  - ✅ **Dynamic layer detection** - Automatically detect and manage new layers
25
25
  - ✅ **Background layer grouping** - Control all basemap layers as one group
26
26
  - ✅ **Background layer legend** - Gear icon to toggle individual background layer visibility
27
+ - ✅ **Saved configurations** - Save the current basemap element visibility as a named preset and re-apply it with one click; presets persist across sessions and projects via `localStorage`
27
28
  - ✅ **Accessibility** - Full ARIA support and keyboard navigation
28
29
  - ✅ **TypeScript** - Full type safety and IntelliSense support
29
30
  - ✅ **React integration** - Optional React components and hooks
@@ -72,7 +73,7 @@ map.on('load', () => {
72
73
  layers: ['my-layer'], // LayerControl auto-detects opacity, visibility, and generates friendly names
73
74
  panelWidth: 340,
74
75
  panelMinWidth: 240,
75
- panelMaxWidth: 450
76
+ panelMaxWidth: 960
76
77
  });
77
78
 
78
79
  // Option 2: Auto-detect with basemapStyleUrl (recommended for reliable basemap detection)
@@ -92,7 +93,7 @@ map.on('load', () => {
92
93
  // collapsed: false,
93
94
  // panelWidth: 340,
94
95
  // panelMinWidth: 240,
95
- // panelMaxWidth: 450
96
+ // panelMaxWidth: 960
96
97
  // });
97
98
 
98
99
  // Option 4: Manually specify layer states (for full control over names)
@@ -166,8 +167,8 @@ function MapComponent() {
166
167
  | `layerStates` | `Record<string, LayerState>` | `undefined` | Manual layer state configuration |
167
168
  | `panelWidth` | `number` | `320` | Initial panel width in pixels |
168
169
  | `panelMinWidth` | `number` | `240` | Minimum panel width |
169
- | `panelMaxWidth` | `number` | `420` | Maximum panel width |
170
- | `panelMaxHeight` | `number` | `600` | Maximum panel height (scrollable when exceeded) |
170
+ | `panelMaxWidth` | `number` | `960` | Maximum panel width |
171
+ | `panelMaxHeight` | `number` | `undefined` | Maximum panel height in pixels. Omit to fill the available vertical space (scrollable only when the layer list is taller than the map) |
171
172
  | `showStyleEditor` | `boolean` | `true` | Show gear icon for style editor |
172
173
  | `showOpacitySlider` | `boolean` | `true` | Show opacity slider for layers |
173
174
  | `showLayerSymbol` | `boolean` | `true` | Show layer type symbols (colored icons) next to layer names |
@@ -175,6 +176,9 @@ function MapComponent() {
175
176
  | `excludeLayers` | `string[]` | `undefined` | Array of wildcard patterns to exclude layers by name (e.g., `['*-temp-*', 'debug-*']`) |
176
177
  | `customLayerAdapters` | `CustomLayerAdapter[]` | `undefined` | Adapters for non-MapLibre layers (deck.gl, Zarr, etc.) |
177
178
  | `basemapStyleUrl` | `string` | `undefined` | URL of basemap style JSON for reliable layer detection (see below) |
179
+ | `enableBackgroundPresets` | `boolean` | `true` | Show the "Saved configurations" controls in the Background Layers panel |
180
+ | `backgroundPresetStorageKey` | `string` | `'maplibre-layer-control:background-presets'` | `localStorage` key under which background visibility presets are stored |
181
+ | `onBackgroundPresetsChange` | `(presets: BackgroundPresets) => void` | `undefined` | Called whenever the saved preset set changes (created or deleted); applying a preset does not change the set, so it does not fire |
178
182
 
179
183
  ### LayerState
180
184
 
@@ -293,9 +297,31 @@ When using the `layers` option to specify specific layers, all other layers are
293
297
  - Quick "Show All" / "Hide All" buttons
294
298
  - **"Only rendered" filter** - Shows only layers that are currently rendered in the map viewport
295
299
  - Indeterminate checkbox state when some layers are hidden
300
+ - **Saved configurations** - Save the current set of visibility toggles as a named preset and re-apply it later with one click
296
301
 
297
302
  This allows fine-grained control over which basemap layers are visible while maintaining a simplified layer control interface.
298
303
 
304
+ #### Saved configurations (presets)
305
+
306
+ The Background Layers panel includes a **Saved configurations** section that lets users save the current basemap element visibility as a named preset and re-apply it with a single click. Presets are stored in `localStorage`, so they persist across page reloads and apply to any project that uses the same basemap layers. Toggle the UI off with `enableBackgroundPresets: false`, or change the storage location with `backgroundPresetStorageKey`.
307
+
308
+ The same functionality is available programmatically:
309
+
310
+ ```javascript
311
+ // Capture the current basemap element visibility
312
+ const visibility = layerControl.getBackgroundLayerVisibility();
313
+ // → { 'water': true, 'road-primary': false, ... }
314
+
315
+ // Apply a configuration (only layers present in the style are affected)
316
+ layerControl.applyBackgroundLayerVisibility(visibility);
317
+
318
+ // Named presets (persisted to localStorage)
319
+ layerControl.saveBackgroundPreset('Minimal'); // save current visibility
320
+ layerControl.getBackgroundPresets(); // → { Minimal: { ... } }
321
+ layerControl.applyBackgroundPreset('Minimal'); // → true if it existed
322
+ layerControl.deleteBackgroundPreset('Minimal');
323
+ ```
324
+
299
325
  ### Custom Layer Adapters
300
326
 
301
327
  The layer control supports non-MapLibre layers (such as deck.gl or Zarr layers) through the Custom Layer Adapter interface. This allows you to integrate any custom layer type with the layer control's visibility toggle, opacity slider, and layer list.
package/dist/index.cjs CHANGED
@@ -691,6 +691,7 @@ class LayerControl {
691
691
  __publicField(this, "minPanelWidth");
692
692
  __publicField(this, "maxPanelWidth");
693
693
  __publicField(this, "initialPanelWidth");
694
+ /** Explicit max panel height; null means fill available vertical space */
694
695
  __publicField(this, "maxPanelHeight");
695
696
  __publicField(this, "showStyleEditor");
696
697
  __publicField(this, "showOpacitySlider");
@@ -703,13 +704,6 @@ class LayerControl {
703
704
  __publicField(this, "nativeLayerGroups", /* @__PURE__ */ new Map());
704
705
  __publicField(this, "basemapStyleUrl", null);
705
706
  __publicField(this, "basemapLayerIds", null);
706
- __publicField(this, "widthSliderEl", null);
707
- __publicField(this, "widthThumbEl", null);
708
- __publicField(this, "widthValueEl", null);
709
- __publicField(this, "isWidthSliderActive", false);
710
- __publicField(this, "widthDragRectWidth", null);
711
- __publicField(this, "widthDragStartX", null);
712
- __publicField(this, "widthDragStartWidth", null);
713
707
  __publicField(this, "widthFrame", null);
714
708
  // Exact-opacity input popup
715
709
  __publicField(this, "opacityInputEl", null);
@@ -730,10 +724,14 @@ class LayerControl {
730
724
  __publicField(this, "onLayerRename");
731
725
  __publicField(this, "onLayerReorder");
732
726
  __publicField(this, "onLayerRemove");
727
+ // Background-layer visibility presets
728
+ __publicField(this, "enableBackgroundPresets");
729
+ __publicField(this, "backgroundPresetStorageKey");
730
+ __publicField(this, "onBackgroundPresetsChange");
733
731
  this.minPanelWidth = options.panelMinWidth || 240;
734
- this.maxPanelWidth = options.panelMaxWidth || 420;
732
+ this.maxPanelWidth = options.panelMaxWidth || 960;
735
733
  this.initialPanelWidth = options.panelWidth || 350;
736
- this.maxPanelHeight = options.panelMaxHeight || 600;
734
+ this.maxPanelHeight = options.panelMaxHeight ?? null;
737
735
  this.showStyleEditor = options.showStyleEditor !== false;
738
736
  this.showOpacitySlider = options.showOpacitySlider !== false;
739
737
  this.showLayerSymbol = options.showLayerSymbol !== false;
@@ -746,6 +744,9 @@ class LayerControl {
746
744
  this.onLayerRename = options.onLayerRename;
747
745
  this.onLayerReorder = options.onLayerReorder;
748
746
  this.onLayerRemove = options.onLayerRemove;
747
+ this.enableBackgroundPresets = options.enableBackgroundPresets !== false;
748
+ this.backgroundPresetStorageKey = options.backgroundPresetStorageKey || "maplibre-layer-control:background-presets";
749
+ this.onBackgroundPresetsChange = options.onBackgroundPresetsChange;
749
750
  this.initialLayerStates = options.layerStates || {};
750
751
  this.state = {
751
752
  collapsed: options.collapsed !== false,
@@ -811,7 +812,6 @@ class LayerControl {
811
812
  this.contextMenuEl = this.createContextMenu();
812
813
  this.mapContainer.appendChild(this.contextMenuEl);
813
814
  }
814
- this.updateWidthDisplay();
815
815
  this.setupEventListeners();
816
816
  if (this.basemapStyleUrl && !this.basemapLayerIds) {
817
817
  this.fetchBasemapStyle().then(() => {
@@ -1211,7 +1211,9 @@ class LayerControl {
1211
1211
  const panel = document.createElement("div");
1212
1212
  panel.className = "layer-control-panel";
1213
1213
  panel.style.width = `${this.state.panelWidth}px`;
1214
- panel.style.maxHeight = `${this.maxPanelHeight}px`;
1214
+ if (this.maxPanelHeight != null) {
1215
+ panel.style.maxHeight = `${this.maxPanelHeight}px`;
1216
+ }
1215
1217
  if (!this.state.collapsed) {
1216
1218
  panel.classList.add("expanded");
1217
1219
  }
@@ -1378,6 +1380,15 @@ class LayerControl {
1378
1380
  this.panel.style.right = `${buttonRight}px`;
1379
1381
  break;
1380
1382
  }
1383
+ const edgeMargin = 8;
1384
+ const isTopAnchored = position === "top-left" || position === "top-right";
1385
+ const occupiedFromEdge = isTopAnchored ? buttonTop + buttonRect.height + panelGap : buttonBottom + buttonRect.height + panelGap;
1386
+ const availableHeight = Math.max(
1387
+ 120,
1388
+ Math.round(mapRect.height - occupiedFromEdge - edgeMargin)
1389
+ );
1390
+ const maxHeight = this.maxPanelHeight != null ? Math.min(this.maxPanelHeight, availableHeight) : availableHeight;
1391
+ this.panel.style.maxHeight = `${maxHeight}px`;
1381
1392
  }
1382
1393
  /**
1383
1394
  * Create action buttons for Show All / Hide All
@@ -1426,7 +1437,8 @@ class LayerControl {
1426
1437
  });
1427
1438
  }
1428
1439
  /**
1429
- * Create the panel header with title and width control
1440
+ * Create the panel header with the title. Panel width is adjusted by
1441
+ * dragging either edge of the panel (see createResizeHandle).
1430
1442
  */
1431
1443
  createPanelHeader() {
1432
1444
  const header = document.createElement("div");
@@ -1435,135 +1447,8 @@ class LayerControl {
1435
1447
  title.className = "layer-control-panel-title";
1436
1448
  title.textContent = "Layers";
1437
1449
  header.appendChild(title);
1438
- const widthControl = this.createWidthControl();
1439
- header.appendChild(widthControl);
1440
1450
  return header;
1441
1451
  }
1442
- /**
1443
- * Create the width control slider
1444
- */
1445
- createWidthControl() {
1446
- const widthControl = document.createElement("label");
1447
- widthControl.className = "layer-control-width-control";
1448
- widthControl.title = "Adjust layer panel width";
1449
- const widthLabel = document.createElement("span");
1450
- widthLabel.textContent = "Width";
1451
- widthControl.appendChild(widthLabel);
1452
- const widthSlider = document.createElement("div");
1453
- widthSlider.className = "layer-control-width-slider";
1454
- widthSlider.setAttribute("role", "slider");
1455
- widthSlider.setAttribute("aria-valuemin", String(this.minPanelWidth));
1456
- widthSlider.setAttribute("aria-valuemax", String(this.maxPanelWidth));
1457
- widthSlider.setAttribute("aria-valuenow", String(this.state.panelWidth));
1458
- widthSlider.setAttribute("aria-valuestep", "10");
1459
- widthSlider.setAttribute("aria-label", "Layer panel width");
1460
- widthSlider.tabIndex = 0;
1461
- const widthTrack = document.createElement("div");
1462
- widthTrack.className = "layer-control-width-track";
1463
- const widthThumb = document.createElement("div");
1464
- widthThumb.className = "layer-control-width-thumb";
1465
- widthSlider.appendChild(widthTrack);
1466
- widthSlider.appendChild(widthThumb);
1467
- this.widthSliderEl = widthSlider;
1468
- this.widthThumbEl = widthThumb;
1469
- const widthValue = document.createElement("span");
1470
- widthValue.className = "layer-control-width-value";
1471
- this.widthValueEl = widthValue;
1472
- widthControl.appendChild(widthSlider);
1473
- widthControl.appendChild(widthValue);
1474
- this.updateWidthDisplay();
1475
- this.setupWidthSliderEvents(widthSlider);
1476
- return widthControl;
1477
- }
1478
- /**
1479
- * Setup event listeners for width slider
1480
- */
1481
- setupWidthSliderEvents(widthSlider) {
1482
- widthSlider.addEventListener("pointerdown", (event) => {
1483
- event.preventDefault();
1484
- const rect = widthSlider.getBoundingClientRect();
1485
- this.widthDragRectWidth = rect.width || 1;
1486
- this.widthDragStartX = event.clientX;
1487
- this.widthDragStartWidth = this.state.panelWidth;
1488
- this.isWidthSliderActive = true;
1489
- widthSlider.setPointerCapture(event.pointerId);
1490
- this.updateWidthFromPointer(event, true);
1491
- });
1492
- widthSlider.addEventListener("pointermove", (event) => {
1493
- if (!this.isWidthSliderActive) return;
1494
- this.updateWidthFromPointer(event);
1495
- });
1496
- const endPointerDrag = (event) => {
1497
- if (!this.isWidthSliderActive) return;
1498
- if (event.pointerId !== void 0) {
1499
- try {
1500
- widthSlider.releasePointerCapture(event.pointerId);
1501
- } catch (error) {
1502
- }
1503
- }
1504
- this.isWidthSliderActive = false;
1505
- this.widthDragRectWidth = null;
1506
- this.widthDragStartX = null;
1507
- this.widthDragStartWidth = null;
1508
- this.updateWidthDisplay();
1509
- };
1510
- widthSlider.addEventListener("pointerup", endPointerDrag);
1511
- widthSlider.addEventListener("pointercancel", endPointerDrag);
1512
- widthSlider.addEventListener("lostpointercapture", endPointerDrag);
1513
- widthSlider.addEventListener("keydown", (event) => {
1514
- let handled = true;
1515
- const step = event.shiftKey ? 20 : 10;
1516
- switch (event.key) {
1517
- case "ArrowLeft":
1518
- case "ArrowDown":
1519
- this.applyPanelWidth(this.state.panelWidth - step, true);
1520
- break;
1521
- case "ArrowRight":
1522
- case "ArrowUp":
1523
- this.applyPanelWidth(this.state.panelWidth + step, true);
1524
- break;
1525
- case "Home":
1526
- this.applyPanelWidth(this.minPanelWidth, true);
1527
- break;
1528
- case "End":
1529
- this.applyPanelWidth(this.maxPanelWidth, true);
1530
- break;
1531
- case "PageUp":
1532
- this.applyPanelWidth(this.state.panelWidth + 50, true);
1533
- break;
1534
- case "PageDown":
1535
- this.applyPanelWidth(this.state.panelWidth - 50, true);
1536
- break;
1537
- default:
1538
- handled = false;
1539
- }
1540
- if (handled) {
1541
- event.preventDefault();
1542
- this.updateWidthDisplay();
1543
- }
1544
- });
1545
- }
1546
- /**
1547
- * Update panel width from pointer event
1548
- */
1549
- updateWidthFromPointer(event, resetBaseline = false) {
1550
- if (!this.widthSliderEl) return;
1551
- const sliderWidth = this.widthDragRectWidth || this.widthSliderEl.getBoundingClientRect().width || 1;
1552
- const widthRange = this.maxPanelWidth - this.minPanelWidth;
1553
- let width;
1554
- if (resetBaseline) {
1555
- const rect = this.widthSliderEl.getBoundingClientRect();
1556
- const relative = rect.width > 0 ? (event.clientX - rect.left) / rect.width : 0;
1557
- const clampedRatio = Math.min(1, Math.max(0, relative));
1558
- width = this.minPanelWidth + clampedRatio * widthRange;
1559
- this.widthDragStartWidth = width;
1560
- this.widthDragStartX = event.clientX;
1561
- } else {
1562
- const delta = event.clientX - (this.widthDragStartX || event.clientX);
1563
- width = (this.widthDragStartWidth || this.state.panelWidth) + delta / sliderWidth * widthRange;
1564
- }
1565
- this.applyPanelWidth(width, this.isWidthSliderActive);
1566
- }
1567
1452
  /**
1568
1453
  * Apply panel width (clamped to min/max)
1569
1454
  */
@@ -1575,7 +1460,6 @@ class LayerControl {
1575
1460
  this.state.panelWidth = clamped;
1576
1461
  const px = `${clamped}px`;
1577
1462
  this.panel.style.width = px;
1578
- this.updateWidthDisplay();
1579
1463
  };
1580
1464
  if (immediate) {
1581
1465
  applyWidth();
@@ -1589,34 +1473,6 @@ class LayerControl {
1589
1473
  this.widthFrame = null;
1590
1474
  });
1591
1475
  }
1592
- /**
1593
- * Update width display (value label and thumb position)
1594
- */
1595
- updateWidthDisplay() {
1596
- if (this.widthValueEl) {
1597
- this.widthValueEl.textContent = `${this.state.panelWidth}px`;
1598
- }
1599
- if (this.widthSliderEl) {
1600
- this.widthSliderEl.setAttribute(
1601
- "aria-valuenow",
1602
- String(this.state.panelWidth)
1603
- );
1604
- const ratio = (this.state.panelWidth - this.minPanelWidth) / (this.maxPanelWidth - this.minPanelWidth || 1);
1605
- if (this.widthThumbEl) {
1606
- const sliderWidth = this.widthSliderEl.clientWidth;
1607
- if (sliderWidth === 0) {
1608
- requestAnimationFrame(() => this.updateWidthDisplay());
1609
- return;
1610
- }
1611
- const thumbWidth = this.widthThumbEl.offsetWidth || 14;
1612
- const padding = 16;
1613
- const available = Math.max(0, sliderWidth - padding - thumbWidth);
1614
- const clampedRatio = Math.min(1, Math.max(0, ratio));
1615
- const leftPx = 8 + available * clampedRatio;
1616
- this.widthThumbEl.style.left = `${leftPx}px`;
1617
- }
1618
- }
1619
- }
1620
1476
  /**
1621
1477
  * Setup main event listeners
1622
1478
  */
@@ -1715,7 +1571,21 @@ class LayerControl {
1715
1571
  const existingItems = this.panel.querySelectorAll(".layer-control-item");
1716
1572
  existingItems.forEach((item) => item.remove());
1717
1573
  this.styleEditors.clear();
1718
- Object.entries(this.state.layerStates).forEach(([layerId, state]) => {
1574
+ const orderedLayerIds = this.getUserLayerIdsInMapOrder();
1575
+ const captured = new Set(orderedLayerIds);
1576
+ for (const layerId of Object.keys(this.state.layerStates)) {
1577
+ if (layerId !== "Background" && !captured.has(layerId)) {
1578
+ orderedLayerIds.push(layerId);
1579
+ }
1580
+ }
1581
+ if (this.state.layerStates["Background"]) {
1582
+ orderedLayerIds.push("Background");
1583
+ }
1584
+ orderedLayerIds.forEach((layerId) => {
1585
+ const state = this.state.layerStates[layerId];
1586
+ if (!state) {
1587
+ return;
1588
+ }
1719
1589
  if (this.targetLayers.length === 0 || this.targetLayers.includes(layerId)) {
1720
1590
  this.addLayerItem(layerId, state);
1721
1591
  }
@@ -2234,8 +2104,112 @@ class LayerControl {
2234
2104
  panel.appendChild(actionsRow);
2235
2105
  panel.appendChild(filterRow);
2236
2106
  panel.appendChild(layerList);
2107
+ if (this.enableBackgroundPresets) {
2108
+ panel.appendChild(this.createBackgroundPresetsRow());
2109
+ }
2237
2110
  return panel;
2238
2111
  }
2112
+ /**
2113
+ * Build the "Saved configurations" controls: a dropdown of saved presets with
2114
+ * Apply/Delete actions, plus a name field and Save button to capture the
2115
+ * current basemap element visibility as a reusable, persisted preset.
2116
+ */
2117
+ createBackgroundPresetsRow() {
2118
+ const section = document.createElement("div");
2119
+ section.className = "background-legend-presets";
2120
+ const label = document.createElement("div");
2121
+ label.className = "background-legend-presets-label";
2122
+ label.textContent = "Saved configurations";
2123
+ section.appendChild(label);
2124
+ const selectRow = document.createElement("div");
2125
+ selectRow.className = "background-legend-presets-row";
2126
+ const select = document.createElement("select");
2127
+ select.className = "background-legend-presets-select";
2128
+ select.title = "Saved configurations";
2129
+ const applyBtn = document.createElement("button");
2130
+ applyBtn.className = "background-legend-action-btn";
2131
+ applyBtn.textContent = "Apply";
2132
+ applyBtn.title = "Apply the selected configuration";
2133
+ const deleteBtn = document.createElement("button");
2134
+ deleteBtn.className = "background-legend-action-btn";
2135
+ deleteBtn.textContent = "Delete";
2136
+ deleteBtn.title = "Delete the selected configuration";
2137
+ const refreshSelect = (selected) => {
2138
+ const presets = this.getBackgroundPresets();
2139
+ const names = Object.keys(presets).sort(
2140
+ (a, b) => a.localeCompare(b, void 0, { sensitivity: "base" })
2141
+ );
2142
+ select.innerHTML = "";
2143
+ const placeholder = document.createElement("option");
2144
+ placeholder.value = "";
2145
+ placeholder.textContent = names.length ? "Select a configuration…" : "No saved configurations";
2146
+ placeholder.disabled = names.length > 0;
2147
+ select.appendChild(placeholder);
2148
+ names.forEach((name) => {
2149
+ const option = document.createElement("option");
2150
+ option.value = name;
2151
+ option.textContent = name;
2152
+ select.appendChild(option);
2153
+ });
2154
+ select.value = selected && names.includes(selected) ? selected : "";
2155
+ const hasSelection = select.value !== "";
2156
+ applyBtn.disabled = !hasSelection;
2157
+ deleteBtn.disabled = !hasSelection;
2158
+ };
2159
+ select.addEventListener("change", () => {
2160
+ const hasSelection = select.value !== "";
2161
+ applyBtn.disabled = !hasSelection;
2162
+ deleteBtn.disabled = !hasSelection;
2163
+ });
2164
+ applyBtn.addEventListener("click", (e) => {
2165
+ e.stopPropagation();
2166
+ if (select.value) this.applyBackgroundPreset(select.value);
2167
+ });
2168
+ deleteBtn.addEventListener("click", (e) => {
2169
+ e.stopPropagation();
2170
+ if (select.value) {
2171
+ this.deleteBackgroundPreset(select.value);
2172
+ refreshSelect();
2173
+ }
2174
+ });
2175
+ selectRow.appendChild(select);
2176
+ selectRow.appendChild(applyBtn);
2177
+ selectRow.appendChild(deleteBtn);
2178
+ const saveRow = document.createElement("div");
2179
+ saveRow.className = "background-legend-presets-row";
2180
+ const nameInput = document.createElement("input");
2181
+ nameInput.type = "text";
2182
+ nameInput.className = "background-legend-presets-input";
2183
+ nameInput.placeholder = "Configuration name…";
2184
+ nameInput.maxLength = 60;
2185
+ const saveBtn = document.createElement("button");
2186
+ saveBtn.className = "background-legend-action-btn";
2187
+ saveBtn.textContent = "Save";
2188
+ saveBtn.title = "Save the current visibility as a configuration";
2189
+ const commitSave = () => {
2190
+ const name = nameInput.value.trim();
2191
+ if (!name) return;
2192
+ this.saveBackgroundPreset(name);
2193
+ nameInput.value = "";
2194
+ refreshSelect(name);
2195
+ };
2196
+ saveBtn.addEventListener("click", (e) => {
2197
+ e.stopPropagation();
2198
+ commitSave();
2199
+ });
2200
+ nameInput.addEventListener("keydown", (e) => {
2201
+ if (e.key === "Enter") {
2202
+ e.preventDefault();
2203
+ commitSave();
2204
+ }
2205
+ });
2206
+ saveRow.appendChild(nameInput);
2207
+ saveRow.appendChild(saveBtn);
2208
+ section.appendChild(selectRow);
2209
+ section.appendChild(saveRow);
2210
+ refreshSelect();
2211
+ return section;
2212
+ }
2239
2213
  /**
2240
2214
  * Check if a layer is currently rendered in the map viewport
2241
2215
  */
@@ -2383,6 +2357,148 @@ class LayerControl {
2383
2357
  this.state.layerStates["Background"].visible = anyVisible;
2384
2358
  }
2385
2359
  }
2360
+ /**
2361
+ * Return the IDs of the background (basemap) style layers the control can
2362
+ * toggle, honoring the drawn-layer and user-defined exclusion filters but not
2363
+ * the transient "only rendered" view filter.
2364
+ */
2365
+ getControllableBackgroundLayerIds() {
2366
+ const styleLayers = this.map.getStyle().layers || [];
2367
+ return styleLayers.filter((layer) => {
2368
+ if (this.isUserAddedLayer(layer.id)) return false;
2369
+ if (this.excludeDrawnLayers && this.isDrawnLayer(layer.id))
2370
+ return false;
2371
+ if (this.isExcludedByPattern(layer.id)) return false;
2372
+ return true;
2373
+ }).map((layer) => layer.id);
2374
+ }
2375
+ /**
2376
+ * Get the current visibility of every controllable background (basemap) layer.
2377
+ *
2378
+ * @returns A map of style-layer ID to whether it is currently visible.
2379
+ */
2380
+ getBackgroundLayerVisibility() {
2381
+ const visibility = {};
2382
+ for (const layerId of this.getControllableBackgroundLayerIds()) {
2383
+ const value = this.map.getLayoutProperty(layerId, "visibility");
2384
+ visibility[layerId] = value !== "none";
2385
+ }
2386
+ return visibility;
2387
+ }
2388
+ /**
2389
+ * Apply a saved background-layer visibility configuration to the map. Only
2390
+ * layers that currently exist in the style are affected, so a configuration
2391
+ * captured on one basemap degrades gracefully when applied to another.
2392
+ *
2393
+ * @param visibility - Map of style-layer ID to desired visibility.
2394
+ */
2395
+ applyBackgroundLayerVisibility(visibility) {
2396
+ for (const [layerId, visible] of Object.entries(visibility)) {
2397
+ if (this.isUserAddedLayer(layerId)) continue;
2398
+ if (!this.map.getLayer(layerId)) continue;
2399
+ this.state.backgroundLayerVisibility.set(layerId, visible);
2400
+ this.map.setLayoutProperty(
2401
+ layerId,
2402
+ "visibility",
2403
+ visible ? "visible" : "none"
2404
+ );
2405
+ }
2406
+ const legendPanel = this.panel.querySelector(
2407
+ ".background-legend-layer-list"
2408
+ );
2409
+ if (legendPanel) {
2410
+ this.populateBackgroundLayerList(legendPanel);
2411
+ }
2412
+ this.updateBackgroundCheckboxState();
2413
+ }
2414
+ /**
2415
+ * Read all saved background-layer presets from localStorage. Returns an empty
2416
+ * object when storage is unavailable or the stored value is malformed.
2417
+ */
2418
+ getBackgroundPresets() {
2419
+ try {
2420
+ const raw = typeof localStorage !== "undefined" ? localStorage.getItem(this.backgroundPresetStorageKey) : null;
2421
+ if (!raw) return {};
2422
+ const parsed = JSON.parse(raw);
2423
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
2424
+ return {};
2425
+ }
2426
+ const presets = {};
2427
+ for (const [name, value] of Object.entries(parsed)) {
2428
+ if (this.isUnsafePresetKey(name)) continue;
2429
+ if (value && typeof value === "object" && !Array.isArray(value)) {
2430
+ presets[name] = value;
2431
+ }
2432
+ }
2433
+ return presets;
2434
+ } catch {
2435
+ return {};
2436
+ }
2437
+ }
2438
+ /**
2439
+ * Reject preset names that, used as object keys, could pollute the prototype
2440
+ * chain or otherwise alias built-in object members.
2441
+ */
2442
+ isUnsafePresetKey(name) {
2443
+ return name === "__proto__" || name === "constructor" || name === "prototype";
2444
+ }
2445
+ /**
2446
+ * Persist the full preset map to localStorage and notify listeners.
2447
+ */
2448
+ writeBackgroundPresets(presets) {
2449
+ var _a;
2450
+ try {
2451
+ if (typeof localStorage !== "undefined") {
2452
+ localStorage.setItem(
2453
+ this.backgroundPresetStorageKey,
2454
+ JSON.stringify(presets)
2455
+ );
2456
+ }
2457
+ } catch {
2458
+ }
2459
+ (_a = this.onBackgroundPresetsChange) == null ? void 0 : _a.call(this, presets);
2460
+ }
2461
+ /**
2462
+ * Save the current background-layer visibility as a named preset. Reusing an
2463
+ * existing name overwrites that preset.
2464
+ *
2465
+ * @param name - Preset name; whitespace is trimmed. Empty names are ignored.
2466
+ * @returns The updated preset map.
2467
+ */
2468
+ saveBackgroundPreset(name) {
2469
+ const trimmed = name.trim();
2470
+ const presets = this.getBackgroundPresets();
2471
+ if (!trimmed || this.isUnsafePresetKey(trimmed)) return presets;
2472
+ presets[trimmed] = this.getBackgroundLayerVisibility();
2473
+ this.writeBackgroundPresets(presets);
2474
+ return presets;
2475
+ }
2476
+ /**
2477
+ * Apply a previously saved preset to the map.
2478
+ *
2479
+ * @param name - The preset name to apply.
2480
+ * @returns `true` if a preset with that name existed and was applied.
2481
+ */
2482
+ applyBackgroundPreset(name) {
2483
+ const presets = this.getBackgroundPresets();
2484
+ if (!Object.prototype.hasOwnProperty.call(presets, name)) return false;
2485
+ this.applyBackgroundLayerVisibility(presets[name]);
2486
+ return true;
2487
+ }
2488
+ /**
2489
+ * Delete a saved preset.
2490
+ *
2491
+ * @param name - The preset name to remove.
2492
+ * @returns The updated preset map.
2493
+ */
2494
+ deleteBackgroundPreset(name) {
2495
+ const presets = this.getBackgroundPresets();
2496
+ if (Object.prototype.hasOwnProperty.call(presets, name)) {
2497
+ delete presets[name];
2498
+ this.writeBackgroundPresets(presets);
2499
+ }
2500
+ return presets;
2501
+ }
2386
2502
  /**
2387
2503
  * Create style button for a layer
2388
2504
  */
@@ -4068,18 +4184,17 @@ class LayerControl {
4068
4184
  uiLayerIds.push(layerId);
4069
4185
  }
4070
4186
  });
4071
- const reversedIds = [...uiLayerIds].reverse();
4072
4187
  const style = this.map.getStyle();
4073
4188
  const mapLibreLayerIds = new Set(((_a = style == null ? void 0 : style.layers) == null ? void 0 : _a.map((l) => l.id)) || []);
4074
- for (let i = 0; i < reversedIds.length; i++) {
4075
- const layerId = reversedIds[i];
4189
+ for (let i = 0; i < uiLayerIds.length; i++) {
4190
+ const layerId = uiLayerIds[i];
4076
4191
  if (!mapLibreLayerIds.has(layerId)) {
4077
4192
  continue;
4078
4193
  }
4079
4194
  let beforeId = void 0;
4080
4195
  for (let j = i - 1; j >= 0; j--) {
4081
- if (mapLibreLayerIds.has(reversedIds[j])) {
4082
- beforeId = reversedIds[j];
4196
+ if (mapLibreLayerIds.has(uiLayerIds[j])) {
4197
+ beforeId = uiLayerIds[j];
4083
4198
  break;
4084
4199
  }
4085
4200
  }