loko/streetup/common/static/common/layer-switcher-control.js
2026-07-22 14:48:40 +02:00

672 lines
27 KiB
JavaScript

/**
* Layer Switcher Control for Maplibre GL
*
* A reusable control to toggle visibility of additional map layers.
* Supports raster layers (WMS, WMTS) and vector layers.
*
* @version 1.0.0
* @requires maplibre-gl
*/
(function(window) {
'use strict';
// Default icons for layer types
const ICONS = {
// Satellite/Ortho icon
satellite: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><polyline points="21 15 16 10 5 21"></polyline></svg>`,
// Map/Layers icon
layers: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 2 7 12 12 22 7 12 2"></polygon><polyline points="2 17 12 22 22 17"></polyline><polyline points="2 12 12 17 22 12"></polyline></svg>`,
// 3D/Cube icon
cube: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path><polyline points="7.5 4.21 12 6.81 16.5 4.21"></polyline><polyline points="7.5 19.79 7.5 14.6 3 12"></polyline><polyline points="21 12 16.5 14.6 16.5 19.79"></polyline><polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline><line x1="12" y1="22.08" x2="12" y2="12"></line></svg>`,
// Eye icon for visibility
eye: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></svg>`,
// Grid/Tiles icon
grid: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"></rect><rect x="14" y="3" width="7" height="7"></rect><rect x="14" y="14" width="7" height="7"></rect><rect x="3" y="14" width="7" height="7"></rect></svg>`,
// Tree icon for vegetation/green surfaces
tree: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22v-7"></path><path d="M9 22h6"></path><path d="M12 15a6 6 0 0 0 6-6c0-1.5-.5-2.9-1.4-4H7.4C6.5 6.1 6 7.5 6 9a6 6 0 0 0 6 6z"></path><path d="M12 2a3 3 0 0 0-3 3h6a3 3 0 0 0-3-3z"></path></svg>`
};
// CSS styles for the control
const CONTROL_STYLES = `
.layer-switcher-ctrl {
background: #fff;
border-radius: 4px;
box-shadow: 0 0 0 2px rgba(0,0,0,.1);
overflow: visible;
}
.layer-switcher-btn {
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
outline: none;
box-sizing: border-box;
background-color: transparent;
cursor: pointer;
border-bottom: 1px solid rgba(0,0,0,.1);
}
.layer-switcher-btn:last-child {
border-bottom: none;
}
.layer-switcher-btn:hover {
background-color: rgba(0, 0, 0, 0.05);
}
.layer-switcher-btn.active {
background-color: #e7f3ff;
}
.layer-switcher-btn.active:hover {
background-color: #cce5ff;
}
.layer-switcher-btn svg {
width: 16px;
height: 16px;
stroke: #333;
}
.layer-switcher-btn.active svg {
stroke: #0066ff;
}
/* Bootstrap Icons support */
.layer-switcher-btn .bi {
font-size: 14px;
color: #333;
}
.layer-switcher-btn.active .bi {
color: #0066ff;
}
/* Sublayer selector styles - positioned to the right of the control */
.layer-switcher-sublayer-selector {
position: absolute;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
padding: 6px;
z-index: 9999;
display: none;
white-space: nowrap;
}
.layer-switcher-sublayer-selector.visible {
display: flex;
gap: 4px;
}
.layer-switcher-sublayer-btn {
padding: 4px 8px;
border: 2px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
font-size: 11px;
font-weight: 500;
transition: all 0.2s ease;
white-space: nowrap;
}
.layer-switcher-sublayer-btn:hover {
background: #f8f9fa;
border-color: #0d6efd;
}
.layer-switcher-sublayer-btn.active {
background: #0d6efd;
color: white;
border-color: #0d6efd;
}
`;
/**
* LayerSwitcherControl class
*
* @param {Object} options - Configuration options
* @param {Array} options.layers - Array of layer configurations
* @param {string} options.layers[].id - Unique layer ID
* @param {string} options.layers[].name - Display name for tooltip
* @param {string} options.layers[].icon - Icon type: 'satellite', 'layers', 'cube', 'eye', 'grid' or custom SVG
* @param {Object} options.layers[].source - MapLibre source configuration (for new layers)
* @param {Object} options.layers[].layer - MapLibre layer configuration without id and source (for new layers)
* @param {string[]} options.layers[].existingLayerIds - Array of existing layer IDs to toggle (for existing layers)
* @param {string} options.layers[].beforeLayer - ID of layer to insert before (optional)
* @param {boolean} options.layers[].visible - Initial visibility (default: false for new layers, true for existing)
* @param {boolean} options.layers[].exclusive - If true, enabling this layer disables others in same group
* @param {string} options.layers[].group - Group name for exclusive layers
* @param {Array} options.layers[].sublayers - Array of sublayer configurations for multi-source layers
* @param {string} options.layers[].sublayers[].id - Unique sublayer ID
* @param {string} options.layers[].sublayers[].name - Display name for sublayer
* @param {Object} options.layers[].sublayers[].source - MapLibre source configuration
* @param {Object} options.layers[].sublayers[].layer - MapLibre layer configuration
* @param {boolean} options.layers[].sublayers[].default - If true, this sublayer is selected by default
*/
class LayerSwitcherControl {
constructor(options = {}) {
this.layers = options.layers || [];
this.map = null;
this.container = null;
this.buttons = {};
this.sublayerSelectors = {}; // Fixed position selectors for sublayers
this.layerStates = new Map(); // Track layer visibility states
this.activeSublayers = new Map(); // Track active sublayer for each parent layer
this._injectStyles();
}
/**
* Inject CSS styles
*/
_injectStyles() {
if (document.getElementById('layer-switcher-styles')) return;
const style = document.createElement('style');
style.id = 'layer-switcher-styles';
style.textContent = CONTROL_STYLES;
document.head.appendChild(style);
}
/**
* Add control to map (IControl interface)
*/
onAdd(map) {
this.map = map;
// Create container
this.container = document.createElement('div');
this.container.className = 'maplibregl-ctrl maplibregl-ctrl-group layer-switcher-ctrl';
this.container.style.position = 'relative';
// Create buttons for each layer
this.layers.forEach(layerConfig => {
// For layers with sublayers, set default active sublayer
if (layerConfig.sublayers && layerConfig.sublayers.length > 0) {
const defaultSublayer = layerConfig.sublayers.find(s => s.default) || layerConfig.sublayers[0];
this.activeSublayers.set(layerConfig.id, defaultSublayer.id);
}
const button = this._createButton(layerConfig);
this.container.appendChild(button);
this.buttons[layerConfig.id] = button;
// Create sublayer selector for sublayers if needed
// Appended to the map container to escape the 29px maplibregl-ctrl-group constraint
if (layerConfig.sublayers && layerConfig.sublayers.length > 0) {
const selector = this._createSublayerSelector(layerConfig);
map.getContainer().appendChild(selector);
this.sublayerSelectors[layerConfig.id] = selector;
}
// Initialize layer state
// For existing layers, default to visible (they already exist on the map)
const isExistingLayer = layerConfig.existingLayerIds && layerConfig.existingLayerIds.length > 0;
const initialVisibility = layerConfig.visible !== undefined
? layerConfig.visible
: isExistingLayer; // existing layers are visible by default
this.layerStates.set(layerConfig.id, {
visible: initialVisibility,
sourceAdded: isExistingLayer, // existing layers don't need source
layerAdded: isExistingLayer // existing layers are already added
});
// Update button state immediately for existing visible layers
if (initialVisibility) {
this._updateButtonState(layerConfig.id, true);
}
});
// Initialize layers if map is already loaded
if (map.loaded()) {
this._initializeLayers();
} else {
map.on('load', () => this._initializeLayers());
}
return this.container;
}
/**
* Remove control from map
*/
onRemove() {
// Remove layers and sources
this.layers.forEach(layerConfig => {
const layerId = `layer-switcher-${layerConfig.id}`;
const sourceId = `layer-switcher-source-${layerConfig.id}`;
if (this.map.getLayer(layerId)) {
this.map.removeLayer(layerId);
}
if (this.map.getSource(sourceId)) {
this.map.removeSource(sourceId);
}
});
// Remove sublayer selectors from body
Object.values(this.sublayerSelectors).forEach(selector => {
if (selector && selector.parentNode) {
selector.parentNode.removeChild(selector);
}
});
if (this.container && this.container.parentNode) {
this.container.parentNode.removeChild(this.container);
}
this.map = null;
this.container = null;
}
/**
* Create a button for a layer
*/
_createButton(layerConfig) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'layer-switcher-btn';
button.title = layerConfig.name || layerConfig.id;
// Set icon
if (layerConfig.icon) {
// Check if it's a Bootstrap Icon (format: "bi:icon-name")
if (layerConfig.icon.startsWith('bi:')) {
const iconName = layerConfig.icon.substring(3);
button.innerHTML = `<i class="bi bi-${iconName}"></i>`;
}
// Check if it's a built-in icon
else if (ICONS[layerConfig.icon]) {
button.innerHTML = ICONS[layerConfig.icon];
}
// Otherwise treat as custom SVG
else {
button.innerHTML = layerConfig.icon;
}
} else {
// Default to layers icon
button.innerHTML = ICONS.layers;
}
// Click handler - simply toggle the layer on/off
button.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
this.toggleLayer(layerConfig.id);
});
return button;
}
/**
* Create a fixed-position sublayer selector (bottom left)
*/
_createSublayerSelector(layerConfig) {
const selector = document.createElement('div');
selector.className = 'layer-switcher-sublayer-selector';
selector.dataset.parentId = layerConfig.id;
layerConfig.sublayers.forEach(sublayer => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'layer-switcher-sublayer-btn';
btn.dataset.sublayerId = sublayer.id;
btn.textContent = sublayer.name || sublayer.id;
// Mark default as active
const activeSublayerId = this.activeSublayers.get(layerConfig.id);
if (sublayer.id === activeSublayerId) {
btn.classList.add('active');
}
// Click handler
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
this._selectSublayer(layerConfig.id, sublayer.id);
});
selector.appendChild(btn);
});
return selector;
}
/**
* Show sublayer selector, positioned to the right of the control button
*/
_showSublayerSelector(layerId) {
const selector = this.sublayerSelectors[layerId];
if (!selector) return;
const btn = this.buttons[layerId];
if (btn && this.map) {
const mapContainer = this.map.getContainer();
const mapRect = mapContainer.getBoundingClientRect();
const btnRect = btn.getBoundingClientRect();
// If the button is on the right half of the map, open the panel to the left
const onRightSide = (btnRect.left - mapRect.left) > mapRect.width / 2;
if (onRightSide) {
selector.style.left = '';
selector.style.right = (mapRect.right - btnRect.left + 4) + 'px';
} else {
selector.style.right = '';
selector.style.left = (btnRect.right - mapRect.left + 4) + 'px';
}
selector.style.top = (btnRect.top - mapRect.top) + 'px';
}
selector.classList.add('visible');
}
/**
* Hide sublayer selector
*/
_hideSublayerSelector(layerId) {
const selector = this.sublayerSelectors[layerId];
if (selector) {
selector.classList.remove('visible');
}
}
/**
* Select a sublayer
*/
_selectSublayer(parentLayerId, sublayerId) {
const layerConfig = this.layers.find(l => l.id === parentLayerId);
if (!layerConfig || !layerConfig.sublayers) return;
const sublayer = layerConfig.sublayers.find(s => s.id === sublayerId);
if (!sublayer) return;
const state = this.layerStates.get(parentLayerId);
if (!state) return;
// Store current active sublayer
this.activeSublayers.set(parentLayerId, sublayerId);
// Update selector button states
const selector = this.sublayerSelectors[parentLayerId];
if (selector) {
selector.querySelectorAll('.layer-switcher-sublayer-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.sublayerId === sublayerId);
});
}
// If layer is visible, switch the source
if (state.visible) {
const mapLayerId = `layer-switcher-${parentLayerId}`;
// Remove old layer if exists
if (this.map.getLayer(mapLayerId)) {
this.map.removeLayer(mapLayerId);
}
// Add new source if needed
const sourceId = `layer-switcher-source-${parentLayerId}`;
if (this.map.getSource(sourceId)) {
this.map.removeSource(sourceId);
}
this.map.addSource(sourceId, sublayer.source);
// Determine where to insert the layer
let beforeLayer = layerConfig.beforeLayer;
if (!beforeLayer) {
const layers = this.map.getStyle().layers;
for (const layer of layers) {
if (layer.id.startsWith('gl-draw-') ||
layer.id.startsWith('custom-draw-') ||
layer.id.startsWith('polygon-') ||
layer.id === 'green-surfaces') {
beforeLayer = layer.id;
break;
}
}
}
// Add layer with sublayer configuration
const mapLayerConfig = {
id: mapLayerId,
source: sourceId,
...(sublayer.layer || layerConfig.layer)
};
this.map.addLayer(mapLayerConfig, beforeLayer);
}
}
/**
* Initialize layers (add sources)
*/
_initializeLayers() {
this.layers.forEach(layerConfig => {
// Add source if not exists
const sourceId = `layer-switcher-source-${layerConfig.id}`;
if (!this.map.getSource(sourceId) && layerConfig.source) {
this.map.addSource(sourceId, layerConfig.source);
const state = this.layerStates.get(layerConfig.id);
if (state) state.sourceAdded = true;
}
// If layer should be visible initially, add it
if (layerConfig.visible) {
this._addLayer(layerConfig);
this._updateButtonState(layerConfig.id, true);
}
});
}
/**
* Add a layer to the map
*/
_addLayer(layerConfig) {
const layerId = `layer-switcher-${layerConfig.id}`;
const sourceId = `layer-switcher-source-${layerConfig.id}`;
if (this.map.getLayer(layerId)) return;
// For layers with sublayers, use the active sublayer's source
let sourceConfig = layerConfig.source;
let layerStyle = layerConfig.layer;
if (layerConfig.sublayers && layerConfig.sublayers.length > 0) {
const activeSublayerId = this.activeSublayers.get(layerConfig.id);
const activeSublayer = layerConfig.sublayers.find(s => s.id === activeSublayerId)
|| layerConfig.sublayers[0];
sourceConfig = activeSublayer.source;
layerStyle = activeSublayer.layer || layerConfig.layer;
}
// Ensure source exists before adding layer
if (!this.map.getSource(sourceId) && sourceConfig) {
this.map.addSource(sourceId, sourceConfig);
const state = this.layerStates.get(layerConfig.id);
if (state) state.sourceAdded = true;
}
// Determine where to insert the layer
let beforeLayer = layerConfig.beforeLayer;
// If no beforeLayer specified, try to insert before draw layers
if (!beforeLayer) {
const layers = this.map.getStyle().layers;
for (const layer of layers) {
if (layer.id.startsWith('gl-draw-') ||
layer.id.startsWith('custom-draw-') ||
layer.id.startsWith('polygon-') ||
layer.id === 'green-surfaces') {
beforeLayer = layer.id;
break;
}
}
}
// Build layer config
const mapLayerConfig = {
id: layerId,
source: sourceId,
...layerStyle
};
this.map.addLayer(mapLayerConfig, beforeLayer);
const state = this.layerStates.get(layerConfig.id);
if (state) {
state.layerAdded = true;
state.visible = true;
}
}
/**
* Toggle layer visibility
*/
toggleLayer(layerId) {
const layerConfig = this.layers.find(l => l.id === layerId);
if (!layerConfig) return;
const state = this.layerStates.get(layerId);
if (!state) return;
// Check if there's a custom toggle callback
if (layerConfig.onToggle && typeof layerConfig.onToggle === 'function') {
// Use custom callback instead of default behavior
layerConfig.onToggle();
// Toggle the state manually
state.visible = !state.visible;
this._updateButtonState(layerId, state.visible);
return;
}
// Check if this is an existing layer configuration
if (layerConfig.existingLayerIds && layerConfig.existingLayerIds.length > 0) {
// Toggle existing layers
const newVisibility = state.visible ? 'none' : 'visible';
layerConfig.existingLayerIds.forEach(existingLayerId => {
if (this.map.getLayer(existingLayerId)) {
this.map.setLayoutProperty(existingLayerId, 'visibility', newVisibility);
}
});
state.visible = !state.visible;
} else {
// Handle new layers created by this control
const mapLayerId = `layer-switcher-${layerId}`;
if (state.visible) {
// Hide layer
if (this.map.getLayer(mapLayerId)) {
this.map.setLayoutProperty(mapLayerId, 'visibility', 'none');
}
state.visible = false;
// Hide sublayer selector if exists
if (layerConfig.sublayers && layerConfig.sublayers.length > 0) {
this._hideSublayerSelector(layerId);
}
} else {
// Handle exclusive layers
if (layerConfig.exclusive && layerConfig.group) {
this._hideGroupLayers(layerConfig.group, layerId);
}
// Show layer
if (this.map.getLayer(mapLayerId)) {
this.map.setLayoutProperty(mapLayerId, 'visibility', 'visible');
} else {
this._addLayer(layerConfig);
}
state.visible = true;
// Show sublayer selector if exists
if (layerConfig.sublayers && layerConfig.sublayers.length > 0) {
this._showSublayerSelector(layerId);
}
}
}
this._updateButtonState(layerId, state.visible);
}
/**
* Hide all layers in a group except the specified one
*/
_hideGroupLayers(group, exceptLayerId) {
this.layers.forEach(layerConfig => {
if (layerConfig.group === group && layerConfig.id !== exceptLayerId) {
const mapLayerId = `layer-switcher-${layerConfig.id}`;
const state = this.layerStates.get(layerConfig.id);
if (state && state.visible && this.map.getLayer(mapLayerId)) {
this.map.setLayoutProperty(mapLayerId, 'visibility', 'none');
state.visible = false;
this._updateButtonState(layerConfig.id, false);
}
}
});
}
/**
* Update button visual state
*/
_updateButtonState(layerId, isActive) {
const button = this.buttons[layerId];
if (button) {
button.classList.toggle('active', isActive);
const layerConfig = this.layers.find(l => l.id === layerId);
if (layerConfig) {
button.title = isActive
? `Masquer ${layerConfig.name || layerConfig.id}`
: `Afficher ${layerConfig.name || layerConfig.id}`;
}
}
}
/**
* Programmatically show a layer
*/
showLayer(layerId) {
const state = this.layerStates.get(layerId);
if (state && !state.visible) {
this.toggleLayer(layerId);
}
}
/**
* Programmatically hide a layer
*/
hideLayer(layerId) {
const state = this.layerStates.get(layerId);
if (state && state.visible) {
this.toggleLayer(layerId);
}
}
/**
* Check if a layer is visible
*/
isLayerVisible(layerId) {
const state = this.layerStates.get(layerId);
return state ? state.visible : false;
}
}
// Export to global scope
window.LayerSwitcherControl = LayerSwitcherControl;
})(window);