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.
- package/CHANGELOG.md +29 -0
- package/LICENSE +21 -0
- package/package.json +60 -0
- package/readme.md +239 -0
- package/src/ol-layer-control.css +408 -0
- package/src/ol-layer-control.js +1132 -0
- package/types/ol-layer-control.d.ts +451 -0
|
@@ -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};
|