ol-layer-control 0.1.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.
@@ -0,0 +1,1132 @@
1
+ /**
2
+ * ol-layer-control
3
+ *
4
+ * A layer control for OpenLayers: a map button that opens a docked side
5
+ * panel in which the user can switch layers and layer groups on and off.
6
+ *
7
+ * The panel is rendered *next to* the map, never on top of it. By default
8
+ * the control inserts the panel as a sibling of the map's target element
9
+ * and shrinks the map while the panel is open. Pass `panelTarget` to
10
+ * render the panel somewhere else and handle the layout yourself.
11
+ *
12
+ * Layer properties the control understands (set them via layer options or
13
+ * `layer.set(...)`):
14
+ *
15
+ * title string Text shown in the panel. Layers without
16
+ * a title are not shown.
17
+ * displayInLayerControl boolean `false` hides the layer (or group).
18
+ * type 'base' Marks a basemap. A group that contains
19
+ * basemaps is exclusive and gets no
20
+ * checkbox of its own.
21
+ * exclusive boolean On a LayerGroup: children are rendered
22
+ * as radio buttons (only one visible).
23
+ * Right-clicking a group title toggles
24
+ * this (see `exclusiveToggle` option).
25
+ * combine boolean On a LayerGroup: rendered as a single
26
+ * layer instead of a group.
27
+ * folded boolean On a LayerGroup: start collapsed.
28
+ *
29
+ * Layers (and groups) with a minResolution/maxResolution or minZoom/maxZoom
30
+ * outside the current view are shown dimmed, with a tooltip. So are layers
31
+ * inside a switched-off group. They can still be switched on and off.
32
+ *
33
+ * @module ol-layer-control
34
+ */
35
+
36
+ import Control from 'ol/control/Control.js';
37
+ import LayerGroup from 'ol/layer/Group.js';
38
+ import {unByKey} from 'ol/Observable.js';
39
+
40
+ // The stylesheet ships with the control. Bundlers (Vite, webpack, Parcel,
41
+ // Rollup + PostCSS) pick it up from here, so users import only this module.
42
+ import './ol-layer-control.css';
43
+
44
+ /**
45
+ * All user-visible strings. Pass a (partial) object with the same keys as
46
+ * the `i18n` option to translate the UI.
47
+ *
48
+ * @typedef {Object} LayerControlI18n
49
+ * @property {string} buttonTitle Tooltip / accessible name of the map button.
50
+ * @property {string} panelTitle Heading of the panel.
51
+ * @property {string} closeTitle Tooltip of the close button.
52
+ * @property {string} resizeTitle Tooltip of the resize handle.
53
+ * @property {string} collapseTitle Tooltip of the fold button when a group is expanded.
54
+ * @property {string} expandTitle Tooltip of the fold button when a group is collapsed.
55
+ * @property {string} exclusiveHint Tooltip of a group title when it can be right-clicked
56
+ * to switch between exclusive and non-exclusive.
57
+ * @property {string} outOfRangeHint Tooltip of a layer that is not shown at the
58
+ * current zoom level (outside its min/max resolution or zoom).
59
+ * @property {string} groupOffHint Tooltip of a layer inside a switched-off group.
60
+ * `{group}` is replaced by the title of that group.
61
+ * @property {string} opacityTitle Tooltip of the opacity button and label of the slider.
62
+ * @property {string} searchToggleTitle Tooltip of the panel title (click shows/hides the search box).
63
+ * @property {string} searchPlaceholder Placeholder text of the search box.
64
+ * @property {string} searchClearTitle Tooltip of the button that clears the search box.
65
+ * @property {string} searchNoResults Message shown when no layer matches the search.
66
+ */
67
+
68
+ /** @type {LayerControlI18n} */
69
+ export const DEFAULT_I18N = Object.freeze({
70
+ buttonTitle: 'Layers',
71
+ panelTitle: 'Layers',
72
+ closeTitle: 'Close layer panel',
73
+ resizeTitle: 'Drag to resize, double-click to reset',
74
+ collapseTitle: 'Collapse group',
75
+ expandTitle: 'Expand group',
76
+ exclusiveHint: 'Right-click to switch between one-at-a-time and multiple layers',
77
+ outOfRangeHint: 'Not shown at the current zoom level',
78
+ groupOffHint: 'Not shown: group "{group}" is switched off',
79
+ opacityTitle: 'Opacity',
80
+ searchToggleTitle: 'Show or hide the search box',
81
+ searchPlaceholder: 'Search layers',
82
+ searchClearTitle: 'Clear search',
83
+ searchNoResults: 'No layers match',
84
+ });
85
+
86
+ /**
87
+ * @typedef {Object} LayerControlOptions
88
+ * @property {HTMLElement|string} [target] Where to render the map button
89
+ * (standard OpenLayers control option). Default: the map's control container.
90
+ * @property {HTMLElement|string} [panelTarget] Element (or element id) to
91
+ * render the panel into. When set, the control leaves the map element
92
+ * alone and the host page is responsible for the layout.
93
+ * @property {'left'|'right'} [side='right'] Side of the map the panel docks to.
94
+ * @property {ButtonPosition} [buttonPosition] Where the map button sits. Defaults to
95
+ * `'top-left'` when `side` is `'left'` and `'right'` otherwise.
96
+ * @property {number} [panelWidth=320] Initial panel width in pixels.
97
+ * @property {number} [minPanelWidth=200] Smallest width the user can resize to.
98
+ * @property {number} [maxPanelWidth=800] Largest width the user can resize to.
99
+ * @property {boolean} [resizable=true] Show a drag handle to resize the panel.
100
+ * @property {boolean} [open=false] Start with the panel open.
101
+ * @property {boolean} [reverse=true] List layers top-most first (rendering order reversed).
102
+ * @property {boolean} [exclusiveToggle=true] Let the user switch a group between
103
+ * exclusive (radio buttons) and non-exclusive (checkboxes) by right-clicking
104
+ * its title. Basemap groups are always exclusive.
105
+ * @property {boolean} [opacitySlider=false] Give every layer a button that reveals
106
+ * an opacity slider.
107
+ * @property {boolean} [search=false] Show the search box from the start. The user
108
+ * can always show or hide it by clicking the panel title.
109
+ * @property {Partial<LayerControlI18n>} [i18n] Translations, see {@link DEFAULT_I18N}.
110
+ */
111
+
112
+ const CSS = 'ol-layer-control';
113
+
114
+ /**
115
+ * Where the map button sits on the map. Each position leaves room for the
116
+ * OpenLayers default control that normally occupies that corner:
117
+ *
118
+ * 'top-left' below the Zoom buttons
119
+ * 'bottom-left' in the bottom-left corner
120
+ * 'top-right' in the top-right corner (shared with the Rotate button,
121
+ * which only shows when the map is rotated)
122
+ * 'right' top-right, below the Rotate button
123
+ * 'bottom-right' above the Attribution button
124
+ *
125
+ * @typedef {'top-left'|'bottom-left'|'top-right'|'right'|'bottom-right'} ButtonPosition
126
+ */
127
+
128
+ /** @type {ReadonlyArray<ButtonPosition>} */
129
+ export const BUTTON_POSITIONS = Object.freeze(['top-left', 'bottom-left', 'top-right', 'right', 'bottom-right']);
130
+
131
+ const LAYERS_ICON =
132
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
133
+ '<path d="M12 3 2 8l10 5 10-5-10-5z"/><path d="m2 12 10 5 10-5"/><path d="m2 16 10 5 10-5"/></svg>';
134
+
135
+ const CLOSE_ICON =
136
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">' +
137
+ '<path d="M6 6l12 12M18 6 6 18"/></svg>';
138
+
139
+ const OPACITY_ICON =
140
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true">' +
141
+ '<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2"/>' +
142
+ '<path d="M12 3a9 9 0 0 1 0 18z" fill="currentColor"/></svg>';
143
+
144
+ const SEARCH_ICON =
145
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">' +
146
+ '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>';
147
+
148
+ const CHEVRON_ICON =
149
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
150
+ '<path d="m9 6 6 6-6 6"/></svg>';
151
+
152
+ let idCounter = 0;
153
+
154
+ /**
155
+ * @param {string} tag
156
+ * @param {string} [className]
157
+ * @return {HTMLElement}
158
+ */
159
+ function el(tag, className) {
160
+ const node = document.createElement(tag);
161
+ if (className) {
162
+ node.className = className;
163
+ }
164
+ return node;
165
+ }
166
+
167
+ /**
168
+ * Whether a layer's own resolution/zoom limits allow it to be drawn at the
169
+ * view's current resolution. Ignores the layer's `visible` flag.
170
+ *
171
+ * @param {import('ol/layer/Base.js').default} layer
172
+ * @param {number} resolution
173
+ * @param {number} zoom
174
+ * @return {boolean}
175
+ */
176
+ function inResolutionRange(layer, resolution, zoom) {
177
+ return (
178
+ resolution >= layer.getMinResolution() &&
179
+ resolution < layer.getMaxResolution() &&
180
+ zoom > layer.getMinZoom() &&
181
+ zoom <= layer.getMaxZoom()
182
+ );
183
+ }
184
+
185
+ /**
186
+ * @param {HTMLElement|string|undefined} target
187
+ * @return {HTMLElement|null}
188
+ */
189
+ function resolveElement(target) {
190
+ if (!target) {
191
+ return null;
192
+ }
193
+ return typeof target === 'string' ? document.getElementById(target) : target;
194
+ }
195
+
196
+ /**
197
+ * Layer control: a map button plus a docked, resizable side panel.
198
+ *
199
+ * Observable properties (use `control.on('change:open', ...)` etc.):
200
+ * - `open` boolean, whether the panel is shown
201
+ * - `panelWidth` number, current panel width in pixels
202
+ *
203
+ * @extends {Control}
204
+ */
205
+ export default class LayerControl extends Control {
206
+ /**
207
+ * @param {LayerControlOptions} [options]
208
+ */
209
+ constructor(options = {}) {
210
+ const element = el('div', `${CSS} ol-unselectable ol-control`);
211
+ super({element, target: options.target});
212
+
213
+ /** @private @type {LayerControlI18n} */
214
+ this.i18n_ = {...DEFAULT_I18N, ...(options.i18n || {})};
215
+ /** @private @type {'left'|'right'} */
216
+ this.side_ = options.side === 'left' ? 'left' : 'right';
217
+ /** @private @type {ButtonPosition} */
218
+ this.defaultButtonPosition_ = this.side_ === 'left' ? 'top-left' : 'right';
219
+ /** @private */
220
+ this.defaultPanelWidth_ = options.panelWidth ?? 320;
221
+ /** @private */
222
+ this.minPanelWidth_ = options.minPanelWidth ?? 200;
223
+ /** @private */
224
+ this.maxPanelWidth_ = options.maxPanelWidth ?? 800;
225
+ /** @private */
226
+ this.resizable_ = options.resizable !== false;
227
+ /** @private */
228
+ this.reverse_ = options.reverse !== false;
229
+ /** @private */
230
+ this.exclusiveToggle_ = options.exclusiveToggle !== false;
231
+ /** @private */
232
+ this.opacitySlider_ = options.opacitySlider === true;
233
+ /** @private @type {HTMLElement|null} */
234
+ this.panelTarget_ = resolveElement(options.panelTarget);
235
+
236
+ /** @private @type {HTMLElement|null} The map's target element we shrink. */
237
+ this.hostElement_ = null;
238
+ /** @private @type {{width: string, marginLeft: string}|null} */
239
+ this.hostSavedStyle_ = null;
240
+ /** @private @type {Array<import('ol/events.js').EventsKey>} */
241
+ this.mapListenerKeys_ = [];
242
+ /** @private @type {Array<import('ol/events.js').EventsKey>} */
243
+ this.layerListenerKeys_ = [];
244
+ /** @private */
245
+ this.rebuildScheduled_ = false;
246
+ /** @private */
247
+ this.layoutScheduled_ = false;
248
+ /** @private @type {Array<HTMLButtonElement>} */
249
+ this.foldButtons_ = [];
250
+ /** @private @type {Array<{layer: import('ol/layer/Base.js').default, parent: LayerGroup|null, item: HTMLElement, label: HTMLElement}>} */
251
+ this.entries_ = [];
252
+ /** @private @type {Map<import('ol/layer/Base.js').default, LayerGroup|null>} */
253
+ this.parents_ = new Map();
254
+ /** @private @type {Array<import('ol/events.js').EventsKey>} */
255
+ this.viewListenerKeys_ = [];
256
+ /** @private @type {HTMLElement|null} */
257
+ this.resizer_ = null;
258
+
259
+ // --- map button -------------------------------------------------------
260
+ /** @private @type {HTMLButtonElement} */
261
+ this.button_ = /** @type {HTMLButtonElement} */ (el('button'));
262
+ this.button_.type = 'button';
263
+ this.button_.innerHTML = LAYERS_ICON;
264
+ this.button_.addEventListener('click', () => this.toggle());
265
+ element.appendChild(this.button_);
266
+
267
+ // --- panel ------------------------------------------------------------
268
+ /** @private @type {HTMLElement} */
269
+ this.panel_ = el('div', `${CSS}-panel ${CSS}-panel--${this.side_}`);
270
+ this.panel_.id = `${CSS}-panel-${++idCounter}`;
271
+ this.panel_.setAttribute('role', 'region');
272
+ this.panel_.addEventListener('keydown', (event) => {
273
+ if (event.key === 'Escape') {
274
+ this.close();
275
+ this.button_.focus();
276
+ }
277
+ });
278
+ this.button_.setAttribute('aria-controls', this.panel_.id);
279
+
280
+ if (this.resizable_) {
281
+ this.resizer_ = el('div', `${CSS}-resizer`);
282
+ this.initResizer_(this.resizer_);
283
+ this.panel_.appendChild(this.resizer_);
284
+ }
285
+
286
+ const header = el('div', `${CSS}-header`);
287
+ /** @private @type {HTMLButtonElement} */
288
+ this.titleElement_ = /** @type {HTMLButtonElement} */ (el('button', `${CSS}-title`));
289
+ this.titleElement_.type = 'button';
290
+ this.titleElement_.addEventListener('click', () => this.toggleSearch());
291
+ header.appendChild(this.titleElement_);
292
+ /** @private @type {HTMLButtonElement} */
293
+ this.closeButton_ = /** @type {HTMLButtonElement} */ (el('button', `${CSS}-close`));
294
+ this.closeButton_.type = 'button';
295
+ this.closeButton_.innerHTML = CLOSE_ICON;
296
+ this.closeButton_.addEventListener('click', () => this.close());
297
+ header.appendChild(this.closeButton_);
298
+ this.panel_.appendChild(header);
299
+
300
+ // --- search box (hidden until opened) ---------------------------------
301
+ /** @private @type {HTMLElement} */
302
+ this.searchRow_ = el('div', `${CSS}-search`);
303
+ this.searchRow_.id = `${CSS}-search-${++idCounter}`;
304
+ this.searchRow_.insertAdjacentHTML('beforeend', SEARCH_ICON);
305
+ /** @private @type {HTMLInputElement} */
306
+ this.searchInput_ = /** @type {HTMLInputElement} */ (el('input'));
307
+ this.searchInput_.type = 'search';
308
+ this.searchInput_.autocomplete = 'off';
309
+ this.searchInput_.addEventListener('input', () => this.applyFilter_());
310
+ this.searchInput_.addEventListener('keydown', (event) => {
311
+ if (event.key === 'Escape' && this.searchInput_.value) {
312
+ event.stopPropagation();
313
+ this.setFilter('');
314
+ }
315
+ });
316
+ this.searchRow_.appendChild(this.searchInput_);
317
+ /** @private @type {HTMLButtonElement} */
318
+ this.searchClear_ = /** @type {HTMLButtonElement} */ (el('button', `${CSS}-search-clear`));
319
+ this.searchClear_.type = 'button';
320
+ this.searchClear_.innerHTML = CLOSE_ICON;
321
+ this.searchClear_.addEventListener('click', () => {
322
+ this.setFilter('');
323
+ this.searchInput_.focus();
324
+ });
325
+ this.searchRow_.appendChild(this.searchClear_);
326
+ this.titleElement_.setAttribute('aria-controls', this.searchRow_.id);
327
+ this.panel_.appendChild(this.searchRow_);
328
+
329
+ const body = el('div', `${CSS}-body`);
330
+ /** @private @type {HTMLElement} */
331
+ this.rootList_ = el('ul', `${CSS}-list`);
332
+ body.appendChild(this.rootList_);
333
+ /** @private @type {HTMLElement} */
334
+ this.noResults_ = el('p', `${CSS}-no-results`);
335
+ this.noResults_.hidden = true;
336
+ body.appendChild(this.noResults_);
337
+ this.panel_.appendChild(body);
338
+
339
+ this.applyTexts_();
340
+
341
+ this.on('change:open', () => this.applyLayout_());
342
+ this.on('change:panelWidth', () => this.scheduleLayout_());
343
+ this.on('change:searchVisible', () => this.applySearchVisibility_());
344
+ this.on('change:buttonPosition', () => this.applyButtonPosition_());
345
+ this.set('buttonPosition', this.normalizeButtonPosition_(options.buttonPosition), true);
346
+ this.applyButtonPosition_();
347
+ this.set('open', !!options.open, true);
348
+ this.set('searchVisible', options.search === true, true);
349
+ this.applySearchVisibility_();
350
+ this.set('panelWidth', this.clampWidth_(this.defaultPanelWidth_), true);
351
+ }
352
+
353
+ // ---------------------------------------------------------------------
354
+ // Public API
355
+ // ---------------------------------------------------------------------
356
+
357
+ /** Show the panel. */
358
+ open() {
359
+ this.set('open', true);
360
+ }
361
+
362
+ /** Hide the panel. */
363
+ close() {
364
+ this.set('open', false);
365
+ }
366
+
367
+ /** Show the panel if hidden, hide it if shown. */
368
+ toggle() {
369
+ this.set('open', !this.get('open'));
370
+ }
371
+
372
+ /** @return {boolean} Whether the panel is shown. */
373
+ isOpen() {
374
+ return !!this.get('open');
375
+ }
376
+
377
+ /**
378
+ * @param {number} width Panel width in pixels (clamped to min/max).
379
+ */
380
+ setPanelWidth(width) {
381
+ this.set('panelWidth', this.clampWidth_(width));
382
+ }
383
+
384
+ /** @return {number} Current panel width in pixels. */
385
+ getPanelWidth() {
386
+ return /** @type {number} */ (this.get('panelWidth'));
387
+ }
388
+
389
+ /** @return {HTMLElement} The panel element. */
390
+ getPanelElement() {
391
+ return this.panel_;
392
+ }
393
+
394
+ /**
395
+ * Move the map button. An unknown value falls back to the default for the
396
+ * panel's side.
397
+ * @param {ButtonPosition} position
398
+ */
399
+ setButtonPosition(position) {
400
+ this.set('buttonPosition', this.normalizeButtonPosition_(position));
401
+ }
402
+
403
+ /** @return {ButtonPosition} Where the map button sits. */
404
+ getButtonPosition() {
405
+ return /** @type {ButtonPosition} */ (this.get('buttonPosition'));
406
+ }
407
+
408
+ /**
409
+ * Show or hide the search box. Hiding it clears the filter.
410
+ * @param {boolean} [visible=true]
411
+ */
412
+ showSearch(visible = true) {
413
+ this.set('searchVisible', !!visible);
414
+ }
415
+
416
+ /** Show the search box if hidden, hide it if shown. */
417
+ toggleSearch() {
418
+ this.showSearch(!this.isSearchVisible());
419
+ }
420
+
421
+ /** @return {boolean} Whether the search box is shown. */
422
+ isSearchVisible() {
423
+ return !!this.get('searchVisible');
424
+ }
425
+
426
+ /**
427
+ * Filter the layer list by (part of) a title, case-insensitive. Groups
428
+ * stay listed when their title or any of their layers match.
429
+ * @param {string} query Empty string shows everything.
430
+ */
431
+ setFilter(query) {
432
+ this.searchInput_.value = query;
433
+ this.applyFilter_();
434
+ }
435
+
436
+ /** @return {string} The current filter text. */
437
+ getFilter() {
438
+ return this.searchInput_.value;
439
+ }
440
+
441
+ /**
442
+ * Replace (part of) the UI strings.
443
+ * @param {Partial<LayerControlI18n>} i18n
444
+ */
445
+ setI18n(i18n) {
446
+ this.i18n_ = {...this.i18n_, ...i18n};
447
+ this.applyTexts_();
448
+ }
449
+
450
+ /** @return {LayerControlI18n} The strings in use. */
451
+ getI18n() {
452
+ return {...this.i18n_};
453
+ }
454
+
455
+ /**
456
+ * Rebuild the layer list from the map. Normally not needed: the control
457
+ * follows layer additions, removals and visibility changes by itself.
458
+ */
459
+ refresh() {
460
+ this.rebuild_();
461
+ }
462
+
463
+ /**
464
+ * Make a layer group exclusive (children as radio buttons, at most one
465
+ * visible) or non-exclusive (checkboxes). When switching to exclusive and
466
+ * several children are visible, only the top-most one stays visible.
467
+ *
468
+ * Basemap groups (children with `type: 'base'`) are always exclusive.
469
+ *
470
+ * @param {LayerGroup} group
471
+ * @param {boolean} exclusive
472
+ */
473
+ setExclusive(group, exclusive) {
474
+ if (exclusive) {
475
+ let found = false;
476
+ for (const layer of this.listedChildren_(group)) {
477
+ if (!layer.getVisible()) {
478
+ continue;
479
+ }
480
+ if (found) {
481
+ layer.setVisible(false);
482
+ }
483
+ found = true;
484
+ }
485
+ }
486
+ group.set('exclusive', exclusive);
487
+ }
488
+
489
+ /**
490
+ * @param {import('ol/Map.js').default|null} map
491
+ * @override
492
+ */
493
+ setMap(map) {
494
+ if (this.getMap()) {
495
+ this.detach_();
496
+ }
497
+ super.setMap(map);
498
+ if (map) {
499
+ this.mapListenerKeys_.push(
500
+ map.on('change:target', () => this.attachPanel_()),
501
+ map.on('change:layergroup', () => this.scheduleRebuild_()),
502
+ map.on('change:view', () => this.observeView_()),
503
+ );
504
+ this.attachPanel_();
505
+ this.observeView_();
506
+ this.rebuild_();
507
+ }
508
+ }
509
+
510
+ /** @private Follow the (possibly new) view's resolution. */
511
+ observeView_() {
512
+ unByKey(this.viewListenerKeys_);
513
+ this.viewListenerKeys_ = [];
514
+ const map = this.getMap();
515
+ const view = map ? map.getView() : null;
516
+ if (view) {
517
+ this.viewListenerKeys_.push(
518
+ view.on('change:resolution', () => this.updateStates_()),
519
+ );
520
+ }
521
+ this.updateStates_();
522
+ }
523
+
524
+ /**
525
+ * Dim entries that are outside their resolution/zoom range and explain,
526
+ * in a tooltip, why an entry is not shown on the map (out of range and/or
527
+ * inside a switched-off group).
528
+ * @private
529
+ */
530
+ updateStates_() {
531
+ const map = this.getMap();
532
+ const view = map ? map.getView() : null;
533
+ const resolution = view ? view.getResolution() : undefined;
534
+ const zoom = view ? view.getZoom() : undefined;
535
+ const known = resolution !== undefined && zoom !== undefined;
536
+ for (const entry of this.entries_) {
537
+ const reasons = [];
538
+ const out = known && !inResolutionRange(entry.layer, resolution, zoom);
539
+ entry.item.classList.toggle(`${CSS}-item--out-of-range`, out);
540
+ if (out) {
541
+ reasons.push(this.i18n_.outOfRangeHint);
542
+ }
543
+ for (let group = entry.parent; group; group = this.parents_.get(group) || null) {
544
+ if (!group.getVisible()) {
545
+ reasons.push(this.i18n_.groupOffHint.replace('{group}', String(group.get('title'))));
546
+ }
547
+ }
548
+ if (entry.label.dataset.hint) {
549
+ reasons.push(entry.label.dataset.hint);
550
+ }
551
+ entry.label.title = reasons.join('\n');
552
+ }
553
+ }
554
+
555
+ // ---------------------------------------------------------------------
556
+ // Panel placement and layout
557
+ // ---------------------------------------------------------------------
558
+
559
+ /** @private */
560
+ attachPanel_() {
561
+ const map = this.getMap();
562
+ const host = map ? map.getTargetElement() : null;
563
+
564
+ if (this.panelTarget_) {
565
+ if (this.panel_.parentNode !== this.panelTarget_) {
566
+ this.panelTarget_.appendChild(this.panel_);
567
+ }
568
+ } else if (host && host.parentNode) {
569
+ if (this.hostElement_ && this.hostElement_ !== host) {
570
+ this.restoreHost_();
571
+ }
572
+ if (this.hostElement_ !== host) {
573
+ this.hostElement_ = host;
574
+ this.hostSavedStyle_ = {
575
+ width: host.style.width,
576
+ marginLeft: host.style.marginLeft,
577
+ };
578
+ host.classList.add(`${CSS}-host`);
579
+ }
580
+ if (this.panel_.previousSibling !== host) {
581
+ host.parentNode.insertBefore(this.panel_, host.nextSibling);
582
+ }
583
+ }
584
+ this.applyLayout_();
585
+ }
586
+
587
+ /** @private */
588
+ restoreHost_() {
589
+ const host = this.hostElement_;
590
+ if (!host) {
591
+ return;
592
+ }
593
+ if (this.hostSavedStyle_) {
594
+ host.style.width = this.hostSavedStyle_.width;
595
+ host.style.marginLeft = this.hostSavedStyle_.marginLeft;
596
+ }
597
+ host.classList.remove(`${CSS}-host`, `${CSS}-host--open`);
598
+ this.hostElement_ = null;
599
+ this.hostSavedStyle_ = null;
600
+ }
601
+
602
+ /** @private */
603
+ detach_() {
604
+ unByKey(this.mapListenerKeys_);
605
+ this.mapListenerKeys_ = [];
606
+ unByKey(this.viewListenerKeys_);
607
+ this.viewListenerKeys_ = [];
608
+ this.clearLayerListeners_();
609
+ this.restoreHost_();
610
+ if (this.panel_.parentNode) {
611
+ this.panel_.parentNode.removeChild(this.panel_);
612
+ }
613
+ }
614
+
615
+ /** @private */
616
+ scheduleLayout_() {
617
+ if (this.layoutScheduled_) {
618
+ return;
619
+ }
620
+ this.layoutScheduled_ = true;
621
+ requestAnimationFrame(() => {
622
+ this.layoutScheduled_ = false;
623
+ this.applyLayout_();
624
+ });
625
+ }
626
+
627
+ /** @private */
628
+ applyLayout_() {
629
+ const open = this.isOpen();
630
+ const width = this.getPanelWidth();
631
+
632
+ this.panel_.classList.toggle(`${CSS}-panel--open`, open);
633
+ this.panel_.style.width = `${width}px`;
634
+ this.button_.setAttribute('aria-expanded', String(open));
635
+ this.element.classList.toggle(`${CSS}--open`, open);
636
+
637
+ const host = this.hostElement_;
638
+ if (host && !this.panelTarget_) {
639
+ host.classList.toggle(`${CSS}-host--open`, open);
640
+ if (open) {
641
+ host.style.width = `calc(100% - ${width}px)`;
642
+ host.style.marginLeft = this.side_ === 'left' ? `${width}px` : '';
643
+ } else if (this.hostSavedStyle_) {
644
+ host.style.width = this.hostSavedStyle_.width;
645
+ host.style.marginLeft = this.hostSavedStyle_.marginLeft;
646
+ }
647
+ }
648
+
649
+ const map = this.getMap();
650
+ if (map) {
651
+ map.updateSize();
652
+ }
653
+ }
654
+
655
+ /**
656
+ * @private
657
+ * @param {number} width
658
+ * @return {number}
659
+ */
660
+ clampWidth_(width) {
661
+ let max = this.maxPanelWidth_;
662
+ const host = this.hostElement_;
663
+ if (host && host.parentElement && !this.panelTarget_) {
664
+ // Always leave some map visible.
665
+ max = Math.min(max, Math.max(this.minPanelWidth_, host.parentElement.clientWidth - 100));
666
+ }
667
+ return Math.round(Math.min(max, Math.max(this.minPanelWidth_, width)));
668
+ }
669
+
670
+ /**
671
+ * @private
672
+ * @param {HTMLElement} handle
673
+ */
674
+ initResizer_(handle) {
675
+ handle.addEventListener('pointerdown', (event) => {
676
+ if (event.button !== 0) {
677
+ return;
678
+ }
679
+ event.preventDefault();
680
+ const startX = event.clientX;
681
+ const startWidth = this.getPanelWidth();
682
+ const direction = this.side_ === 'right' ? -1 : 1;
683
+ handle.setPointerCapture(event.pointerId);
684
+ this.panel_.classList.add(`${CSS}-panel--resizing`);
685
+
686
+ const onMove = (moveEvent) => {
687
+ this.setPanelWidth(startWidth + direction * (moveEvent.clientX - startX));
688
+ };
689
+ const onUp = () => {
690
+ handle.removeEventListener('pointermove', onMove);
691
+ handle.removeEventListener('pointerup', onUp);
692
+ handle.removeEventListener('pointercancel', onUp);
693
+ this.panel_.classList.remove(`${CSS}-panel--resizing`);
694
+ this.applyLayout_();
695
+ };
696
+ handle.addEventListener('pointermove', onMove);
697
+ handle.addEventListener('pointerup', onUp);
698
+ handle.addEventListener('pointercancel', onUp);
699
+ });
700
+ handle.addEventListener('dblclick', () => this.setPanelWidth(this.defaultPanelWidth_));
701
+ }
702
+
703
+ /** @private */
704
+ applyTexts_() {
705
+ const t = this.i18n_;
706
+ this.button_.title = t.buttonTitle;
707
+ this.button_.setAttribute('aria-label', t.buttonTitle);
708
+ this.panel_.setAttribute('aria-label', t.panelTitle);
709
+ this.titleElement_.textContent = t.panelTitle;
710
+ this.titleElement_.title = t.searchToggleTitle;
711
+ this.searchInput_.placeholder = t.searchPlaceholder;
712
+ this.searchInput_.setAttribute('aria-label', t.searchPlaceholder);
713
+ this.searchClear_.title = t.searchClearTitle;
714
+ this.searchClear_.setAttribute('aria-label', t.searchClearTitle);
715
+ this.noResults_.textContent = t.searchNoResults;
716
+ this.closeButton_.title = t.closeTitle;
717
+ this.closeButton_.setAttribute('aria-label', t.closeTitle);
718
+ if (this.resizer_) {
719
+ this.resizer_.title = t.resizeTitle;
720
+ }
721
+ for (const button of this.foldButtons_) {
722
+ this.updateFoldButton_(button);
723
+ }
724
+ for (const label of this.panel_.querySelectorAll(`.${CSS}-label--toggle`)) {
725
+ /** @type {HTMLElement} */ (label).dataset.hint = t.exclusiveHint;
726
+ }
727
+ for (const button of this.panel_.querySelectorAll(`.${CSS}-opacity-toggle`)) {
728
+ /** @type {HTMLElement} */ (button).title = t.opacityTitle;
729
+ button.setAttribute('aria-label', t.opacityTitle);
730
+ }
731
+ for (const range of this.panel_.querySelectorAll(`.${CSS}-opacity input`)) {
732
+ range.setAttribute('aria-label', `${t.opacityTitle}: ${/** @type {HTMLElement} */ (range).dataset.layerTitle}`);
733
+ }
734
+ this.updateStates_();
735
+ }
736
+
737
+ /**
738
+ * @private
739
+ * @param {string|undefined} position
740
+ * @return {ButtonPosition}
741
+ */
742
+ normalizeButtonPosition_(position) {
743
+ if (position === undefined) {
744
+ return this.defaultButtonPosition_;
745
+ }
746
+ if (BUTTON_POSITIONS.includes(/** @type {ButtonPosition} */ (position))) {
747
+ return /** @type {ButtonPosition} */ (position);
748
+ }
749
+ console.warn(
750
+ `ol-layer-control: unknown buttonPosition "${position}", using "${this.defaultButtonPosition_}". ` +
751
+ `Valid values: ${BUTTON_POSITIONS.join(', ')}.`,
752
+ );
753
+ return this.defaultButtonPosition_;
754
+ }
755
+
756
+ /** @private */
757
+ applyButtonPosition_() {
758
+ const current = this.getButtonPosition();
759
+ for (const position of BUTTON_POSITIONS) {
760
+ this.element.classList.toggle(`${CSS}--${position}`, position === current);
761
+ }
762
+ }
763
+
764
+ // ---------------------------------------------------------------------
765
+ // Search
766
+ // ---------------------------------------------------------------------
767
+
768
+ /** @private */
769
+ applySearchVisibility_() {
770
+ const visible = this.isSearchVisible();
771
+ this.panel_.classList.toggle(`${CSS}-panel--search`, visible);
772
+ this.titleElement_.setAttribute('aria-expanded', String(visible));
773
+ if (visible) {
774
+ if (this.isOpen()) {
775
+ this.searchInput_.focus();
776
+ }
777
+ } else if (this.searchInput_.value) {
778
+ this.setFilter('');
779
+ }
780
+ }
781
+
782
+ /** @private Show only entries that match the search text. */
783
+ applyFilter_() {
784
+ const query = this.searchInput_.value.trim().toLowerCase();
785
+ const hiddenClass = `${CSS}-item--hidden`;
786
+ this.panel_.classList.toggle(`${CSS}-panel--filtering`, query !== '');
787
+ this.searchClear_.hidden = query === '';
788
+
789
+ if (query === '') {
790
+ for (const entry of this.entries_) {
791
+ entry.item.classList.remove(hiddenClass);
792
+ }
793
+ this.noResults_.hidden = true;
794
+ return;
795
+ }
796
+
797
+ /** @type {Set<import('ol/layer/Base.js').default>} */
798
+ const shown = new Set();
799
+ for (const entry of this.entries_) {
800
+ const title = String(entry.layer.get('title')).toLowerCase();
801
+ if (title.includes(query)) {
802
+ // The match itself, and every ancestor so the path to it is visible.
803
+ for (let layer = entry.layer; layer; layer = this.parents_.get(layer) || null) {
804
+ shown.add(layer);
805
+ }
806
+ } else {
807
+ // Inside a matching group: keep all of that group's contents.
808
+ for (let group = entry.parent; group; group = this.parents_.get(group) || null) {
809
+ if (String(group.get('title')).toLowerCase().includes(query)) {
810
+ shown.add(entry.layer);
811
+ break;
812
+ }
813
+ }
814
+ }
815
+ }
816
+ for (const entry of this.entries_) {
817
+ entry.item.classList.toggle(hiddenClass, !shown.has(entry.layer));
818
+ }
819
+ this.noResults_.hidden = shown.size > 0;
820
+ }
821
+
822
+ // ---------------------------------------------------------------------
823
+ // Layer tree
824
+ // ---------------------------------------------------------------------
825
+
826
+ /** @private */
827
+ clearLayerListeners_() {
828
+ unByKey(this.layerListenerKeys_);
829
+ this.layerListenerKeys_ = [];
830
+ }
831
+
832
+ /** @private */
833
+ scheduleRebuild_() {
834
+ if (this.rebuildScheduled_) {
835
+ return;
836
+ }
837
+ this.rebuildScheduled_ = true;
838
+ Promise.resolve().then(() => {
839
+ this.rebuildScheduled_ = false;
840
+ this.rebuild_();
841
+ });
842
+ }
843
+
844
+ /** @private */
845
+ rebuild_() {
846
+ this.clearLayerListeners_();
847
+ this.foldButtons_ = [];
848
+ this.entries_ = [];
849
+ this.parents_ = new Map();
850
+ this.rootList_.textContent = '';
851
+ const map = this.getMap();
852
+ if (!map) {
853
+ return;
854
+ }
855
+ this.renderCollection_(map.getLayerGroup().getLayers(), this.rootList_, null, null);
856
+ this.updateStates_();
857
+ this.applyFilter_();
858
+ }
859
+
860
+ /**
861
+ * @private
862
+ * @param {import('ol/layer/Base.js').default} layer
863
+ * @param {LayerGroup|null} parent
864
+ * @param {HTMLElement} item
865
+ * @param {HTMLElement} label
866
+ */
867
+ registerEntry_(layer, parent, item, label) {
868
+ this.entries_.push({layer, parent, item, label});
869
+ this.parents_.set(layer, parent);
870
+ this.layerListenerKeys_.push(
871
+ layer.on(
872
+ ['change:minResolution', 'change:maxResolution', 'change:minZoom', 'change:maxZoom'],
873
+ () => this.updateStates_(),
874
+ ),
875
+ );
876
+ }
877
+
878
+ /**
879
+ * @private
880
+ * @param {import('ol/Collection.js').default<import('ol/layer/Base.js').default>} collection
881
+ * @param {HTMLElement} list The <ul> to render into.
882
+ * @param {string|null} radioName Non-null when the parent group is exclusive.
883
+ * @param {LayerGroup|null} parent The group that owns the collection.
884
+ */
885
+ renderCollection_(collection, list, radioName, parent) {
886
+ this.layerListenerKeys_.push(
887
+ collection.on(['add', 'remove'], () => this.scheduleRebuild_()),
888
+ );
889
+ const layers = this.listedLayers_(collection);
890
+ for (const layer of layers) {
891
+ if (layer instanceof LayerGroup && !layer.get('combine')) {
892
+ this.renderGroup_(layer, layers, list, radioName, parent);
893
+ } else {
894
+ this.renderLayer_(layer, layers, list, radioName, parent);
895
+ }
896
+ }
897
+ }
898
+
899
+ /**
900
+ * The layers of a collection that appear in the panel, in panel order.
901
+ * @private
902
+ * @param {import('ol/Collection.js').default<import('ol/layer/Base.js').default>} collection
903
+ * @return {Array<import('ol/layer/Base.js').default>}
904
+ */
905
+ listedLayers_(collection) {
906
+ const layers = collection.getArray().filter(
907
+ (layer) => layer.get('displayInLayerControl') !== false && layer.get('title'),
908
+ );
909
+ if (this.reverse_) {
910
+ layers.reverse();
911
+ }
912
+ return layers;
913
+ }
914
+
915
+ /**
916
+ * @private
917
+ * @param {LayerGroup} group
918
+ * @return {Array<import('ol/layer/Base.js').default>}
919
+ */
920
+ listedChildren_(group) {
921
+ return this.listedLayers_(group.getLayers());
922
+ }
923
+
924
+ /**
925
+ * @private
926
+ * @param {import('ol/layer/Base.js').default} layer
927
+ * @param {Array<import('ol/layer/Base.js').default>} siblings Rendered siblings, incl. the layer itself.
928
+ * @param {HTMLElement} list
929
+ * @param {string|null} radioName
930
+ * @param {LayerGroup|null} parent
931
+ */
932
+ renderLayer_(layer, siblings, list, radioName, parent) {
933
+ const item = el('li', `${CSS}-layer`);
934
+ const row = el('div', `${CSS}-row`);
935
+ row.appendChild(el('span', `${CSS}-spacer`));
936
+ const input = this.createInput_(layer, siblings, radioName);
937
+ row.appendChild(input);
938
+ const label = this.createLabel_(layer, input);
939
+ row.appendChild(label);
940
+ item.appendChild(row);
941
+ if (this.opacitySlider_) {
942
+ this.addOpacitySlider_(layer, item, row);
943
+ }
944
+ list.appendChild(item);
945
+ this.registerEntry_(layer, parent, item, label);
946
+ }
947
+
948
+ /**
949
+ * Adds an opacity button to the row and a (collapsed) slider row to the item.
950
+ * @private
951
+ * @param {import('ol/layer/Base.js').default} layer
952
+ * @param {HTMLElement} item The <li>.
953
+ * @param {HTMLElement} row The row holding the input and label.
954
+ */
955
+ addOpacitySlider_(layer, item, row) {
956
+ const openClass = `${CSS}-layer--opacity-open`;
957
+ const sliderRow = el('div', `${CSS}-opacity`);
958
+ sliderRow.id = `${CSS}-opacity-${++idCounter}`;
959
+
960
+ const button = /** @type {HTMLButtonElement} */ (el('button', `${CSS}-opacity-toggle`));
961
+ button.type = 'button';
962
+ button.innerHTML = OPACITY_ICON;
963
+ button.title = this.i18n_.opacityTitle;
964
+ button.setAttribute('aria-label', this.i18n_.opacityTitle);
965
+ button.setAttribute('aria-controls', sliderRow.id);
966
+ button.setAttribute('aria-expanded', 'false');
967
+ button.addEventListener('click', () => {
968
+ const open = item.classList.toggle(openClass);
969
+ button.setAttribute('aria-expanded', String(open));
970
+ if (open) {
971
+ range.focus();
972
+ }
973
+ });
974
+ row.appendChild(button);
975
+
976
+ const range = /** @type {HTMLInputElement} */ (el('input'));
977
+ range.type = 'range';
978
+ range.min = '0';
979
+ range.max = '1';
980
+ range.step = '0.01';
981
+ range.dataset.layerTitle = String(layer.get('title'));
982
+ range.setAttribute('aria-label', `${this.i18n_.opacityTitle}: ${layer.get('title')}`);
983
+ const value = el('span', `${CSS}-opacity-value`);
984
+
985
+ const update = () => {
986
+ const opacity = layer.getOpacity();
987
+ range.value = String(opacity);
988
+ value.textContent = `${Math.round(opacity * 100)}%`;
989
+ };
990
+ update();
991
+ range.addEventListener('input', () => layer.setOpacity(Number(range.value)));
992
+ this.layerListenerKeys_.push(layer.on('change:opacity', update));
993
+
994
+ sliderRow.appendChild(range);
995
+ sliderRow.appendChild(value);
996
+ item.appendChild(sliderRow);
997
+ }
998
+
999
+ /**
1000
+ * @private
1001
+ * @param {LayerGroup} group
1002
+ * @param {Array<import('ol/layer/Base.js').default>} siblings
1003
+ * @param {HTMLElement} list
1004
+ * @param {string|null} radioName
1005
+ * @param {LayerGroup|null} parent
1006
+ */
1007
+ renderGroup_(group, siblings, list, radioName, parent) {
1008
+ const children = group.getLayers();
1009
+ const isBaseGroup = children.getArray().some((layer) => layer.get('type') === 'base');
1010
+ const exclusive = isBaseGroup || group.get('exclusive') === true;
1011
+
1012
+ const item = el('li', `${CSS}-group`);
1013
+ item.classList.toggle(`${CSS}-group--exclusive`, exclusive);
1014
+ item.classList.toggle(`${CSS}-group--folded`, group.get('folded') === true);
1015
+ item.classList.toggle(`${CSS}-group--inactive`, !group.getVisible());
1016
+
1017
+ const row = el('div', `${CSS}-row`);
1018
+ const childList = el('ul', `${CSS}-list`);
1019
+ childList.id = `${CSS}-list-${++idCounter}`;
1020
+
1021
+ const fold = /** @type {HTMLButtonElement} */ (el('button', `${CSS}-fold`));
1022
+ fold.type = 'button';
1023
+ fold.innerHTML = CHEVRON_ICON;
1024
+ fold.setAttribute('aria-controls', childList.id);
1025
+ fold.addEventListener('click', () => {
1026
+ const folded = !item.classList.contains(`${CSS}-group--folded`);
1027
+ item.classList.toggle(`${CSS}-group--folded`, folded);
1028
+ group.set('folded', folded);
1029
+ this.updateFoldButton_(fold);
1030
+ });
1031
+ this.foldButtons_.push(fold);
1032
+ row.appendChild(fold);
1033
+
1034
+ let input = null;
1035
+ if (!isBaseGroup) {
1036
+ input = this.createInput_(group, siblings, radioName);
1037
+ row.appendChild(input);
1038
+ }
1039
+ const label = this.createLabel_(group, input);
1040
+ if (!isBaseGroup && this.exclusiveToggle_) {
1041
+ label.dataset.hint = this.i18n_.exclusiveHint;
1042
+ label.classList.add(`${CSS}-label--toggle`);
1043
+ label.addEventListener('contextmenu', (event) => {
1044
+ event.preventDefault();
1045
+ this.setExclusive(group, !exclusive);
1046
+ });
1047
+ }
1048
+ row.appendChild(label);
1049
+ item.appendChild(row);
1050
+ this.updateFoldButton_(fold);
1051
+
1052
+ this.layerListenerKeys_.push(
1053
+ group.on('change:visible', () => {
1054
+ item.classList.toggle(`${CSS}-group--inactive`, !group.getVisible());
1055
+ this.updateStates_();
1056
+ }),
1057
+ group.on(['change:layers', 'change:exclusive'], () => this.scheduleRebuild_()),
1058
+ );
1059
+
1060
+ this.renderCollection_(children, childList, exclusive ? `${CSS}-radio-${++idCounter}` : null, group);
1061
+ item.appendChild(childList);
1062
+ list.appendChild(item);
1063
+ this.registerEntry_(group, parent, item, label);
1064
+ }
1065
+
1066
+ /**
1067
+ * @private
1068
+ * @param {import('ol/layer/Base.js').default} layer
1069
+ * @param {Array<import('ol/layer/Base.js').default>} siblings
1070
+ * @param {string|null} radioName
1071
+ * @return {HTMLInputElement}
1072
+ */
1073
+ createInput_(layer, siblings, radioName) {
1074
+ const input = /** @type {HTMLInputElement} */ (el('input'));
1075
+ input.type = radioName ? 'radio' : 'checkbox';
1076
+ input.id = `${CSS}-input-${++idCounter}`;
1077
+ if (radioName) {
1078
+ input.name = radioName;
1079
+ }
1080
+ input.checked = layer.getVisible();
1081
+ input.addEventListener('change', () => {
1082
+ if (radioName) {
1083
+ for (const sibling of siblings) {
1084
+ sibling.setVisible(sibling === layer);
1085
+ }
1086
+ } else {
1087
+ layer.setVisible(input.checked);
1088
+ }
1089
+ });
1090
+ this.layerListenerKeys_.push(
1091
+ layer.on('change:visible', () => {
1092
+ input.checked = layer.getVisible();
1093
+ }),
1094
+ );
1095
+ return input;
1096
+ }
1097
+
1098
+ /**
1099
+ * @private
1100
+ * @param {import('ol/layer/Base.js').default} layer
1101
+ * @param {HTMLInputElement|null} input
1102
+ * @return {HTMLElement}
1103
+ */
1104
+ createLabel_(layer, input) {
1105
+ const label = el(input ? 'label' : 'span', `${CSS}-label`);
1106
+ label.textContent = String(layer.get('title'));
1107
+ if (input) {
1108
+ /** @type {HTMLLabelElement} */ (label).htmlFor = input.id;
1109
+ }
1110
+ this.layerListenerKeys_.push(
1111
+ layer.on('change:title', () => {
1112
+ label.textContent = String(layer.get('title'));
1113
+ }),
1114
+ );
1115
+ return label;
1116
+ }
1117
+
1118
+ /**
1119
+ * @private
1120
+ * @param {HTMLButtonElement} button
1121
+ */
1122
+ updateFoldButton_(button) {
1123
+ const item = button.closest(`.${CSS}-group`);
1124
+ const folded = !!item && item.classList.contains(`${CSS}-group--folded`);
1125
+ const title = folded ? this.i18n_.expandTitle : this.i18n_.collapseTitle;
1126
+ button.title = title;
1127
+ button.setAttribute('aria-label', title);
1128
+ button.setAttribute('aria-expanded', String(!folded));
1129
+ }
1130
+ }
1131
+
1132
+ export {LayerControl};