1// modules/maplibre-compat.js 2// Leaflet-compatible API backed by MapLibre GL JS 3// Provides window.L namespace so existing modules work with minimal changes 4 5;(function(win) { 6 'use strict'; 7 8 if (!win.maplibregl) { 9 console.error('[MapLibreCompat] maplibregl not loaded'); 10 return; 11 } 12 13 const mlgl = win.maplibregl; 14 let _stampCounter = 0; 15 const _stampMap = new WeakMap(); 16 17 const L = {}; 18 19 // ============================================================ 20 // UTILITIES 21 // ============================================================ 22 23 L.stamp = function(obj) { 24 if (!obj) return 0; 25 if (_stampMap.has(obj)) return _stampMap.get(obj); 26 const id = ++_stampCounter; 27 _stampMap.set(obj, id); 28 return id; 29 }; 30 31 // Walk nested GeoJSON coordinates, call fn(lng, lat) on each leaf 32 function _walkCoords(coords, fn) { 33 if (typeof coords[0] === 'number') { fn(coords[0], coords[1]); return; } 34 for (let i = 0; i < coords.length; i++) _walkCoords(coords[i], fn); 35 } 36 37 function toLatLng(a, b) { 38 if (a == null) return { lat: 0, lng: 0 }; 39 if (typeof a === 'number' && typeof b === 'number') return { lat: a, lng: b }; 40 if (Array.isArray(a)) return { lat: a[0], lng: a[1] }; 41 if (a.lat !== undefined) return { lat: a.lat, lng: a.lng }; 42 return { lat: 0, lng: 0 }; 43 } 44 45 L.latLng = function(a, b) { 46 const ll = toLatLng(a, b); 47 ll.distanceTo = function(other) { 48 const o = toLatLng(other); 49 const R = 6371000; 50 const dLat = (o.lat - ll.lat) * Math.PI / 180; 51 const dLng = (o.lng - ll.lng) * Math.PI / 180; 52 const aVal = Math.sin(dLat/2)**2 + Math.cos(ll.lat*Math.PI/180)*Math.cos(o.lat*Math.PI/180)*Math.sin(dLng/2)**2; 53 return R * 2 * Math.atan2(Math.sqrt(aVal), Math.sqrt(1 - aVal)); 54 }; 55 ll.equals = function(other) { 56 if (!other) return false; 57 const o = toLatLng(other); 58 return Math.abs(ll.lat - o.lat) < 1e-9 && Math.abs(ll.lng - o.lng) < 1e-9; 59 }; 60 return ll; 61 }; 62 63 // Invalid bounds returned when a layer has no geometry (Leaflet compat) 64 const _invalidBounds = { 65 _southWest: null, _northEast: null, 66 getSouthWest: () => null, getNorthEast: () => null, 67 getCenter: () => L.latLng(0, 0), 68 contains: () => false, extend: () => _invalidBounds, 69 isValid: () => false, toBBoxArray: () => [0, 0, 0, 0] 70 }; 71 72 L.latLngBounds = function(sw, ne) { 73 const _sw = toLatLng(sw); 74 const _ne = toLatLng(ne); 75 const bounds = { 76 _southWest: _sw, 77 _northEast: _ne, 78 getSouthWest: () => _sw, 79 getNorthEast: () => _ne, 80 getCenter: () => L.latLng((_sw.lat + _ne.lat) / 2, (_sw.lng + _ne.lng) / 2), 81 contains: (ll) => { 82 const p = toLatLng(ll); 83 return p.lat >= _sw.lat && p.lat <= _ne.lat && p.lng >= _sw.lng && p.lng <= _ne.lng; 84 }, 85 extend: (ll) => { 86 // Accepter un bounds OU un point 87 if (ll && ll._southWest && ll._northEast) { 88 _sw.lat = Math.min(_sw.lat, ll._southWest.lat); 89 _sw.lng = Math.min(_sw.lng, ll._southWest.lng); 90 _ne.lat = Math.max(_ne.lat, ll._northEast.lat); 91 _ne.lng = Math.max(_ne.lng, ll._northEast.lng); 92 } else { 93 const p = toLatLng(ll); 94 _sw.lat = Math.min(_sw.lat, p.lat); 95 _sw.lng = Math.min(_sw.lng, p.lng); 96 _ne.lat = Math.max(_ne.lat, p.lat); 97 _ne.lng = Math.max(_ne.lng, p.lng); 98 } 99 return bounds; 100 }, 101 isValid: () => true, 102 toBBoxArray: () => [_sw.lng, _sw.lat, _ne.lng, _ne.lat] 103 }; 104 return bounds; 105 }; 106 107 // ============================================================ 108 // COLOR UTILITIES 109 // ============================================================ 110 111 // Default layer color - replaces Leaflet legacy #3388ff. 112 // Uses the CSS primary token so it reacts to city branding + theme. 113 const _defaultColor = 'var(--color-primary)'; 114 115 // Convert CSS color (including var(), color-mix(), etc.) to a value MapLibre GL accepts - cached 116 const _colorCache = new Map(); 117 // Reusable hidden element for forcing browser color resolution 118 let _colorProbe = null; 119 function _getColorProbe() { 120 if (!_colorProbe) { 121 _colorProbe = document.createElement('div'); 122 _colorProbe.style.cssText = 'position:absolute;width:0;height:0;visibility:hidden;pointer-events:none'; 123 document.body.appendChild(_colorProbe); 124 } 125 return _colorProbe; 126 } 127 // Convert a browser-serialized color string (rgb(...) or color(srgb ...)) to #rrggbb. 128 // Returns null if the format is not recognised. 129 function _parseBrowserColor(str) { 130 // rgb(r, g, b) - standard serialization on all browsers 131 const rgbM = str.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/); 132 if (rgbM) return '#' + [rgbM[1], rgbM[2], rgbM[3]].map(n => parseInt(n).toString(16).padStart(2, '0')).join(''); 133 // color(srgb r g b) - CSS Color Level 4 format returned by modern browsers (Safari â¥15, Chrome â¥111) 134 // values are floats in [0, 1], multiply à 255 to get 8-bit channels 135 const srgbM = str.match(/^color\(\s*srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)(?:\s*\/\s*[\d.]+)?\s*\)$/); 136 if (srgbM) return '#' + [srgbM[1], srgbM[2], srgbM[3]].map(n => Math.round(Math.min(1, Math.max(0, parseFloat(n))) * 255).toString(16).padStart(2, '0')).join(''); 137 return null; 138 } 139
140 function resolveColor(color) { 141 if (!color || typeof color !== 'string') return color; 142 // Plain hex/rgb/named color - MapLibre handles these natively 143 if (!color.startsWith('var(') && !color.startsWith('color-mix(')) return color; 144 145 const cached = _colorCache.get(color); 146 if (cached !== undefined) return cached; 147 148 // Step 1: resolve var() â get the raw CSS value (may itself be color-mix or another function) 149 let raw = color; 150 if (color.startsWith('var(')) { 151 const varName = color.match(/var\((--[^,)]+)/)?.[1]; 152 if (varName) { 153 raw = getComputedStyle(document.documentElement).getPropertyValue(varName).trim() || color; 154 } 155 } 156 157 // Step 2: if still a CSS function (color-mix, color(srgbâ¦), oklch, etc.), resolve via a probe element 158 if (raw.startsWith('color-mix(') || raw.startsWith('oklch(') || raw.startsWith('hsl(') || raw.startsWith('rgb(') || raw.startsWith('color(')) { 159 try { 160 const probe = _getColorProbe(); 161 probe.style.color = raw; 162 const computed = getComputedStyle(probe).color; // usually rgb(r, g, b); may be color(srgb ...) on P3 displays 163 probe.style.color = ''; 164 if (computed && computed !== raw) { 165 const result = _parseBrowserColor(computed) ?? computed; 166 _colorCache.set(color, result); 167 return result; 168 } 169 } catch {} 170 // Fallback: probe returned same string (P3 display) - parse color(srgb â¦) directly 171 const direct = _parseBrowserColor(raw); 172 if (direct) { 173 _colorCache.set(color, direct); 174 return direct; 175 } 176 } 177 178 // Don't cache failed resolutions: CSS vars may not be loaded yet 179 if (!raw.startsWith('var(')) _colorCache.set(color, raw); 180 return raw; 181 } 182 // Clear cache on theme change 183 function clearColorCache() { _colorCache.clear(); } 184 185 // ============================================================ 186 // SOURCE POOL - Reduce MapLibre GL sources 187 // ============================================================ 188 189 class SourcePool { 190 constructor() { 191 this._pools = new Map(); // key: styleHash, value: { sourceId, features: [], layers: [] } 192 this._stampToPool = new Map(); // stamp â styleHash for O(1) pool lookup in removeFeature 193 this._nextId = 0; 194 this._batching = false; 195 this._dirtyPools = new Set(); 196 this._activeFilter = null; // persisted filter expression 197 this._activeMlMap = null; // map reference for deferred filter application 198 } 199 200 beginBatch() { 201 this._batching = true; 202 this._dirtyPools.clear(); 203 } 204 205 endBatch(mlMap) { 206 this._batching = false; 207 for (const styleHash of this._dirtyPools) { 208 const pool = this._pools.get(styleHash); 209 if (pool) { 210 const source = mlMap.getSource(pool.sourceId); 211 if (source) { 212 source.setData({ type: 'FeatureCollection', features: pool.features }); 213 } 214 } 215 } 216 this._dirtyPools.clear(); 217 } 218 219 _getStyleHash(geomType, style) { 220 // Create a hash based on geometry type and ALL style properties 221 const parts = [ 222 geomType, 223 resolveColor(style.color) || 'default', 224 style.weight || 3, 225 style.opacity !== undefined ? style.opacity : 1 226 ]; 227 228 // For polygons, include fill properties 229 if (geomType === 'Polygon' || geomType === 'MultiPolygon') { 230 parts.push(resolveColor(style.fillColor || style.color) || 'default'); 231 parts.push(style.fillOpacity !== undefined ? style.fillOpacity : 0.2); 232 } 233 234 return parts.join('-'); 235 } 236 237 addFeature(mlMap, geomType, geojson, style, pathLayer) { 238 const styleHash = this._getStyleHash(geomType, style); 239 240 if (!this._pools.has(styleHash)) { 241 // Create new pooled source 242 const sourceId = `pool-${this._nextId++}`; 243 const layerId = `${sourceId}-layer`; 244 245 // Add feature ID for event mapping and feature-state support 246 geojson.properties = geojson.properties || {}; 247 geojson.properties._pool_layer_id = L.stamp(pathLayer); 248 geojson.id = L.stamp(pathLayer); // MapLibre GL feature ID for feature-state 249 250 mlMap.addSource(sourceId, { 251 type: 'geojson', 252 data: { type: 'FeatureCollection', features: [geojson] }, 253 generateId: false // Use our own IDs 254 }); 255 256 const w = style.weight || 3; 257 const baseOp = style.opacity !== undefined ? style.opacity : 0.8; 258 const baseFillOp = style.fillOpacity !== undefined ? style.fillOpacity : 0.2;
259 const lineColor = resolveColor(style.color) || resolveColor(_defaultColor); 260 const linePaint = { 261 'line-color': lineColor, 262 'line-width': ['case', 263 ['boolean', ['feature-state', 'selected'], false], w + 4, 264 ['boolean', ['feature-state', 'hover'], false], w + 2, 265 w 266 ], 267 'line-opacity': ['case', 268 ['boolean', ['feature-state', 'selected'], false], 1, 269 ['boolean', ['feature-state', 'hover'], false], 1, 270 ['boolean', ['feature-state', 'dimmed'], false], 0.15, 271 baseOp 272 ] 273 }; 274 275 if (geomType === 'LineString' || geomType === 'MultiLineString') { 276 mlMap.addLayer({ id: layerId, type: 'line', source: sourceId, paint: linePaint }); 277 } else if (geomType === 'Polygon' || geomType === 'MultiPolygon') { 278 const fillLayerId = `${sourceId}-fill`; 279 mlMap.addLayer({ 280 id: fillLayerId, type: 'fill', source: sourceId, 281 paint: { 282 'fill-color': resolveColor(style.fillColor || style.color) || resolveColor(_defaultColor), 283 'fill-opacity': ['case', 284 ['boolean', ['feature-state', 'selected'], false], Math.min(baseFillOp + 0.35, 0.7), 285 ['boolean', ['feature-state', 'hover'], false], Math.min(baseFillOp + 0.2, 0.6), 286 ['boolean', ['feature-state', 'dimmed'], false], 0.05, 287 baseFillOp 288 ] 289 } 290 }); 291 mlMap.addLayer({ id: layerId, type: 'line', source: sourceId, paint: linePaint }); 292 } 293 294 const newPool = { 295 sourceId, 296 layerId, 297 fillLayerId: geomType === 'Polygon' || geomType === 'MultiPolygon' ? `${sourceId}-fill` : null, 298 features: [geojson], 299 layers: [pathLayer], 300 geomType 301 }; 302 this._pools.set(styleHash, newPool); 303 this._stampToPool.set(L.stamp(pathLayer), styleHash); 304 this._applyFilterToPool(newPool); 305 306 // Events (hover/click/cursor) handled by FeatureInteractions at map level 307 } else { 308 // Add to existing pool 309 const pool = this._pools.get(styleHash); 310 geojson.properties = geojson.properties || {}; 311 geojson.properties._pool_layer_id = L.stamp(pathLayer); 312 geojson.id = L.stamp(pathLayer); // MapLibre GL feature ID 313 314 pool.features.push(geojson); 315 pool.layers.push(pathLayer); 316 this._stampToPool.set(L.stamp(pathLayer), styleHash); 317 318 // Update source (deferred if batching) 319 if (this._batching) { 320 this._dirtyPools.add(styleHash); 321 } else { 322 const source = mlMap.getSource(pool.sourceId); 323 if (source) { 324 source.setData({ 325 type: 'FeatureCollection', 326 features: pool.features 327 }); 328 } 329 } 330 331 // Events handled by FeatureInteractions at map level 332 } 333 } 334 335 336 // Set a MapLibre filter expression on all pool layers (and persist for future pools) 337 setFilter(mlMap, filterExpr) { 338 this._activeFilter = filterExpr; 339 this._activeMlMap = mlMap; 340 for (const pool of this._pools.values()) { 341 try { 342 if (mlMap.getLayer(pool.layerId)) mlMap.setFilter(pool.layerId, filterExpr); 343 if (pool.fillLayerId && mlMap.getLayer(pool.fillLayerId)) mlMap.setFilter(pool.fillLayerId, filterExpr); 344 } catch {} 345 } 346 } 347 348 // Apply the stored filter to a single newly-registered pool (called from _createDirectSource) 349 _applyFilterToPool(pool) { 350 if (!this._activeFilter || !this._activeMlMap) return; 351 const mlMap = this._activeMlMap; 352 try { 353 if (mlMap.getLayer(pool.layerId)) mlMap.setFilter(pool.layerId, this._activeFilter); 354 if (pool.fillLayerId && mlMap.getLayer(pool.fillLayerId)) mlMap.setFilter(pool.fillLayerId, this._activeFilter); 355 } catch {} 356 } 357 358 // Clear filters on all pool layers 359 clearFilter(mlMap) { 360 this.setFilter(mlMap, null); 361 } 362 363 // Update line-width paint property on all pool layers (O(pools) instead of per-feature setStyle) 364 setLineWidth(mlMap, width) { 365 for (const pool of this._pools.values()) { 366 try { 367 if (mlMap.getLayer(pool.layerId)) { 368 mlMap.setPaintProperty(pool.layerId, 'line-width', [ 369 'case', 370 ['boolean', ['feature-state', 'selected'], false], width + 4, 371 ['boolean', ['feature-state', 'hover'], false], width + 2, 372 width 373 ]); 374 } 375 } catch {} 376 } 377 } 378
379 removeFeature(mlMap, pathLayer) { 380 const layerId = L.stamp(pathLayer); 381 382 // O(1) pool lookup via stamp map (instead of iterating all pools) 383 const styleHash = this._stampToPool.get(layerId); 384 if (styleHash === undefined) return; 385 this._stampToPool.delete(layerId); 386 387 const pool = this._pools.get(styleHash); 388 if (!pool) return; 389 390 const idx = pool.layers.findIndex(l => L.stamp(l) === layerId); 391 if (idx >= 0) { 392 pool.layers.splice(idx, 1); 393 pool.features.splice(idx, 1); 394 395 if (pool.features.length === 0) { 396 // Notify FeatureInteractions before removing the source 397 if (win.FeatureInteractions) { 398 win.FeatureInteractions.invalidateSource(pool.sourceId); 399 } 400 // Remove empty pool - clean up all layers 401 try { 402 if (pool.fillLayerId && mlMap.getLayer(pool.fillLayerId)) { 403 mlMap.removeLayer(pool.fillLayerId); 404 } 405 if (mlMap.getLayer(pool.layerId)) { 406 mlMap.removeLayer(pool.layerId); 407 } 408 if (mlMap.getSource(pool.sourceId)) { 409 mlMap.removeSource(pool.sourceId); 410 } 411 } catch (e) { 412 console.debug('[SourcePool] Error removing empty pool:', e); 413 } 414 this._pools.delete(styleHash); 415 } else { 416 // Update source with remaining features (deferred if batching) 417 if (this._batching) { 418 this._dirtyPools.add(styleHash); 419 } else { 420 const source = mlMap.getSource(pool.sourceId); 421 if (source) { 422 source.setData({ 423 type: 'FeatureCollection', 424 features: pool.features 425 }); 426 } 427 } 428 } 429 } 430 } 431 432 // Re-resolve CSS var colors and update paint properties on all pools (called on theme change) 433 repaintColors(mlMap) { 434 for (const pool of this._pools.values()) { 435 let colorStr, fillStr, needsRepaint; 436 437 if (pool.layers.length > 0) { 438 // SourcePool-managed path (shared pool with PathLayer references) 439 const style = pool.layers[0]._style; 440 if (!style) continue; 441 colorStr = style.color || ''; 442 fillStr = style.fillColor || style.color || ''; 443 } else if (pool._rawColor) { 444 // Direct-path pool (GeoJSONLayer bulk render, no PathLayer references) 445 colorStr = pool._rawColor; 446 fillStr = pool._rawFillColor || pool._rawColor; 447 } else { 448 continue; 449 } 450 451 needsRepaint = colorStr.startsWith('var(') || colorStr.startsWith('color-mix(') 452 || fillStr.startsWith('var(') || fillStr.startsWith('color-mix('); 453 if (!needsRepaint) continue; 454 455 const newColor = resolveColor(colorStr) || resolveColor(_defaultColor); 456 const newFill = resolveColor(fillStr) || resolveColor(_defaultColor); 457 458 // For direct-path pools with per-feature _color, use data-driven expression 459 const lineExpr = pool._hasPerFeatureColor ? ['coalesce', ['get', '_color'], newColor] : newColor; 460 const fillExpr = pool._hasPerFeatureColor ? ['coalesce', ['get', '_color'], newFill] : newFill; 461 462 try { 463 if (mlMap.getLayer(pool.layerId)) { 464 mlMap.setPaintProperty(pool.layerId, 'line-color', lineExpr); 465 } 466 } catch {} 467 468 if (pool.fillLayerId) { 469 try { 470 if (mlMap.getLayer(pool.fillLayerId)) { 471 mlMap.setPaintProperty(pool.fillLayerId, 'fill-color', fillExpr); 472 } 473 } catch {} 474 } 475 } 476 } 477 } 478 479 // Global source pool 480 L._sourcePool = new SourcePool(); 481 482 // ============================================================ 483 // EVENT MIXIN 484 // ============================================================ 485 486 class EventEmitter { 487 constructor() { this._events = {}; } 488 on(type, fn) { 489 if (!this._events[type]) this._events[type] = []; 490 this._events[type].push(fn); 491 return this; 492 }
493 off(type, fn) { 494 if (!this._events[type]) return this; 495 if (fn) this._events[type] = this._events[type].filter(f => f !== fn); 496 else delete this._events[type]; 497 return this; 498 } 499 once(type, fn) { 500 const wrapped = (...args) => { this.off(type, wrapped); fn.apply(this, args); }; 501 return this.on(type, wrapped); 502 } 503 fire(type, data) { 504 if (!this._events[type]) return this; 505 const evt = Object.assign({ type, target: this }, data || {}); 506 this._events[type].slice().forEach(fn => { try { fn(evt); } catch (err) { console.debug('[Evented] handler error:', err); } }); 507 return this; 508 } 509 listens(type) { return !!(this._events[type] && this._events[type].length); } 510 addEventListener(t, fn) { return this.on(t, fn); } 511 removeEventListener(t, fn) { return this.off(t, fn); } 512 } 513 514 // ============================================================ 515 // ICON CLASSES 516 // ============================================================ 517 518 class DivIcon { 519 constructor(options) { 520 this.options = Object.assign({ 521 html: '', 522 className: 'leaflet-div-icon', 523 iconSize: [12, 12], 524 iconAnchor: null, 525 popupAnchor: [0, -30] 526 }, options); 527 } 528 createIcon() { 529 const el = document.createElement('div'); 530 el.className = this.options.className || ''; 531 if (typeof this.options.html === 'string') el.innerHTML = this.options.html; 532 else if (this.options.html instanceof HTMLElement) el.appendChild(this.options.html); 533 if (this.options.iconSize) { 534 const s = Array.isArray(this.options.iconSize) ? this.options.iconSize : [this.options.iconSize, this.options.iconSize]; 535 el.style.width = s[0] + 'px'; 536 el.style.height = s[1] + 'px'; 537 } 538 return el; 539 } 540 } 541 542 class Icon { 543 constructor(options) { 544 this.options = Object.assign({ 545 iconUrl: '', 546 iconRetinaUrl: '', 547 shadowUrl: '', 548 iconSize: [25, 41], 549 iconAnchor: [12, 41], 550 popupAnchor: [1, -34], 551 shadowSize: [41, 41] 552 }, options); 553 } 554 createIcon() { 555 const el = document.createElement('div'); 556 const img = document.createElement('img'); 557 img.src = this.options.iconUrl; 558 if (this.options.iconSize) { 559 img.style.width = this.options.iconSize[0] + 'px'; 560 img.style.height = this.options.iconSize[1] + 'px'; 561 } 562 el.appendChild(img); 563 return el; 564 } 565 } 566 567 // Icon.Default setup - use a wrapper object instead of modifying class prototype 568 Icon.Default = function(options) { return new Icon(options); }; 569 Icon.Default._getIconUrl = true; // Stub so delete L.Icon.Default._getIconUrl works 570 Icon.Default.prototype = { _getIconUrl: true }; // For delete L.Icon.Default.prototype._getIconUrl 571 Icon.Default.mergeOptions = function(opts) { 572 // Merge into Icon's default options 573 if (!Icon._defaultOptions) Icon._defaultOptions = {}; 574 Object.assign(Icon._defaultOptions, opts); 575 }; 576 577 L.divIcon = function(options) { return new DivIcon(options); }; 578 L.icon = function(options) { return new Icon(options); }; 579 L.Icon = Icon; 580 L.DivIcon = DivIcon; 581 582 // ============================================================ 583 // POPUP 584 // ============================================================ 585 586 class Popup extends EventEmitter { 587 constructor(options) { 588 super(); 589 this.options = Object.assign({ offset: [0, -30], className: '', maxWidth: 300, closeButton: true, autoPan: true }, options); 590 this._mlPopup = null; 591 this._content = ''; 592 this._latlng = null; 593 } 594 setContent(content) { this._content = content; return this; } 595 setLatLng(latlng) { this._latlng = toLatLng(latlng); return this; } 596 getLatLng() { return this._latlng; } 597 getContent() { return this._content; } 598 openOn(map) { 599 if (!this._latlng) return this; 600 if (this._mlPopup) this._mlPopup.remove(); 601 const offset = this.options.offset || [0, -30]; 602 this._mlPopup = new mlgl.Popup({ 603 offset: Array.isArray(offset) ? offset : [0, typeof offset === 'number' ? offset : -30], 604 className: this.options.className || '', 605 maxWidth: this.options.maxWidth || 300, 606 closeButton: this.options.closeButton !== false 607 }) 608 .setLngLat([this._latlng.lng, this._latlng.lat]) 609 .setHTML(typeof this._content === 'string' ? this._content : '') 610 .addTo(map._mlMap || map); 611 return this; 612 } 613 remove() { if (this._mlPopup) { this._mlPopup.remove(); this._mlPopup = null; } return this; } 614 isOpen() { return !!this._mlPopup; } 615 } 616 617 L.popup = function(options) { return new Popup(options); }; 618 L.Popup = Popup; 619 620 // ============================================================ 621 // TOOLTIP (implemented as small popup) 622 // ============================================================ 623
624 class Tooltip extends EventEmitter { 625 constructor(options) { 626 super(); 627 this.options = Object.assign({ 628 permanent: false, direction: 'top', className: '', offset: [0, 0], opacity: 0.9, interactive: false, sticky: false 629 }, options); 630 this._content = ''; 631 this._mlPopup = null; 632 this._latlng = null; 633 } 634 setContent(content) { this._content = content; return this; } 635 setLatLng(latlng) { 636 this._latlng = toLatLng(latlng); 637 if (this._mlPopup) this._mlPopup.setLngLat([this._latlng.lng, this._latlng.lat]); 638 return this; 639 } 640 addTo(map) { 641 if (!this._latlng) return this; 642 const anchor = this.options.direction === 'bottom' ? 'top' : 'bottom'; 643 this._mlPopup = new mlgl.Popup({ 644 closeButton: false, 645 closeOnClick: !this.options.permanent, 646 className: 'maplibre-tooltip ' + (this.options.className || ''), 647 anchor: anchor, 648 offset: this.options.offset || [0, 0] 649 }) 650 .setLngLat([this._latlng.lng, this._latlng.lat]) 651 .setHTML(typeof this._content === 'string' ? this._content : '') 652 .addTo(map._mlMap || map); 653 return this; 654 } 655 remove() { if (this._mlPopup) { this._mlPopup.remove(); this._mlPopup = null; } return this; } 656 } 657 658 L.tooltip = function(options) { return new Tooltip(options); }; 659 L.Tooltip = Tooltip; 660 661 // ============================================================ 662 // MARKER 663 // ============================================================ 664 665 class LMarker extends EventEmitter { 666 constructor(latlng, options) { 667 super(); 668 this._latlng = toLatLng(latlng); 669 this.options = Object.assign({ icon: null, riseOnHover: false, draggable: false, pane: 'markerPane' }, options); 670 this._mlMarker = null; 671 this._map = null; 672 this._tooltip = null; 673 this._popup = null; 674 this._popupContent = null; 675 this._visible = true; 676 this.feature = null; 677 } 678 addTo(map) { 679 this._map = map; 680 const el = this._createEl(); 681 const anchor = this._getAnchor(); 682 this._mlMarker = new mlgl.Marker({ element: el, anchor: 'center', offset: anchor, draggable: this.options.draggable }) 683 .setLngLat([this._latlng.lng, this._latlng.lat]); 684 if (map._mlMap) this._mlMarker.addTo(map._mlMap); 685 else this._mlMarker.addTo(map); 686 687 // Wire DOM events on the marker element to our EventEmitter 688 const self = this; 689 el.addEventListener('click', function(ev) { 690 ev.stopPropagation(); 691 self.fire('click', { latlng: self._latlng, originalEvent: ev, target: self }); 692 }); 693 el.addEventListener('mouseenter', function(ev) { 694 self.fire('mouseover', { latlng: self._latlng, originalEvent: ev, target: self }); 695 }); 696 el.addEventListener('mouseleave', function(ev) { 697 self.fire('mouseout', { latlng: self._latlng, originalEvent: ev, target: self }); 698 }); 699 el.addEventListener('contextmenu', function(ev) { 700 self.fire('contextmenu', { latlng: self._latlng, originalEvent: ev, target: self }); 701 }); 702 703 if (this.options.draggable) { 704 this._mlMarker.on('dragend', () => { 705 const ll = this._mlMarker.getLngLat(); 706 this._latlng = { lat: ll.lat, lng: ll.lng }; 707 this.fire('dragend', { target: this }); 708 this.fire('drag', { target: this }); 709 }); 710 } 711 this.fire('add', { target: this }); 712 return this; 713 } 714 remove() { 715 if (this._mlMarker) { this._mlMarker.remove(); this._mlMarker = null; } 716 if (this._tooltip) this._tooltip.remove(); 717 if (this._popup && this._popup._mlPopup) this._popup._mlPopup.remove(); 718 this.fire('remove', { target: this }); 719 return this; 720 } 721 setLatLng(latlng) { 722 this._latlng = toLatLng(latlng); 723 if (this._mlMarker) this._mlMarker.setLngLat([this._latlng.lng, this._latlng.lat]); 724 return this; 725 } 726 getLatLng() { return this._latlng; } 727 setIcon(icon) { 728 this.options.icon = icon; 729 if (this._mlMarker) { 730 const el = this._createEl(); 731 this._mlMarker.getElement().replaceWith(el); 732 // La nouvelle icône peut avoir un ancrage différent 733 this._mlMarker.setOffset(this._getAnchor()); 734 } 735 return this; 736 } 737 setOpacity(val) { 738 if (this._mlMarker) this._mlMarker.getElement().style.opacity = val; 739 return this; 740 } 741 getElement() { 742 return this._mlMarker ? this._mlMarker.getElement() : null; 743 } 744 bindTooltip(content, options) { 745 this._tooltipContent = content; 746 this._tooltipOptions = options || {}; 747 return this; 748 } 749 openTooltip() { 750 if (!this._tooltipContent || !this._map) return this; 751 if (this._tooltip) this._tooltip.remove(); 752 this._tooltip = new Tooltip(this._tooltipOptions); 753 this._tooltip.setContent(typeof this._tooltipContent === 'function' ? this._tooltipContent(this) : this._tooltipContent); 754 this._tooltip.setLatLng(this._latlng); 755 this._tooltip.addTo(this._map); 756 return this; 757 } 758 closeTooltip() { 759 if (this._tooltip) { this._tooltip.remove(); this._tooltip = null; } 760 return this; 761 } 762 bindPopup(content, options) { 763 this._popupContent = content; 764 this._popupOptions = options || {}; 765 return this; 766 } 767 openPopup() { 768 if (!this._popupContent || !this._map) return this; 769 if (this._popup) this._popup.remove(); 770 // Leaflet : popupAnchor est relatif au point d'ancrage de l'icône, pas à son centre. 771 // Sans ça la popup se superpose au pin maintenant que celui-ci est correctement ancré. 772 const popupOptions = Object.assign({}, this._popupOptions); 773 if (!popupOptions.offset && this.options.icon?.options?.popupAnchor) { 774 popupOptions.offset = this.options.icon.options.popupAnchor; 775 } 776 this._popup = new Popup(popupOptions); 777 this._popup.setContent(typeof this._popupContent === 'function' ? this._popupContent(this) : this._popupContent); 778 this._popup.setLatLng(this._latlng); 779 this._popup.openOn(this._map); 780 return this; 781 } 782 closePopup() { 783 if (this._popup) { this._popup.remove(); this._popup = null; } 784 return this; 785 } 786 togglePopup() { 787 if (this._popup && this._popup.isOpen()) this.closePopup(); 788 else this.openPopup(); 789 return this; 790 } 791 setZIndexOffset(offset) { 792 if (this._mlMarker) this._mlMarker.getElement().style.zIndex = offset; 793 return this; 794 } 795 toGeoJSON() { 796 return { type: 'Feature', geometry: { type: 'Point', coordinates: [this._latlng.lng, this._latlng.lat] }, properties: this.feature ? this.feature.properties : {} }; 797 } 798 getBounds() { 799 return L.latLngBounds([this._latlng.lat, this._latlng.lng], [this._latlng.lat, this._latlng.lng]); 800 } 801 _createEl() { 802 const icon = this.options.icon; 803 if (icon && typeof icon.createIcon === 'function') return icon.createIcon(); 804 const el = document.createElement('div'); 805 el.className = 'maplibre-default-marker'; 806 el.innerHTML = `<svg width="25" height="41" viewBox="0 0 25 41"><path d="M12.5 0C5.6 0 0 5.6 0 12.5c0 2.5.7 4.8 2 6.8L12.5 41l10.5-21.7c1.3-2 2-4.3 2-6.8C25 5.6 19.4 0 12.5 0z" fill="${resolveColor(_defaultColor)}"/><circle cx="12.5" cy="12.5" r="5" fill="white"/></svg>`; 807 return el; 808 } 809 _getAnchor() { 810 // MapLibre positionne le centre de l'élément sur project(lngLat) + offset (anchor: 'center'). 811 // Pour que le point iconAnchor de l'icône tombe pile sur la coordonnée, il faut décaler 812 // de (taille / 2 - iconAnchor) : un pin 32x40 ancré en [16, 40] remonte donc de 20px, 813 // sa pointe se cale sur le point géographique. 814 const icon = this.options.icon; 815 if (icon && icon.options && icon.options.iconAnchor) { 816 const a = icon.options.iconAnchor; 817 const s = icon.options.iconSize || [0, 0]; 818 return [s[0]/2 - a[0], s[1]/2 - a[1]]; 819 } 820 return [0, 0]; 821 } 822 } 823 824 L.Marker = LMarker; 825 L.marker = function(latlng, options) { return new LMarker(latlng, options); }; 826 827 // ============================================================ 828 // TILE LAYER 829 // ============================================================ 830
831 class TileLayer extends EventEmitter { 832 constructor(urlTemplate, options) { 833 super(); 834 this.options = Object.assign({ attribution: '', tileSize: 256 }, options); 835 this._urlTemplate = urlTemplate; 836 this._map = null; 837 this._sourceId = 'basemap-' + (++_stampCounter); 838 this._layerId = this._sourceId + '-layer'; 839 } 840 addTo(map) { 841 this._map = map; 842 const doAdd = () => { 843 const mlMap = map._mlMap || map; 844 // Handle {s} subdomain pattern by creating multiple tile URLs 845 const urls = []; 846 if (this._urlTemplate.includes('{s}')) { 847 ['a', 'b', 'c'].forEach(s => { 848 urls.push(this._urlTemplate.replace('{s}', s)); 849 }); 850 } else { 851 urls.push(this._urlTemplate); 852 } 853 854 try { 855 if (mlMap.getLayer(this._layerId)) mlMap.removeLayer(this._layerId); 856 if (mlMap.getSource(this._sourceId)) mlMap.removeSource(this._sourceId); 857 } catch {} 858 859 mlMap.addSource(this._sourceId, { 860 type: 'raster', 861 tiles: urls, 862 tileSize: this.options.tileSize || 256, 863 attribution: this.options.attribution || '' 864 }); 865 866 // Add layer at the bottom (before any existing non-raster layers) 867 const layers = mlMap.getStyle().layers || []; 868 const firstNonRaster = layers.find(l => l.type !== 'raster'); 869 mlMap.addLayer({ 870 id: this._layerId, 871 type: 'raster', 872 source: this._sourceId 873 }, firstNonRaster ? firstNonRaster.id : undefined); 874 875 this.fire('add'); 876 }; 877 878 if (map._styleLoaded === false) { 879 map._queue.push(doAdd); 880 } else { 881 doAdd(); 882 } 883 return this; 884 } 885 remove() { 886 if (this._map) { 887 const mlMap = this._map._mlMap || this._map; 888 try { 889 if (mlMap.getLayer(this._layerId)) mlMap.removeLayer(this._layerId); 890 if (mlMap.getSource(this._sourceId)) mlMap.removeSource(this._sourceId); 891 } catch {} 892 } 893 this.fire('remove'); 894 return this; 895 } 896 setUrl(url) { 897 this._urlTemplate = url; 898 if (this._map) { this.remove(); this.addTo(this._map); } 899 return this; 900 } 901 getAttribution() { return this.options.attribution; } 902 } 903 904 L.TileLayer = TileLayer; 905 L.tileLayer = function(url, options) { return new TileLayer(url, options); }; 906 907 // ============================================================ 908 // VECTOR BASEMAP (MapLibre GL style - no setStyle(), no data loss) 909 // ============================================================ 910 // 911 // Strategy: never call mlMap.setStyle() - it wipes all user data layers. 912 // Instead, fetch the style JSON and inject its sources/layers directly using 913 // prefixed IDs (__vbm__<id>), inserted before user data layers. 914 // _cleanupOwned() scans the live style by prefix - never misses anything. 915 916 class VectorBasemap extends EventEmitter { 917 constructor(styleUrl, options) { 918 super(); 919 this._styleUrl = styleUrl; 920 this.options = Object.assign({ attribution: '' }, options); 921 this._map = null; 922 } 923 924 addTo(map) { 925 this._map = map; 926 const mlMap = map._mlMap || map; 927 // Remove any __vbm__ layers/sources from a previous apply before re-injecting 928 this._cleanupOwned(mlMap); 929 this._applyStyle(mlMap); 930 return this; 931 } 932 933 async _applyStyle(mlMap) { 934 try { 935 const resp = await fetch(this._styleUrl); 936 if (!resp.ok) throw new Error(`HTTP ${resp.status}`); 937 const styleJson = await resp.json(); 938 939 // Update glyphs so road labels render with correct fonts. 940 // setGlyphs() only updates the glyph URL - does NOT touch s
940ources or layers. 941 if (styleJson.glyphs && typeof mlMap.setGlyphs === 'function') { 942 try { mlMap.setGlyphs(styleJson.glyphs); } catch {} 943 } 944 945 // Do NOT call setSprite() - the basemap sprite references images (e.g. fill-pattern 946 // textures) that may not exist in every environment. We inject individual layers 947 // so the sprite is unnecessary; pattern layers are stripped below. 948 949 // Insertion anchor: first app-data layer - vbm layers go BELOW it 950 const anchor = this._findAnchorLayer(mlMap); 951 952 // Attribution de basemaps_v2 en secours, uniquement si AUCUNE source du 953 // style n'en fournit (sinon doublon dans le contrôle d'attribution) 954 const styleSources = Object.values(styleJson.sources || {}); 955 const needsFallbackAttrib = this.options.attribution && 956 !styleSources.some(s => s.attribution); 957 958 // Map original source IDs â prefixed IDs to avoid collisions 959 const sourceIdMap = {}; 960 let attribApplied = false; 961 for (const [origId, srcDef] of Object.entries(styleJson.sources || {})) { 962 const prefixed = `__vbm__${origId}`; 963 sourceIdMap[origId] = prefixed; 964 if (mlMap.getSource(prefixed)) continue; 965 if (needsFallbackAttrib && !attribApplied) { 966 srcDef.attribution = this.options.attribution; 967 attribApplied = true; 968 } 969 try { 970 mlMap.addSource(prefixed, srcDef); 971 } catch (e) { 972 console.debug('[VectorBasemap] addSource failed:', prefixed, e.message); 973 } 974 } 975 976 // Add layers in style order, each prefixed and wired to the prefixed source. 977 // Skip layers that use fill-pattern or icon-image - they depen
977d on the external 978 // sprite and would produce "image could not be loaded" warnings. 979 for (const layer of (styleJson.layers || [])) { 980 if (layer.source && !sourceIdMap[layer.source]) continue; 981 if (layer.paint?.['fill-pattern'] || layer.layout?.['icon-image']) continue; 982 const ml = JSON.parse(JSON.stringify(layer)); 983 ml.id = `__vbm__${layer.id}`; 984 if (ml.source) ml.source = sourceIdMap[ml.source]; 985 if (mlMap.getLayer(ml.id)) continue; 986 try { 987 mlMap.addLayer(ml, anchor); 988 } catch (e) { 989 console.debug('[VectorBasemap] addLayer failed:', ml.id, e.message); 990 } 991 } 992 993 this.fire('add'); 994 } catch (e) { 995 console.error('[VectorBasemap] Failed to load/apply style:', this._styleUrl, e); 996 } 997 } 998 999 /** 1000 * First layer that belongs to user/app data (projects, 3D buildingsâ¦). 1001 * All vbm layers are inserted BEFORE this so they render underneath. 1002 */ 1003 _findAnchorLayer(mlMap) { 1004 const style = mlMap.getStyle(); 1005 if (!style) return undefined; 1006 return (style.layers || []).find(l => 1007 !l.id.startsWith('__vbm__') && 1008 !l.id.startsWith('basemap-') && 1009 l.type !== 'background' && 1010 l.type !== 'sky' 1011 )?.id; 1012 } 1013 1014 /** 1015 * Remove ALL __vbm__ layers then ALL __vbm__ sources by scanning the live 1016 * style - no tracking list needed, never misses layers added by a prior run. 1017 * Layers MUST be removed before their sources (MapLibre requirement). 1018 */ 1019 _cleanupOwned(mlMap) { 1020 const style = mlMap.getStyle(); 1021 if (!style) return; 1022 // 1) Layers first 1023 for (const layer of (style.layers || [])) { 1024 if (!layer.id.startsWith('__vbm__')) continue; 1025 try { mlMap.removeLayer(layer.id); } catch {} 1026 } 1027 // 2) Sources after all layers referencing them are gone 1028 for (const id of Object.keys(style.sources || {})) { 1029 if (!id.startsWith('__vbm__')) continue; 1030 try { mlMap.removeSource(id); } catch {} 1031 } 1032 } 1033 1034 remove() { 1035 if (this._map) { 1036 const mlMap = this._map._mlMap || this._map; 1037 this._cleanupOwned(mlMap); 1038 } 1039 this.fire('remove'); 1040 return this; 1041 } 1042 1043 getAttribution() { return this.options.attribution; } 1044 } 1045 1046 L.VectorBasemap = VectorBasemap; 1047 L.vectorBasemap = function(styleUrl, options) { return new VectorBasemap(styleUrl, options); }; 1048 1049 /** 1050 * Factory: create the right layer type for a basemap record. 1051 * kind === 'vector' â VectorBasemap (fetches MapLibre style JSON, no setStyle) 1052 * kind === 'raster' â TileLayer (classic raster tiles) 1053 * @param {Object} bm - Basemap record from basemaps_v2 1054 * @returns {TileLayer|VectorBasemap} 1055 */ 1056 L.createBasemapLayer = function(bm) { 1057 if (bm.kind === 'vector' && bm.style_url) { 1058 return new VectorBasemap(bm.style_url, { attribution: bm.attribution }); 1059 } 1060 return new TileLayer(bm.url, { attribution: bm.attribution }); 1061 }; 1062 1063 // ============================================================ 1064 // SHAPES: Polyline, Polygon, Circle 1065 // ============================================================ 1066 1067 class PathLayer extends EventEmitter { 1068 constructor() { 1069 super(); 1070 this._map = null; 1071 this._sourceId = 'path-' + (++_stampCounter); 1072 this._layerId = this._sourceId + '-layer'; 1073 this._fillLayerId = this._sourceId + '-fill'; 1074 this._style = {}; 1075 this.feature = null; 1076 this._visible = true; 1077 } 1078 // Expose _style as options for compat (navigationmodule reads layer.options) 1079 get options() { return this._style; } 1080 set options(v) { this._style = v; } 1081 setStyle(style) { 1082 this._style = Object.assign(this._style, style); 1083 if (this._map) this._applyStyle(); 1084 return this; 1085 } 1086 setOpacity(val) { 1087 return this.setStyle({ opacity: val, fillOpacity: val * 0.5 }); 1088 } 1089 addTo(map) { 1090 this._map = map; 1091 const doAdd = () => { 1092 const mlMap = map._mlMap || map; 1093 // Only add to MapLibre if not using shared source 1094 if (!this._skipMapLibreAdd) { 1095 this._addToMap(mlMap); 1096 // Only bind events if not using pool (pool handles events itself) 1097 if (!this._usingPool) { 1098 this._bindMapEvents(mlMap); 1099 } 1100 } 1101 this.fire('add'); 1102 }; 1103 if (map._styleLoaded === false) { 1104 map._queue.push(doAdd); 1105 } else { 1106 doAdd(); 1107 } 1108 return this; 1109 } 1110 remove() { 1111 if (this._map) { 1112 const mlMap = this._map._mlMap || this._map; 1113 this._unbindMapEvents(mlMap); 1114 1115 // If using pool, remove from pool 1116 if (this._usingPool && L._sourcePool) {
1117 L._sourcePool.removeFeature(mlMap, this); 1118 } else { 1119 // Remove individual source 1120 try { 1121 if (mlMap.getLayer(this._layerId)) mlMap.removeLayer(this._layerId); 1122 if (mlMap.getLayer(this._fillLayerId)) mlMap.removeLayer(this._fillLayerId); 1123 if (mlMap.getSource(this._sourceId)) mlMap.removeSource(this._sourceId); 1124 } catch {} 1125 } 1126 } 1127 this.fire('remove'); 1128 return this; 1129 } 1130 _bindMapEvents(mlMap) { 1131 // Proxy MapLibre layer events to Leaflet-style layer events 1132 const targetLayerId = mlMap.getLayer(this._fillLayerId) ? this._fillLayerId : this._layerId; 1133 this._mlEventHandlers = {}; 1134 ['click', 'mouseenter', 'mouseleave'].forEach(mlEvent => { 1135 const lEvent = mlEvent === 'mouseenter' ? 'mouseover' : mlEvent === 'mouseleave' ? 'mouseout' : mlEvent; 1136 const handler = (e) => { 1137 const latlng = e.lngLat ? { lat: e.lngLat.lat, lng: e.lngLat.lng } : null; 1138 this.fire(lEvent, { latlng, originalEvent: e.originalEvent, target: this, containerPoint: e.point }); 1139 }; 1140 this._mlEventHandlers[mlEvent] = handler; 1141 try { mlMap.on(mlEvent, targetLayerId, handler); } catch {} 1142 }); 1143 // Change cursor on hover 1144 this._cursorEnter = () => { mlMap.getCanvas().style.cursor = 'pointer'; }; 1145 this._cursorLeave = () => { mlMap.getCanvas().style.cursor = ''; }; 1146 try { 1147 mlMap.on('mouseenter', targetLayerId, this._cursorEnter); 1148 mlMap.on('mouseleave', targetLayerId, this._cursorLeave); 1149 } catch {} 1150 } 1151 _unbindMapEvents(mlMap) { 1152 if (!this._mlEventHandlers) return; 1153 const targetLayerId = mlMap.getLayer(this._fillLayerId) ? this._fillLayerId : this._layerId; 1154 Object.entries(this._mlEventHandlers).forEach(([ev, handler]) => { 1155 try { mlMap.off(ev, targetLayerId, handler); } catch {} 1156 }); 1157 try { 1158 if (this._cursorEnter) mlMap.off('mouseenter', targetLayerId, this._cursorEnter); 1159 if (this._cursorLeave) mlMap.off('mouseleave', targetLayerId, this._cursorLeave); 1160 } catch {} 1161 } 1162 getBounds() { 1163 const coords = this._getAllCoords(); 1164 if (!coords.length) return _invalidBounds; 1165 let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity; 1166 coords.forEach(c => { minLat = Math.min(minLat, c[1]); maxLat = Math.max(maxLat, c[1]); minLng = Math.min(minLng, c[0]); maxLng = Math.max(maxLng, c[0]); }); 1167 return L.latLngBounds([minLat, minLng], [maxLat, maxLng]); 1168 } 1169 getElement() { return null; } 1170 toGeoJSON() { return this._geojson || null; } 1171 bindTooltip(content, options) { 1172 this._tooltipContent = content; 1173 this._tooltipOptions = options || {}; 1174 // Auto-show on hover unless permanent 1175 if (!this._tooltipOptions.permanent) { 1176 this.on('mouseover', (e) => { 1177 if (!this._map) return; 1178 const latlng = e.latlng || (this._latlngs && this._latlngs[0]); 1179 if (!latlng) return; 1180 this.openTooltip(latlng); 1181 }); 1182 this.on('mouseout', () => { this.closeTooltip(); }); 1183 } 1184 return this; 1185 } 1186 openTooltip(latlng) { 1187 if (!this._tooltipContent || !this._map) return this; 1188 if (this._activeTooltip) this._activeTooltip.remove(); 1189 this._activeTooltip = new Tooltip(this._tooltipOptions); 1190 const text = typeof this._tooltipContent === 'function' ? this._tooltipContent(this) : this._tooltipContent; 1191 this._activeTooltip.setContent(text); 1192 this._activeTooltip.setLatLng(latlng); 1193 this._activeTooltip.addTo(this._map); 1194 return this; 1195 } 1196 closeTooltip() { 1197 if (this._activeTooltip) { this._activeTooltip.remove(); this._activeTooltip = null; } 1198 return this; 1199 } 1200 bindPopup(content, options) { this._popupContent = content; this._popupOptions = options; return this; } 1201 openPopup() { return this; } 1202 closePopup() { return this; } 1203 _getAllCoords() { return []; } 1204 _addToMap(_mlMap) {} 1205 _applyStyle() {} 1206 } 1207 1208 class LPolyline extends PathLayer { 1209 constructor(latlngs, style) { 1210 super(); 1211 this._latlngs = (latlngs || []).map(ll => toLatLng(ll)); 1212 this._style = Object.assign({ color: _defaultColor, weight: 3, opacity: 1, dashArray: null }, style); 1213 this._geojson = { 1214 type: 'Feature', 1215 geometry: { type: 'LineString', coordinates: this._latlngs.map(ll => [ll.lng, ll.lat]) }, 1216 properties: { _leaflet_id: L.stamp(this) } 1217 }; 1218 } 1219 // Fast-path: accept original GeoJSON feature, skip coordinate conversions 1220 static _fromGeoJSON(feature, style) { 1221 const instance = new LPolyline([], style); 1222 instance._latlngs = null; // Lazy - computed on demand 1223 instance._geojson = feature; 1224 instance.feature = feature; 1225 return instance; 1226 } 1227 setLatLngs(latlngs) { 1228 this._latlngs = latlngs.map(ll => toLatLng(ll)); 1229 this._geojson.geometry.coordinates = this._latlngs.map(ll => [ll.lng, ll.lat]); 1230 if (this._map) { 1231 const mlMap = this._map._mlMap || this._map; 1232 1233 // If using pool, update via pool 1234 if (this._usingPool && L._sourcePool) {
1235 L._sourcePool.removeFeature(mlMap, this); 1236 L._sourcePool.addFeature(mlMap, 'LineString', this._geojson, this._style, this); 1237 } else { 1238 // Update individual source 1239 const src = mlMap.getSource(this._sourceId); 1240 if (src) src.setData(this._geojson); 1241 } 1242 } 1243 return this; 1244 } 1245 getLatLngs() { 1246 if (!this._latlngs) { 1247 this._latlngs = (this._geojson.geometry.coordinates || []).map(c => L.latLng(c[1], c[0])); 1248 } 1249 return this._latlngs; 1250 } 1251 _getAllCoords() { return this._geojson.geometry.coordinates; } 1252 _addToMap(mlMap) { 1253 // Skip if using shared source (GeoJSONLayer optimization) 1254 if (this._skipMapLibreAdd) return; 1255 1256 // Use SourcePool to reduce number of MapLibre GL sources 1257 if (L._sourcePool && !this._style.dashArray) { 1258 L._sourcePool.addFeature(mlMap, 'LineString', this._geojson, this._style, this); 1259 this._usingPool = true; 1260 return; 1261 } 1262 1263 // Fallback to individual source for dashArray or if pool not available 1264 mlMap.addSource(this._sourceId, { type: 'geojson', data: this._geojson }); 1265 const paint = { 1266 'line-color': resolveColor(this._style.color) || resolveColor(_defaultColor), 1267 'line-width': this._style.weight || 3, 1268 'line-opacity': this._style.opacity !== undefined ? this._style.opacity : 1 1269 }; 1270 if (this._style.dashArray) { 1271 const da = typeof this._style.dashArray === 'string' 1272 ? this._style.dashArray.split(/[, ]+/).map(Number) 1273 : this._style.dashArray; 1274 paint['line-dasharray'] = da; 1275 } 1276 mlMap.addLayer({ id: this._layerId, type: 'line', source: this._sourceId, paint }); 1277 } 1278 _applyStyle() { 1279 if (!this._map) return; 1280 const mlMap = this._map._mlMap || this._map; 1281 1282 // If using pool, style changes require re-adding to pool with new style 1283 if (this._usingPool && L._sourcePool) { 1284 L._sourcePool.removeFeature(mlMap, this); 1285 L._sourcePool.addFeature(mlMap, 'LineString', this._geojson, this._style, this); 1286 return; 1287 } 1288 1289 // Individual layer styling - check layer exists first 1290 if (!mlMap.getLayer(this._layerId)) return; 1291 1292 if (this._style.color) mlMap.setPaintProperty(this._layerId, 'line-color', resolveColor(this._style.color)); 1293 if (this._style.weight !== undefined) mlMap.setPaintProperty(this._layerId, 'line-width', this._style.weight); 1294 if (this._style.opacity !== undefined) mlMap.setPaintProperty(this._layerId, 'line-opacity', this._style.opacity); 1295 if (this._style.dashArray) { 1296 const da = typeof this._style.dashArray === 'string' 1297 ? this._style.dashArray.split(/[, ]+/).map(Number) 1298 : this._style.dashArray; 1299 mlMap.setPaintProperty(this._layerId, 'line-dasharray', da); 1300 } 1301 } 1302 } 1303 1304 class LPolygon extends PathLayer { 1305 constructor(latlngs, style) { 1306 super(); 1307 this._latlngs = (latlngs || []).map(ll => toLatLng(ll)); 1308 const s = style || {}; 1309 this._style = Object.assign({ color: _defaultColor, weight: 3, opacity: 1, fillOpacity: 0.2 }, s); 1310 // Derive fillColor from color when not explicitly provided 1311 if (!s.fillColor) this._style.fillColor = this._style.color; 1312 const coords = this._latlngs.map(ll => [ll.lng, ll.lat]); 1313 if (coords.length > 0 && (coords[0][0] !== coords[coords.length-1][0] || coords[0][1] !== coords[coords.length-1][1])) { 1314 coords.push(coords[0]); 1315 } 1316 this._geojson = { 1317 type: 'Feature', 1318 geometry: { type: 'Polygon', coordinates: [coords] }, 1319 properties: {} 1320 }; 1321 } 1322 // Fast-path: accept original GeoJSON feature, skip coordinate conversions 1323 static _fromGeoJSON(feature, style) { 1324 const instance = new LPolygon([], style); 1325 instance._latlngs = null; // Lazy - computed on demand 1326 instance._geojson = feature; 1327 instance.feature = feature; 1328 return instance; 1329 } 1330 _getAllCoords() { return this._geojson.geometry.coordinates[0] || []; } 1331 _addToMap(mlMap) { 1332 // Skip if using shared source (GeoJSONLayer optimization) 1333 if (this._skipMapLibreAdd) return; 1334 1335 // Use SourcePool to reduce number of MapLibre GL sources 1336 if (L._sourcePool) { 1337 L._sourcePool.addFeature(mlMap, 'Polygon', this._geojson, this._style, this); 1338 this._usingPool = true; 1339 return; 1340 } 1341 1342 // Fallback to individual source 1343 mlMap.addSource(this._sourceId, { type: 'geojson', data: this._geojson }); 1344 mlMap.addLayer({ 1345 id: this._fillLayerId, type: 'fill', source: this._sourceId, 1346 paint: {
1347 'fill-color': resolveColor(this._style.fillColor || this._style.color) || resolveColor(_defaultColor), 1348 'fill-opacity': this._style.fillOpacity !== undefined ? this._style.fillOpacity : 0.2 1349 } 1350 }); 1351 mlMap.addLayer({ 1352 id: this._layerId, type: 'line', source: this._sourceId, 1353 paint: { 1354 'line-color': resolveColor(this._style.color) || resolveColor(_defaultColor), 1355 'line-width': this._style.weight || 3, 1356 'line-opacity': this._style.opacity !== undefined ? this._style.opacity : 1 1357 } 1358 }); 1359 } 1360 _applyStyle() { 1361 if (!this._map) return; 1362 const mlMap = this._map._mlMap || this._map; 1363 1364 // If using pool, style changes require re-adding to pool with new style 1365 if (this._usingPool && L._sourcePool) { 1366 L._sourcePool.removeFeature(mlMap, this); 1367 L._sourcePool.addFeature(mlMap, 'Polygon', this._geojson, this._style, this); 1368 return; 1369 } 1370 1371 // Individual layer styling - check layers exist first 1372 if (!mlMap.getLayer(this._layerId)) return; 1373 1374 if (this._style.fillColor && mlMap.getLayer(this._fillLayerId)) { 1375 mlMap.setPaintProperty(this._fillLayerId, 'fill-color', resolveColor(this._style.fillColor)); 1376 } 1377 if (this._style.fillOpacity !== undefined && mlMap.getLayer(this._fillLayerId)) { 1378 mlMap.setPaintProperty(this._fillLayerId, 'fill-opacity', this._style.fillOpacity); 1379 } 1380 if (this._style.color) mlMap.setPaintProperty(this._layerId, 'line-color', resolveColor(this._style.color)); 1381 if (this._style.weight !== undefined) mlMap.setPaintProperty(this._layerId, 'line-width', this._style.weight); 1382 if (this._style.opacity !== undefined) mlMap.setPaintProperty(this._layerId, 'line-opacity', this._style.opacity); 1383 if (this._style.dashArray) { 1384 const da = typeof this._style.dashArray === 'string' 1385 ? this._style.dashArray.split(/[, ]+/).map(Number) 1386 : this._style.dashArray; 1387 mlMap.setPaintProperty(this._layerId, 'line-dasharray', da); 1388 } 1389 } 1390 } 1391 1392 class LCircle extends PathLayer { 1393 constructor(latlng, options) { 1394 super(); 1395 this._center = toLatLng(latlng); 1396 const opts = typeof options === 'number' ? { radius: options } : (options || {}); 1397 this._radius = opts.radius || 10; 1398 this._style = Object.assign({ color: _defaultColor, fillColor: _defaultColor, fillOpacity: 0.2, opacity: 1 }, opts); 1399 this._geojson = this._createCircleGeoJSON(); 1400 } 1401 setRadius(r) { 1402 this._radius = r; 1403 this._geojson = this._createCircleGeoJSON(); 1404 if (this._map) { 1405 const mlMap = this._map._mlMap || this._map; 1406 const src = mlMap.getSource(this._sourceId); 1407 if (src) src.setData(this._geojson); 1408 } 1409 return this; 1410 } 1411 setLatLng(latlng) { 1412 this._center = toLatLng(latlng); 1413 this._geojson = this._createCircleGeoJSON(); 1414 if (this._map) { 1415 const mlMap = this._map._mlMap || this._map; 1416 const src = mlMap.getSource(this._sourceId); 1417 if (src) src.setData(this._geojson); 1418 } 1419 return this; 1420 } 1421 getLatLng() { return this._center; } 1422 getRadius() { return this._radius; } 1423 _createCircleGeoJSON() { 1424 const steps = 64; 1425 const coords = []; 1426 const km = this._radius / 1000; 1427 for (let i = 0; i <= steps; i++) { 1428 const angle = (i / steps) * 2 * Math.PI; 1429 const dx = km * Math.cos(angle); 1430 const dy = km * Math.sin(angle); 1431 const lat = this._center.lat + (dy / 111.32); 1432 const lng = this._center.lng + (dx / (111.32 * Math.cos(this._center.lat * Math.PI / 180))); 1433 coords.push([lng, lat]); 1434 } 1435 return { type: 'Feature', geometry: { type: 'Polygon', coordinates: [coords] }, properties: {} }; 1436 } 1437 _getAllCoords() { return this._geojson.geometry.coordinates[0] || []; } 1438 _addToMap(mlMap) { 1439 mlMap.addSource(this._sourceId, { type: 'geojson', data: this._geojson }); 1440 mlMap.addLayer({ 1441 id: this._fillLayerId, type: 'fill', source: this._sourceId, 1442 paint: {
1443 'fill-color': resolveColor(this._style.fillColor || this._style.color) || resolveColor(_defaultColor), 1444 'fill-opacity': this._style.fillOpacity !== undefined ? this._style.fillOpacity : 0.2 1445 } 1446 }); 1447 mlMap.addLayer({ 1448 id: this._layerId, type: 'line', source: this._sourceId, 1449 paint: { 1450 'line-color': resolveColor(this._style.color) || resolveColor(_defaultColor), 1451 'line-width': 2, 1452 'line-opacity': this._style.opacity !== undefined ? this._style.opacity : 1 1453 } 1454 }); 1455 } 1456 _applyStyle() { 1457 const mlMap = this._map._mlMap || this._map; 1458 try { 1459 if (this._style.fillColor) mlMap.setPaintProperty(this._fillLayerId, 'fill-color', resolveColor(this._style.fillColor)); 1460 if (this._style.fillOpacity !== undefined) mlMap.setPaintProperty(this._fillLayerId, 'fill-opacity', this._style.fillOpacity); 1461 if (this._style.color) mlMap.setPaintProperty(this._layerId, 'line-color', resolveColor(this._style.color)); 1462 } catch {} 1463 } 1464 } 1465 1466 L.polyline = function(latlngs, style) { return new LPolyline(latlngs, style); }; 1467 L.polygon = function(latlngs, style) { return new LPolygon(latlngs, style); }; 1468 L.circle = function(latlng, options) { return new LCircle(latlng, options); }; 1469 L.Polyline = LPolyline; 1470 L.Polygon = LPolygon; 1471 L.Circle = LCircle; 1472 1473 // ============================================================ 1474 // FEATURE GROUP / LAYER GROUP 1475 // ============================================================ 1476
1477 class FeatureGroup extends EventEmitter { 1478 constructor(layers) { 1479 super(); 1480 this._layers = []; 1481 this._map = null; 1482 if (layers) layers.forEach(l => this.addLayer(l)); 1483 } 1484 addLayer(layer) { 1485 this._layers.push(layer); 1486 if (this._map) layer.addTo(this._map); 1487 return this; 1488 } 1489 removeLayer(layer) { 1490 const idx = this._layers.indexOf(layer); 1491 if (idx >= 0) { 1492 this._layers.splice(idx, 1); 1493 if (layer.remove) layer.remove(); 1494 } 1495 return this; 1496 } 1497 hasLayer(layer) { return this._layers.indexOf(layer) >= 0; } 1498 clearLayers() { 1499 this._layers.forEach(l => { if (l.remove) l.remove(); }); 1500 this._layers = []; 1501 return this; 1502 } 1503 getLayers() { return this._layers.slice(); } 1504 eachLayer(fn) { this._layers.forEach(fn); return this; } 1505 addTo(map) { 1506 this._map = map; 1507 this._layers.forEach(l => l.addTo(map)); 1508 return this; 1509 } 1510 remove() { 1511 this._layers.forEach(l => { if (l.remove) l.remove(); }); 1512 return this; 1513 } 1514 getBounds() { 1515 let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity; 1516 this._layers.forEach(l => { 1517 const b = l.getBounds ? l.getBounds() : (l.getLatLng ? l.getBounds() : null); 1518 if (b) {
1519 const sw = b.getSouthWest ? b.getSouthWest() : b._southWest; 1520 const ne = b.getNorthEast ? b.getNorthEast() : b._northEast; 1521 if (sw && ne) { 1522 minLat = Math.min(minLat, sw.lat); maxLat = Math.max(maxLat, ne.lat); 1523 minLng = Math.min(minLng, sw.lng); maxLng = Math.max(maxLng, ne.lng); 1524 } 1525 } 1526 }); 1527 if (minLat === Infinity) return _invalidBounds; 1528 return L.latLngBounds([minLat, minLng], [maxLat, maxLng]); 1529 } 1530 toGeoJSON() { 1531 return { 1532 type: 'FeatureCollection', 1533 features: this._layers.map(l => l.toGeoJSON ? l.toGeoJSON() : null).filter(Boolean) 1534 }; 1535 } 1536 } 1537 1538 L.FeatureGroup = FeatureGroup; 1539 L.featureGroup = function(layers) { return new FeatureGroup(layers); }; 1540 L.LayerGroup = FeatureGroup; 1541 L.layerGroup = function(layers) { return new FeatureGroup(layers); }; 1542 1543 // ============================================================ 1544 // GEOJSON LAYER 1545 // ============================================================ 1546 1547 class GeoJSONLayer extends EventEmitter { 1548 constructor(data, options) { 1549 super(); 1550 this.options = options || {}; 1551 this._layers = []; // Point markers + PathLayers (SourcePool path) 1552 this._pathFeatures = []; // Raw GeoJSON for non-Points (direct path) 1553 this._directSourceIds = []; // { sourceId, layerIds } for cleanup 1554 this._map = null; 1555 this._sourceId = 'geojson-' + L.stamp(this); 1556 this._featureCount = 0; 1557 if (data) this.addData(data); 1558 } 1559 addData(data) { 1560 if (!data) return this; 1561 const features = data.type === 'FeatureCollection' ? data.features : 1562 data.type === 'Feature' ? [data] : 1563 data.features ? data.features : [data]; 1564 1565 // Batch SourcePool updates when on map with non-direct-path PathLayers 1566 const shouldBatch = !this.options._directPath && this._map && L._sourcePool && !L._sourcePool._batching; 1567 const mlMap = this._map ? (this._map._mlMap || this._map) : null; 1568 if (shouldBatch) L._sourcePool.beginBatch(); 1569 1570 for (let i = 0; i < features.length; i++) { 1571 const feature = features[i]; 1572 if (this.options.filter && !this.options.filter(feature)) continue; 1573 this._featureCount++; 1574 this._addFeature(feature); 1575 } 1576 1577 if (shouldBatch) L._sourcePool.endBatch(mlMap); 1578 1579 // If direct-path features added while already on map, update sources 1580 if (this.options._directPath && this._map && this._directSourceIds.length > 0) { 1581 this._updateDirectSources(); 1582 } 1583 return this; 1584 } 1585 _addFeature(feature) { 1586 if (!feature || !feature.geometry) return; 1587 const geomType = feature.geometry.type; 1588 1589 if (geomType === 'Point' || geomType === 'MultiPoint') { 1590 const coords = geomType === 'Point' ? [feature.geometry.coordinates] : feature.geometry.coordinates; 1591 coords.forEach(coord => { 1592 const latlng = L.latLng(coord[1], coord[0]); 1593 let layer; 1594 if (this.options.pointToLayer) { 1595 layer = this.options.pointToLayer(feature, latlng); 1596 } else { 1597 layer = L.marker(latlng); 1598 } 1599 if (layer) { 1600 layer.feature = feature; 1601 if (this.options.onEachFeature) this.options.onEachFeature(feature, layer); 1602 this._layers.push(layer); 1603 if (this._map) layer.addTo(this._map); 1604 } 1605 }); 1606 } else if (this.options._directPath) { 1607 // DIRECT PATH: store raw GeoJSON, skip PathLayer creation entirely 1608 this._pathFeatures.push(feature); 1609 } else { 1610 // SOURCEPOOL PATH: create PathLayer objects (needed for per-feature styling) 1611 const style = this.options.style 1612 ? (typeof this.options.style === 'function' ? this.options.style(feature) : this.options.style) 1613 : {}; 1614 const layer = (geomType === 'LineString' || geomType === 'MultiLineString') 1615 ? LPolyline._fromGeoJSON(feature, style) 1616 : LPolygon._fromGeoJSON(feature, style); 1617 if (layer) { 1618 if (this.options.onEachFeature) this.options.onEachFeature(feature, layer); 1619 this._layers.push(layer); 1620 if (this._map) layer.addTo(this._map); 1621 } 1622 } 1623 } 1624 1625 // ââ Direct path: create MapLibre sources directly from raw GeoJSON ââ 1626 _addDirectPaths(map) { 1627 const mlMap = map._mlMap || map; 1628 const lines = []; 1629 const polygons = []; 1630 for (let i = 0; i < this._pathFeatures.length; i++) { 1631 const t = this._pathFeatures[i].geometry.type; 1632 if (t === 'LineString' || t === 'MultiLineString') lines.push(this._pathFeatures[i]); 1633 else if (t === 'Polygon' || t === 'MultiPolygon') polygons.push(this._pathFeatures[i]); 1634 } 1635 1636 const styleFn = this.options.style; 1637 const sample = this._pathFeatures[0]; 1638 const s = typeof styleFn === 'function' ? styleFn(sample) : (styleFn || {}); 1639 const w = s.weight || 3; 1640 const op = s.opacity !== undefined ? s.opacity : 0.8;
1641 const color = resolveColor(s.color) || resolveColor(_defaultColor); 1642 1643 // For polygon style, use a polygon sample if available (avoids wrong fillOpacity=0 from line sample) 1644 const polyStyle = polygons.length > 0 && styleFn 1645 ? (() => { const ps = typeof styleFn === 'function' ? styleFn(polygons[0]) : s; return { ...s, fillOpacity: ps.fillOpacity ?? s.fillOpacity }; })() 1646 : s; 1647 1648 const doAdd = () => { 1649 let nextId = 0; 1650 if (lines.length > 0) this._createDirectSource(mlMap, lines, 'dlines', 'LineString', color, w, op, s, nextId); 1651 nextId += lines.length; 1652 if (polygons.length > 0) this._createDirectSource(mlMap, polygons, 'dpolygons', 'Polygon', color, w, op, polyStyle, nextId); 1653 }; 1654 1655 if (map._styleLoaded === false) { map._queue.push(doAdd); } 1656 else { doAdd(); } 1657 } 1658 _createDirectSource(mlMap, features, suffix, geomType, color, w, op, style, startId) { 1659 for (let i = 0; i < features.length; i++) features[i].id = startId + i; 1660 1661 const sourceId = this._sourceId + '-' + suffix; 1662 const lineLayerId = sourceId + '-layer'; 1663 mlMap.addSource(sourceId, { 1664 type: 'geojson', 1665 data: { type: 'FeatureCollection', features }, 1666 generateId: false 1667 }); 1668 1669 // Data-driven color: use per-feature _color property when available 1670 // Check several features: first feature may have no dates (total<=0) and thus no _color 1671 const hasPerFeatureColor = features.some(f => { const c = f.properties?._color; return c && !c.startsWith('var('); }); 1672 const lineColorExpr = hasPerFeatureColor 1673 ? ['coalesce', ['get', '_color'], color] 1674 : color; 1675 1676 const linePaint = { 1677 'line-color': lineColorExpr, 1678 'line-width': ['case', 1679 ['boolean', ['feature-state', 'selected'], false], w + 4, 1680 ['boolean', ['feature-state', 'hover'], false], w + 2, 1681 w 1682 ], 1683 'line-opacity': ['case', 1684 ['boolean', ['feature-state', 'selected'], false], 1, 1685 ['boolean', ['feature-state', 'hover'], false], 1, 1686 ['boolean', ['feature-state', 'dimmed'], false], 0.15, 1687 op 1688 ] 1689 }; 1690 if (style.dashArray) { 1691 linePaint['line-dasharray'] = typeof style.dashArray === 'string' 1692 ? style.dashArray.split(/[, ]+/).map(Number) : style.dashArray; 1693 } 1694 1695 const layerIds = [lineLayerId]; 1696 let fillLayerId = null; 1697 1698 if (geomType === 'Polygon') { 1699 fillLayerId = sourceId + '-fill'; 1700 const baseFillColor = resolveColor(style.fillColor || style.color) || resolveColor(_defaultColor); 1701 const fillColorExpr = hasPerFeatureColor 1702 ? ['coalesce', ['get', '_color'], baseFillColor] 1703 : baseFillColor; 1704 const fillOp = style.fillOpacity !== undefined ? style.fillOpacity : 0.2; 1705 mlMap.addLayer({ 1706 id: fillLayerId, type: 'fill', source: sourceId, 1707 paint: { 1708 'fill-color': fillColorExpr, 1709 'fill-opacity': ['case', 1710 ['boolean', ['feature-state', 'selected'], false], Math.min(fillOp + 0.35, 0.7), 1711 ['boolean', ['feature-state', 'hover'], false], Math.min(fillOp + 0.2, 0.6), 1712 ['boolean', ['feature-state', 'dimmed'], false], 0.05, 1713 fillOp 1714 ] 1715 } 1716 }); 1717 layerIds.push(fillLayerId); 1718 } 1719 1720 mlMap.addLayer({ id: lineLayerId, type: 'line', source: sourceId, paint: linePaint }); 1721 this._directSourceIds.push({ sourceId, layerIds }); 1722 1723 // Register with SourcePool for FeatureInteractions compatibility 1724 if (L._sourcePool) { 1725 const pool = { 1726 sourceId, layerId: lineLayerId, fillLayerId, 1727 features, layers: [], geomType, 1728 layerName: this.options._layerName || null, 1729 _rawColor: style.color || null, 1730 _rawFillColor: style.fillColor || style.color || null, 1731 _hasPerFeatureColor: features.some(f => { const c = f.properties?._color; return c && !c.startsWith('var('); }) 1732 }; 1733 L._sourcePool._pools.set(sourceId, pool); 1734 L._sourcePool._applyFilterToPool(pool); 1735 } 1736 } 1737 _updateDirectSources() { 1738 if (!this._map) return; 1739 const mlMap = this._map._mlMap || this._map; 1740 for (const { sourceId } of this._directSourceIds) { 1741 const src = mlMap.getSource(sourceId); 1742 if (!src) continue; 1743 const isLines = sourceId.endsWith('-dlines'); 1744 const filtered = this._pathFeatures.filter(f => { 1745 const t = f.geometry.type;
1746 return isLines ? (t === 'LineString' || t === 'MultiLineString') : (t === 'Polygon' || t === 'MultiPolygon'); 1747 }); 1748 src.setData({ type: 'FeatureCollection', features: filtered }); 1749 if (L._sourcePool) { 1750 const pool = L._sourcePool._pools.get(sourceId); 1751 if (pool) pool.features = filtered; 1752 } 1753 } 1754 } 1755 _removeDirectSources() { 1756 if (!this._map || this._directSourceIds.length === 0) return; 1757 const mlMap = this._map._mlMap || this._map; 1758 for (const { sourceId, layerIds } of this._directSourceIds) { 1759 for (const lid of layerIds) { 1760 try { if (mlMap.getLayer(lid)) mlMap.removeLayer(lid); } catch {} 1761 } 1762 try { if (mlMap.getSource(sourceId)) mlMap.removeSource(sourceId); } catch {} 1763 if (L._sourcePool) { 1764 if (win.FeatureInteractions) win.FeatureInteractions.invalidateSource(sourceId); 1765 L._sourcePool._pools.delete(sourceId); 1766 } 1767 } 1768 this._directSourceIds = []; 1769 } 1770 1771 // ââ Lifecycle ââ 1772 addTo(map) { 1773 this._map = map; 1774 const mlMap = map._mlMap || map; 1775 // Add markers + PathLayers via SourcePool batch 1776 if (this._layers.length > 0) { 1777 if (L._sourcePool) L._sourcePool.beginBatch(); 1778 this._layers.forEach(l => l.addTo(map)); 1779 if (L._sourcePool) L._sourcePool.endBatch(mlMap); 1780 } 1781 // Add direct-path features (zero PathLayer overhead) 1782 if (this._pathFeatures.length > 0) { 1783 this._addDirectPaths(map); 1784 } 1785 this.fire('add'); 1786 return this; 1787 } 1788 remove() { 1789 this._removeDirectSources(); 1790 const mlMap = this._map ? (this._map._mlMap || this._map) : null; 1791 if (this._layers.length > 0) { 1792 const shouldBatch = mlMap && L._sourcePool && !L._sourcePool._batching; 1793 if (shouldBatch) L._sourcePool.beginBatch(); 1794 this._layers.forEach(l => { if (l.remove) l.remove(); }); 1795 if (shouldBatch) L._sourcePool.endBatch(mlMap); 1796 } 1797 this._pathFeatures = []; 1798 this.fire('remove'); 1799 return this; 1800 } 1801 removeFrom(_map) { return this.remove(); } 1802 clearLayers() { 1803 this._removeDirectSources(); 1804 const mlMap = this._map ? (this._map._mlMap || this._map) : null; 1805 if (this._layers.length > 0) { 1806 const shouldBatch = mlMap && L._sourcePool && !L._sourcePool._batching; 1807 if (shouldBatch) L._sourcePool.beginBatch(); 1808 this._layers.forEach(l => { if (l.remove) l.remove(); }); 1809 if (shouldBatch) L._sourcePool.endBatch(mlMap); 1810 } 1811 this._layers = []; 1812 this._pathFeatures = []; 1813 this._featureCount = 0; 1814 return this; 1815 } 1816 1817 // ââ Query ââ 1818 eachLayer(fn) { this._layers.forEach(fn); return this; } 1819 getLayers() { return this._layers.slice(); } 1820 getFeatureCount() { return this._featureCount; } 1821 hasLayer(layer) { return this._layers.indexOf(layer) >= 0; } 1822 1823 // ââ Mutation ââ 1824 addLayer(layer) { 1825 this._layers.push(layer); 1826 if (this._map) layer.addTo(this._map); 1827 return this; 1828 } 1829 removeLayer(layer) { 1830 const idx = this._layers.indexOf(layer); 1831 if (idx >= 0) { 1832 this._layers.splice(idx, 1); 1833 if (layer.remove) layer.remove(); 1834 } 1835 return this; 1836 } 1837 setStyle(style) { 1838 this._layers.forEach(l => { if (l.setStyle) l.setStyle(typeof style === 'function' ? style(l.feature) : style); }); 1839 // Update direct source paint properties 1840 if (this._directSourceIds.length > 0 && this._map) { 1841 const mlMap = this._map._mlMap || this._map; 1842 const s = typeof style === 'function' && this._pathFeatures.length > 0 1843 ? style(this._pathFeatures[0]) : (typeof style === 'object' ? style : {}); 1844 for (const { layerIds } of this._directSourceIds) { 1845 for (const lid of layerIds) { 1846 try { 1847 if (lid.endsWith('-fill')) { 1848 if (s.fillColor || s.color) mlMap.setPaintProperty(lid, 'fill-color', resolveColor(s.fillColor || s.color)); 1849 if (s.fillOpacity !== undefined) mlMap.setPaintProperty(lid, 'fill-opacity', s.fillOpacity); 1850 } else { 1851 if (s.color) mlMap.setPaintProperty(lid, 'line-color', resolveColor(s.color)); 1852 if (s.weight !== undefined) mlMap.setPaintProperty(lid, 'line-width', s.weight); 1853 if (s.opacity !== undefined) mlMap.setPaintProperty(lid, 'line-opacity', s.opacity); 1854 } 1855 } catch {} 1856 } 1857 } 1858 } 1859 return this; 1860 } 1861 resetStyle(layer) { 1862 if (layer && this.options.style) { 1863 const s = typeof this.options.style === 'function' ? this.options.style(layer.feature) : this.options.style; 1864 if (layer.setStyle) layer.setStyle(s); 1865 } 1866 return this; 1867 } 1868 getBounds() { 1869 let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity;
1870 this._layers.forEach(l => { 1871 if (l.getLatLng) { 1872 const ll = l.getLatLng(); 1873 minLat = Math.min(minLat, ll.lat); maxLat = Math.max(maxLat, ll.lat); 1874 minLng = Math.min(minLng, ll.lng); maxLng = Math.max(maxLng, ll.lng); 1875 } else if (l.getBounds) { 1876 const b = l.getBounds(); 1877 if (b) { 1878 const sw = b._southWest || b.getSouthWest(); 1879 const ne = b._northEast || b.getNorthEast(); 1880 minLat = Math.min(minLat, sw.lat); maxLat = Math.max(maxLat, ne.lat); 1881 minLng = Math.min(minLng, sw.lng); maxLng = Math.max(maxLng, ne.lng); 1882 } 1883 } 1884 }); 1885 // Include direct-path features in bounds 1886 for (let i = 0; i < this._pathFeatures.length; i++) { 1887 const coords = this._pathFeatures[i].geometry?.coordinates; 1888 if (coords) _walkCoords(coords, (lng, lat) => { 1889 if (lat < minLat) minLat = lat; if (lat > maxLat) maxLat = lat; 1890 if (lng < minLng) minLng = lng; if (lng > maxLng) maxLng = lng; 1891 }); 1892 } 1893 if (minLat === Infinity) return _invalidBounds; 1894 return L.latLngBounds([minLat, minLng], [maxLat, maxLng]); 1895 } 1896 toGeoJSON() { 1897 const markerFeatures = this._layers.map(l => l.toGeoJSON ? l.toGeoJSON() : l.feature).filter(Boolean); 1898 return { 1899 type: 'FeatureCollection', 1900 features: markerFeatures.concat(this._pathFeatures) 1901 }; 1902 } 1903 } 1904 1905 L.geoJSON = function(data, options) { return new GeoJSONLayer(data, options); }; 1906 L.GeoJSON = GeoJSONLayer; 1907 1908 // ============================================================ 1909 // MAP WRAPPER 1910 // ============================================================ 1911 1912 class LMap extends EventEmitter { 1913 constructor(containerId, options) { 1914 super(); 1915 const opts = options || {}; 1916 const _container = typeof containerId === 'string' ? containerId : containerId; 1917 const center = opts.center || [0, 0]; 1918 const centerLL = toLatLng(center); 1919 const zoom = opts.zoom || 10; 1920 1921 this._container = typeof containerId === 'string' ? document.getElementById(containerId) : containerId; 1922 this._layers = []; 1923 this._panes = {}; 1924 1925 // Create MapLibre map with minimal style (no 3D buildings by default for performance) 1926 this._mlMap = new mlgl.Map({ 1927 container: containerId, 1928 style: { 1929 version: 8, 1930 sources: {}, 1931 layers: [], 1932 glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf' 1933 }, 1934 center: [centerLL.lng, centerLL.lat], 1935 zoom: zoom, 1936 pitch: opts.pitch || 0, 1937 bearing: opts.bearing || 0, 1938 // Contrôle natif désactivé - on ajoute notre propre contrôle compact 1939 // ci-dessous (mlgl n'accepte pas `true`, uniquement false | options) 1940 attributionControl: false, 1941 maxPitch: 85 1942 }); 1943 1944 // Attribution compacte en bas à gauche (crédits OSM / fond de carte). 1945 // Repliée en pastille â, elle se déplie au clic - les attributions 1946 // proviennent des sources (champ `attribution` de basemaps_v2 ou du style vectoriel). 1947 if (opts.attributionControl !== false) { 1948 try { 1949 this._mlMap.addControl(new mlgl.AttributionControl({ compact: true }), 'bottom-left'); 1950 } catch (e) { 1951 console.debug('[MapLibreCompat] Could not add attribution control:', e); 1952 } 1953 } 1954 1955 // Queue for operations before style loads 1956 this._styleLoaded = false; 1957 this._queue = []; 1958 const addZoomControl = opts.zoomControl !== false; 1959 1960 this._mlMap.on('load', () => { 1961 this._styleLoaded = true; 1962 1963 // Add unified navigation control (zoom + compass) after map is loaded 1964 if (addZoomControl) { 1965 try { 1966 this._mlMap.addControl( 1967 new mlgl.NavigationControl({ showCompass: true, showZoom: true, visualizePitch: true }), 1968 'bottom-right' 1969 ); 1970 } catch (e) { 1971 console.debug('[MapLibreCompat] Could not add navigation control:', e); 1972 } 1973 } 1974
1975 // Compass: show only when bearing â 0° or pitch > 0 1976 const _updateCompassVis = () => { 1977 const compass = this._mlMap.getContainer().querySelector('.maplibregl-ctrl-compass'); 1978 if (!compass) return; 1979 const active = Math.abs(this._mlMap.getBearing()) > 0.5 || this._mlMap.getPitch() > 0.5; 1980 compass.classList.toggle('compass--active', active); 1981 }; 1982 _updateCompassVis(); 1983 ['rotate', 'rotateend', 'pitch', 'pitchend'].forEach(evt => this._mlMap.on(evt, _updateCompassVis)); 1984 // Enable 3D buildings layer from OpenMapTiles 1985 this._enable3DBuildings(); 1986 1987 // Auto-pitch removed: it fired easeTo() on every zoom event, 1988 // creating cascading animations that broke mouse wheel zoom. 1989 // Users can manually tilt with Ctrl+drag; 3D buildings still render at zoom 15+. 1990 1991 // Batch all queued operations so SourcePool coalesces setData calls 1992 if (L._sourcePool) L._sourcePool.beginBatch(); 1993 this._queue.forEach(fn => fn()); 1994 this._queue = []; 1995 if (L._sourcePool) L._sourcePool.endBatch(this._mlMap); 1996 }); 1997 1998 // Proxy MapLibre events to Leaflet-style events 1999 const eventMap = { 2000 'zoomend': 'zoomend', 'moveend': 'moveend', 'move': 'move', 2001 'click': 'click', 'dblclick': 'dblclick', 2002 'mousedown': 'mousedown', 'mouseup': 'mouseup', 2003 'mousemove': 'mousemove', 'mouseout': 'mouseout', 2004 'contextmenu': 'contextmenu', 'load': 'load', 2005 'resize': 'resize', 'zoom': 'zoom', 2006 'dragstart': 'dragstart', 'drag': 'drag', 'dragend': 'dragend' 2007 }; 2008 2009 Object.entries(eventMap).forEach(([mlEvent, lEvent]) => { 2010 this._mlMap.on(mlEvent, (e) => { 2011 const latlng = e.lngLat ? { lat: e.lngLat.lat, lng: e.lngLat.lng } : this.getCenter(); 2012 this.fire(lEvent, { 2013 latlng: latlng, 2014 containerPoint: e.point || { x: 0, y: 0 }, 2015 originalEvent: e.originalEvent || e 2016 }); 2017 }); 2018 }); 2019 2020 // Double click zoom control 2021 this.doubleClickZoom = { 2022 _map: this._mlMap, 2023 disable() { this._map.doubleClickZoom.disable(); }, 2024 enable() { this._map.doubleClickZoom.enable(); } 2025 }; 2026 2027 // scrollWheelZoom 2028 this.scrollWheelZoom = { 2029 _map: this._mlMap, 2030 disable() { this._map.scrollZoom.disable(); }, 2031 enable() { this._map.scrollZoom.enable(); } 2032 }; 2033 } 2034 2035 // ââ 3D Buildings ââ 2036 _buildings3DEnabled = false; 2037 2038 /** 2039 * Active/désactive la couche 3D des bâtiments 2040 * Source : OpenFreeMap (OpenMapTiles schema, données OSM, gratuit, sans clé API) 2041 */ 2042 setBuildings3D(enabled) { 2043 const mlMap = this._mlMap; 2044 2045 if (enabled && !this._buildings3DEnabled) { 2046 this._addForest3DLayers(); // forests first - lower z-order than buildings 2047 this._addBuildings3DLayer(); 2048 this._buildings3DEnabled = true; 2049 } else if (!enabled && this._buildings3DEnabled) { 2050 ['forest-fill', '3d-buildings'].forEach(id => { 2051 if (mlMap.getLayer(id)) mlMap.removeLayer(id); 2052 }); 2053 this._buildings3DEnabled = false; 2054 } 2055 return this; 2056 } 2057 2058 getBuildings3D() { return this._buildings3DEnabled; } 2059 2060 // ââ Building paint factory ââ 2061 _BUILDING_COLORS = { 2062 light: [0, '#e8e4e0', 20, '#d5d0cc', 60, '#bfc5cc', 150, '#a0b0c0', 300, '#8fa8bd'], 2063 dark: [0, '#3a3a3a', 20, '#454545', 60, '#505050', 150, '#5a5a5a', 300, '#656565'], 2064 }; 2065 2066 _getBuildingColorExpr(isDark) { 2067 const stops = isDark ? this._BUILDING_COLORS.dark : this._BUILDING_COLORS.light; 2068 return ['interpolate', ['linear'], ['coalesce', ['get', 'render_height'], 10], ...stops]; 2069 } 2070 2071 // Full paint used for addLayer (static + theme-sensitive properties) 2072 _getBuildingPaint(isDark) { 2073 return { 2074 'fill-extrusion-color': this._getBuildingColorExpr(isDark), 2075 'fill-extrusion-height': ['coalesce', ['get', 'render_height'], 10], 2076 'fill-extrusion-base': ['coalesce', ['get', 'render_min_height'], 0], 2077 'fill-extrusion-opacity': 1, 2078 }; 2079 } 2080 2081 // âââ Forest 3D - two-pass stratified canopy ââââââââââââââââââââââââââââââââ 2082 _FOREST_COLORS = { 2083 light: { 2084 understory: '#071a0e', // forest floor - near-black green 2085 wood: '#1c5e36', // mature forest canopy 2086 park: '#3d8c4a', // managed park trees 2087 scrub: '#6b7c32', // dry scrub / heath 2088 grass: '#558c3c', // meadow / grassland 2089 }, 2090 dark: { 2091 understory: '#020708', // moonlit floor - almost black 2092 wood: '#0b2418', // deep moonlit forest 2093 park: '#112e1c', // dark teal park 2094 scrub: '#1c2610', // very dark olive 2095 grass: '#0e2012', // night meadow 2096 }, 2097 }; 2098 2099 _getForestColorExpr(type, isDark) { 2100 const c = isDark ? this._FOREST_COLORS.dark : this._FOREST_COLORS.light; 2101 if (type === 'understory') return c.understory; 2102 return ['match', ['get', 'subclass'], 2103 ['forest', 'wood'], c.wood, 2104 ['park', 'garden', 'village_green', 'recreation_ground'], c.park, 2105 ['scrub', 'heath', 'fell', 'shrubbery'], c.scrub, 2106 ['grassland', 'meadow', 'grass', 'flowerbed', 'wet_meadow'], c.grass, 2107 c.wood, 2108 ]; 2109 } 2110 2111 _getForestHeightExpr() { 2112 return ['match', ['get', 'subclass'], 2113 ['forest', 'wood'], 22, 2114 ['park', 'garden', 'village_green', 'recreation_ground'], 14, 2115 ['scrub', 'heath', 'fell', 'shrubbery'], 9, 2116 ['grassland', 'meadow', 'grass', 'flowerbed', 'wet_meadow'], 3, 2117 18, 2118 ]; 2119 } 2120 2121 // Height of understory = base of canopy 2122 _getForestBaseExpr() { 2123 return ['match', ['get', 'subclass'], 2124 ['forest', 'wood'], 7, 2125 ['park', 'garden', 'village_green', 'recreation_ground'], 5, 2126 ['scrub', 'heath', 'fell', 'shrubbery'], 3, 2127 ['grassland', 'meadow', 'grass', 'flowerbed', 'wet_meadow'], 1, 2128 6, 2129 ]; 2130 } 2131 2132 _addForest3DLayers() { 2133 const mlMap = this._mlMap; 2134 // Source is shared with buildings - ensure it exists 2135 if (!mlMap.getSource('ofm-buildings')) { 2136 mlMap.addSource('ofm-buildings', { type: 'vector', url: 'https://tiles.openfreemap.org/planet' }); 2137 } 2138 const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; 2139 // Insert before the first basemap fill/line so forests sit below data overlays 2140 const anchor = mlMap.getStyle().layers.find(l => l.type === 'fill' || l.type === 'line')?.id; 2141 const filter = ['match', ['get', 'class'], ['wood', 'grass'], true, false]; 2142 2143 if (!mlMap.getLayer('forest-fill')) { 2144 mlMap.addLayer({ 2145 id: 'forest-fill', 2146 source: 'ofm-buildings', 2147 'source-layer': 'landcover', 2148 type: 'fill', 2149 minzoom: 12, 2150 filter, 2151 paint: { 2152 'fill-color': this._getForestColorExpr('canopy', isDark), 2153 // Fades in smoothly between zoom 12 and 15 2154 'fill-opacity': ['interpolate', ['linear'], ['zoom'], 12, 0, 15, 0.18], 2155 }, 2156 }, anchor); 2157 } 2158 } 2159 // âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 2160 2161 _addBuildings3DLayer() { 2162 const mlMap = this._mlMap; 2163 2164 if (!mlMap.getSource('ofm-buildings')) { 2165 mlMap.addSource('ofm-buildings', { 2166 type: 'vector', 2167 url: 'https://tiles.openfreemap.org/planet' 2168 }); 2169 } 2170 2171 if (!mlMap.getLayer('3d-buildings')) { 2172 // Insert between basemap and data layers 2173 const firstDataLayer = mlMap.getStyle().layers.find(l => 2174 l.type === 'line' || (l.type === 'fill' && l.id !== '3d-buildings') 2175 ); 2176 2177 // Check if dark mode is active 2178 const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; 2179 2180 mlMap.addLayer({ 2181 id: '3d-buildings', 2182 source: 'ofm-buildings', 2183 'source-layer': 'building', 2184 type: 'fill-extrusion', 2185 minzoom: 15, 2186 filter: ['!=', ['get', 'hide_3d'], true], 2187 paint: this._getBuildingPaint(isDark), 2188 }, firstDataLayer?.id); 2189 } 2190 } 2191 2192 updateBuildings3DTheme() { 2193 const mlMap = this._mlMap; 2194 const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; 2195 const apply = () => { 2196 try { 2197 if (mlMap.getLayer('3d-buildings')) { 2198 mlMap.setPaintProperty('3d-buildings', 'fill-extrusion-color', this._getBuildingColorExpr(isDark)); 2199 mlMap.setPaintProperty('3d-buildings', 'fill-extrusion-opacity', 1); 2200 } 2201 if (mlMap.getLayer('forest-fill')) { 2202 mlMap.setPaintProperty('forest-fill', 'fill-color', this._getForestColorExpr('canopy', isDark)); 2203 } 2204 } catch (e) { 2205 console.debug('[MapLibreCompat] updateBuildings3DTheme failed:', e); 2206 } 2207 }; 2208 apply(); 2209 // Fallback: retry after style settles (basemap swap may delay readiness) 2210 setTimeout(apply, 80); 2211 } 2212 2213 // Re-resolve CSS var colors on all data layers (SourcePool) after theme change 2214 repaintDataColors() { 2215 if (L._sourcePool) { 2216 L._sourcePool.repaintColors(this._mlMap); 2217 } 2218 } 2219 2220 /** 2221 * Centralise tous les effets visuels MapLibre lors d'un changement de thème. 2222 * Appelé depuis ThemeManager.applyTheme() - couvre toggle manuel ET sync OS. 2223 */ 2224 onThemeChanged() { 2225 this.repaintDataColors(); 2226 this.updateBuildings3DTheme(); 2227 this.updateSkyTheme(); 2228 } 2229 2230 updateSkyTheme() { 2231 if (!this._terrainEnabled) return; 2232 const preset = this._getSkyPreset(); 2233 const apply = () => { 2234 try { 2235 this._mlMap.setSky(preset); 2236 } catch (e) { 2237 console.debug('[MapLibreCompat] updateSkyTheme failed:', e); 2238 } 2239 }; 2240 apply(); 2241 setTimeout(apply, 80); 2242 } 2243 2244 _enable3DBuildings() { 2245 this._addForest3DLayers(); // forests below buildings at init 2246 this._addBuildings3DLayer(); 2247 } 2248 2249 // ââ 3D Terrain ââ 2250 _terrainSourceId = 'gp-terrain-dem';
2251 _hillshadeSourceId = 'gp-hillshade-dem'; 2252 _terrainEnabled = false; 2253 _DEM_TILES_URL = 'https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png'; 2254 _DEM_MAX_ZOOM = 15; 2255 // Sky presets (MapLibre requires valid SkySpecification - never null) 2256 // Light: soft blue midday sky 2257 _SKY_DAY = { 'sky-color': '#89CFF0', 'sky-horizon-blend': 0.3, 'horizon-color': '#f0e8d8', 'horizon-fog-blend': 0.8, 'fog-color': '#dce6ef', 'fog-ground-blend': 0.9 }; 2258 // Dark: warm amber-violet sunset 2259 _SKY_SUNSET = { 'sky-color': '#1a0a2e', 'sky-horizon-blend': 0.5, 'horizon-color': '#c45c1a', 'horizon-fog-blend': 0.6, 'fog-color': '#7a2a0a', 'fog-ground-blend': 0.85 }; 2260 _SKY_RESET = { 'sky-color': 'transparent', 'horizon-color': 'transparent', 'fog-color': 'transparent', 'fog-ground-blend': 1, 'atmosphere-blend': 0 }; 2261 2262 _getSkyPreset() { 2263 return document.documentElement.getAttribute('data-theme') === 'dark' ? this._SKY_SUNSET : this._SKY_DAY; 2264 } 2265 2266 _ensureDemSource(id) { 2267 if (!this._mlMap.getSource(id)) { 2268 this._mlMap.addSource(id, { 2269 type: 'raster-dem', 2270 tiles: [this._DEM_TILES_URL], 2271 encoding: 'terrarium', 2272 tileSize: 256, 2273 maxzoom: this._DEM_MAX_ZOOM 2274 }); 2275 } 2276 } 2277 2278 setTerrain(enabled, exaggeration) { 2279 const mlMap = this._mlMap; 2280 const ex = exaggeration || 1.2; 2281 2282 if (enabled && !this._terrainEnabled) { 2283 this._ensureDemSource(this._terrainSourceId); 2284 this._ensureDemSource(this._hillshadeSourceId); 2285 if (!mlMap.getLayer('gp-hillshade')) { 2286 const anchorLayer = mlMap.getStyle().layers.find(l => 2287 l.type === 'line' || (l.type === 'fill' && !l.id.startsWith('gp-')) 2288 ); 2289 try { 2290 mlMap.addLayer({ 2291 id: 'gp-hillshade', type: 'hillshade', source: this._hillshadeSourceId, 2292 paint: { 'hillshade-shadow-color': '#473B24', 'hillshade-illumination-anchor': 'map', 'hillshade-exaggeration': 0.5 } 2293 }, anchorLayer?.id || '3d-buildings'); 2294 } catch (e) { 2295 console.debug('[MapLibreCompat] Could not add hillshade layer:', e); 2296 } 2297 } 2298 mlMap.setTerrain({ source: this._terrainSourceId, exaggeration: ex }); 2299 mlMap.setSky(this._getSkyPreset()); 2300 if (mlMap.getPitch() < 10) mlMap.easeTo({ pitch: 18, duration: 1000 }); 2301 this._terrainEnabled = true; 2302 2303 } else if (!enabled && this._terrainEnabled) { 2304 mlMap.setTerrain(null); 2305 mlMap.setSky(this._SKY_RESET); 2306 if (mlMap.getLayer('gp-hillshade')) mlMap.removeLayer('gp-hillshade'); 2307 if (mlMap.getPitch() > 10) mlMap.easeTo({ pitch: 0, duration: 800 }); 2308 this._terrainEnabled = false; 2309 } 2310 return this; 2311 } 2312 2313 getTerrain() { return this._terrainEnabled; } 2314 2315 // View methods 2316 setView(center, zoom) { 2317 const ll = toLatLng(center); 2318 if (zoom !== undefined) { 2319 this._mlMap.jumpTo({ center: [ll.lng, ll.lat], zoom: zoom }); 2320 } else { 2321 this._mlMap.setCenter([ll.lng, ll.lat]); 2322 } 2323 return this; 2324 } 2325 getCenter() { 2326 const c = this._mlMap.getCenter(); 2327 return { lat: c.lat, lng: c.lng }; 2328 } 2329 getZoom() { return this._mlMap.getZoom(); } 2330 setZoom(zoom) { this._mlMap.setZoom(zoom); return this; } 2331 zoomIn(delta) { this._mlMap.zoomIn(delta || 1); return this; } 2332 zoomOut(delta) { this._mlMap.zoomOut(delta || 1); return this; } 2333 getMinZoom() { return this._mlMap.getMinZoom(); } 2334 getMaxZoom() { return this._mlMap.getMaxZoom(); } 2335 getBounds() { 2336 const b = this._mlMap.getBounds(); 2337 return L.latLngBounds(
2338 [b.getSouthWest().lat, b.getSouthWest().lng], 2339 [b.getNorthEast().lat, b.getNorthEast().lng] 2340 ); 2341 } 2342 fitBounds(bounds, options) { 2343 const b = bounds.toBBoxArray ? bounds.toBBoxArray() : 2344 (bounds._southWest ? [bounds._southWest.lng, bounds._southWest.lat, bounds._northEast.lng, bounds._northEast.lat] : 2345 Array.isArray(bounds) ? [bounds[0][1], bounds[0][0], bounds[1][1], bounds[1][0]] : bounds); 2346 const mlBounds = Array.isArray(b) && b.length === 4 2347 ? [[b[0], b[1]], [b[2], b[3]]] 2348 : b; 2349 const fitOpts = {}; 2350 if (options) { 2351 if (options.padding) fitOpts.padding = typeof options.padding === 'number' ? options.padding : options.padding; 2352 if (options.animate === false) fitOpts.duration = 0; 2353 if (options.duration !== undefined) fitOpts.duration = options.duration; 2354 if (options.bearing !== undefined) fitOpts.bearing = options.bearing; 2355 } 2356 // Always reset pitch: with tilt > 0 the perspective projection pushes features 2357 // outside the visually unobstructed viewport even when bbox maths says they fit. 2358 fitOpts.pitch = options?.pitch ?? 0; 2359 this._mlMap.fitBounds(mlBounds, fitOpts); 2360 return this; 2361 } 2362 panTo(latlng, options) { 2363 const ll = toLatLng(latlng); 2364 this._mlMap.panTo([ll.lng, ll.lat], options); 2365 return this; 2366 } 2367 flyTo(latlng, zoom, options) { 2368 const ll = toLatLng(latlng); 2369 const duration = options?.duration; 2370 const durationMs = typeof duration === 'number' && duration > 0 && duration <= 10 2371 ? duration * 1000 2372 : duration; 2373 // Reset pitch for the same reason as fitBounds: perspective tilt displaces features 2374 this._mlMap.flyTo({ 2375 center: [ll.lng, ll.lat], 2376 zoom: zoom || this.getZoom(), 2377 pitch: options?.pitch ?? 0, 2378 ...(options?.bearing !== undefined ? { bearing: options.bearing } : {}), 2379 ...(durationMs !== undefined ? { duration: durationMs } : {}) 2380 }); 2381 return this; 2382 } 2383 2384 // Layer methods 2385 addLayer(layer) { 2386 if (!layer) return this; 2387 this._layers.push(layer); 2388 if (layer.addTo) layer.addTo(this); 2389 return this; 2390 } 2391 removeLayer(layer) { 2392 if (!layer) return this; 2393 const idx = this._layers.indexOf(layer); 2394 if (idx >= 0) this._layers.splice(idx, 1); 2395 if (layer.remove) layer.remove(); 2396 return this; 2397 } 2398 hasLayer(layer) { 2399 return this._layers.indexOf(layer) >= 0; 2400 } 2401 eachLayer(fn) { 2402 this._layers.forEach(fn); 2403 return this; 2404 } 2405 2406 // Panes (compatibility - no real panes in MapLibre) 2407 createPane(name) { 2408 this._panes[name] = { style: { zIndex: 400 } }; 2409 return this._panes[name]; 2410 } 2411 getPane(name) { return this._panes[name] || null; } 2412 2413 // Misc 2414 invalidateSize() { this._mlMap.resize(); return this; } 2415 whenReady(fn) { 2416 if (this._mlMap.loaded()) { fn({ target: this }); } 2417 else { this._mlMap.on('load', () => fn({ target: this })); } 2418 return this; 2419 } 2420 getContainer() { return this._mlMap.getContainer(); } 2421 getSize() { 2422 const c = this._mlMap.getContainer(); 2423 return { x: c.clientWidth, y: c.clientHeight }; 2424 } 2425 latLngToContainerPoint(latlng) { 2426 const ll = toLatLng(latlng); 2427 return this._mlMap.project([ll.lng, ll.lat]); 2428 } 2429 containerPointToLatLng(point) { 2430 const ll = this._mlMap.unproject(point); 2431 return { lat: ll.lat, lng: ll.lng }; 2432 } 2433 project(latlng) { 2434 const ll = toLatLng(latlng); 2435 return this._mlMap.project([ll.lng, ll.lat]); 2436 } 2437 unproject(point) { 2438 const ll = this._mlMap.unproject(point); 2439 return L.latLng(ll.lat, ll.lng); 2440 } 2441 remove() { 2442 this._mlMap.remove(); 2443 } 2444 getPixelBounds() { 2445 const size = this.getSize(); 2446 return { min: { x: 0, y: 0 }, max: { x: size.x, y: size.y } }; 2447 } 2448 } 2449 2450 L.map = function(containerId, options) { return new LMap(containerId, options); }; 2451 L.Map = LMap; 2452 2453 // ============================================================ 2454 // SVG RENDERER (no-op for MapLibre) 2455 // ============================================================ 2456 L.svg = function(options) { 2457 return { options: options || {} }; 2458 }; 2459 L.SVG = function() {}; 2460 2461 // ============================================================ 2462 // CONTROL (stubs) 2463 // ============================================================ 2464 L.Control = L.Control || {}; 2465 L.Control.Draw = true; // Truthy stub so modules don't bail out 2466 L.control = L.control || {}; 2467 2468 // ============================================================ 2469 // DRAW SYSTEM (replaces Leaflet.Draw) 2470 // ============================================================ 2471 2472 let _activeDrawHandler = null; 2473 2474 class DrawPolyline { 2475 constructor(map, options) { 2476 this._map = map; 2477 this._options = options || {}; 2478 this._points = []; 2479 this._tempLine = null; 2480 this._clickHandler = null; 2481 this._dblClickHandler = null; 2482 this._mouseMoveHandler = null; 2483 } 2484 enable() { 2485 if (_activeDrawHandler) _activeDrawHandler.disable(); 2486 _activeDrawHandler = this; 2487 this._points = []; 2488 const mlMap = this._map._mlMap || this._map; 2489 mlMap.getCanvas().style.cursor = 'crosshair'; 2490 2491 this._clickHandler = (e) => { 2492 this._points.push([e.lngLat.lng, e.lngLat.lat]); 2493 this._updatePreview(mlMap); 2494 }; 2495 this._dblClickHandler = (e) => { 2496 e.preventDefault(); 2497 if (this._points.length >= 2) this._finish(); 2498 else this.disable(); 2499 }; 2500 this._mouseMoveHandler = (e) => { 2501 if (this._points.length > 0) { 2502 const preview = [...this._points, [e.lngLat.lng, e.lngLat.lat]]; 2503 this._drawPreview(mlMap, preview); 2504 } 2505 }; 2506 mlMap.on('click', this._clickHandler); 2507 mlMap.on('dblclick', this._dblClickHandler); 2508 mlMap.on('mousemove', this._mouseMoveHandler); 2509 mlMap.doubleClickZoom.disable(); 2510 } 2511 disable() { 2512 const mlMap = this._map._mlMap || this._map; 2513 if (this._clickHandler) mlMap.off('click', this._clickHandler); 2514 if (this._dblClickHandler) mlMap.off('dblclick', this._dblClickHandler); 2515 if (this._mouseMoveHandler) mlMap.off('mousemove', this._mouseMoveHandler); 2516 this._removePreview(mlMap); 2517 mlMap.getCanvas().style.cursor = ''; 2518 mlMap.doubleClickZoom.enable(); 2519 if (_activeDrawHandler === this) _activeDrawHandler = null; 2520 } 2521 _updatePreview(mlMap) { 2522 this._drawPreview(mlMap, this._points); 2523 } 2524 _drawPreview(mlMap, coords) { 2525 const srcId = '_draw_preview_src'; 2526 const layId = '_draw_preview_layer'; 2527 const data = { type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: {} };
2528 try { 2529 const src = mlMap.getSource(srcId); 2530 if (src) { src.setData(data); return; } 2531 } catch {} 2532 try { 2533 mlMap.addSource(srcId, { type: 'geojson', data: data }); 2534 mlMap.addLayer({ id: layId, type: 'line', source: srcId, paint: { 'line-color': resolveColor(_defaultColor), 'line-width': 3, 'line-dasharray': [3, 3] }}); 2535 } catch {} 2536 } 2537 _removePreview(mlMap) { 2538 try { if (mlMap.getLayer('_draw_preview_layer')) mlMap.removeLayer('_draw_preview_layer'); } catch {} 2539 try { if (mlMap.getSource('_draw_preview_src')) mlMap.removeSource('_draw_preview_src'); } catch {} 2540 } 2541 _finish() { 2542 const latlngs = this._points.map(c => L.latLng(c[1], c[0])); 2543 const layer = L.polyline(latlngs, this._options?.shapeOptions || {}); 2544 layer.feature = { type: 'Feature', geometry: { type: 'LineString', coordinates: this._points }, properties: {} }; 2545 this.disable(); 2546 const map = this._map; 2547 if (map.fire) map.fire('draw:created', { layerType: 'polyline', layer: layer }); 2548 } 2549 } 2550 2551 class DrawPolygon { 2552 constructor(map, options) { 2553 this._map = map; 2554 this._options = options || {}; 2555 this._points = []; 2556 this._clickHandler = null; 2557 this._dblClickHandler = null; 2558 this._mouseMoveHandler = null; 2559 } 2560 enable() { 2561 if (_activeDrawHandler) _activeDrawHandler.disable(); 2562 _activeDrawHandler = this; 2563 this._points = []; 2564 const mlMap = this._map._mlMap || this._map; 2565 mlMap.getCanvas().style.cursor = 'crosshair'; 2566 2567 this._clickHandler = (e) => { 2568 this._points.push([e.lngLat.lng, e.lngLat.lat]); 2569 this._updatePreview(mlMap); 2570 }; 2571 this._dblClickHandler = (e) => { 2572 e.preventDefault(); 2573 if (this._points.length >= 3) this._finish(); 2574 else this.disable(); 2575 }; 2576 this._mouseMoveHandler = (e) => { 2577 if (this._points.length > 0) { 2578 const preview = [...this._points, [e.lngLat.lng, e.lngLat.lat]]; 2579 if (preview.length > 2) preview.push(preview[0]); 2580 this._drawPreview(mlMap, preview); 2581 } 2582 }; 2583 mlMap.on('click', this._clickHandler); 2584 mlMap.on('dblclick', this._dblClickHandler); 2585 mlMap.on('mousemove', this._mouseMoveHandler); 2586 mlMap.doubleClickZoom.disable(); 2587 } 2588 disable() { 2589 const mlMap = this._map._mlMap || this._map; 2590 if (this._clickHandler) mlMap.off('click', this._clickHandler); 2591 if (this._dblClickHandler) mlMap.off('dblclick', this._dblClickHandler); 2592 if (this._mouseMoveHandler) mlMap.off('mousemove', this._mouseMoveHandler); 2593 this._removePreview(mlMap); 2594 mlMap.getCanvas().style.cursor = ''; 2595 mlMap.doubleClickZoom.enable(); 2596 if (_activeDrawHandler === this) _activeDrawHandler = null; 2597 } 2598 _updatePreview(mlMap) { 2599 const coords = this._points.length > 2 ? [...this._points, this._points[0]] : this._points; 2600 this._drawPreview(mlMap, coords); 2601 } 2602 _drawPreview(mlMap, coords) { 2603 const srcId = '_draw_preview_src'; 2604 const layId = '_draw_preview_layer'; 2605 const fillId = '_draw_preview_fill'; 2606 const geomType = coords.length > 2 ? 'Polygon' : 'LineString'; 2607 const geomCoords = geomType === 'Polygon' ? [coords] : coords; 2608 const data = { type: 'Feature', geometry: { type: geomType, coordinates: geomCoords }, properties: {} };
2609 try { 2610 const src = mlMap.getSource(srcId); 2611 if (src) { src.setData(data); return; } 2612 } catch {} 2613 try { 2614 mlMap.addSource(srcId, { type: 'geojson', data: data }); 2615 if (geomType === 'Polygon') { 2616 mlMap.addLayer({ id: fillId, type: 'fill', source: srcId, paint: { 'fill-color': resolveColor(_defaultColor), 'fill-opacity': 0.15 }}); 2617 } 2618 mlMap.addLayer({ id: layId, type: 'line', source: srcId, paint: { 'line-color': resolveColor(_defaultColor), 'line-width': 3, 'line-dasharray': [3, 3] }}); 2619 } catch {} 2620 } 2621 _removePreview(mlMap) { 2622 try { if (mlMap.getLayer('_draw_preview_layer')) mlMap.removeLayer('_draw_preview_layer'); } catch {} 2623 try { if (mlMap.getLayer('_draw_preview_fill')) mlMap.removeLayer('_draw_preview_fill'); } catch {} 2624 try { if (mlMap.getSource('_draw_preview_src')) mlMap.removeSource('_draw_preview_src'); } catch {} 2625 } 2626 _finish() { 2627 const closed = [...this._points, this._points[0]]; 2628 const latlngs = this._points.map(c => L.latLng(c[1], c[0])); 2629 const layer = L.polygon(latlngs, this._options?.shapeOptions || {}); 2630 layer.feature = { type: 'Feature', geometry: { type: 'Polygon', coordinates: [closed] }, properties: {} }; 2631 this.disable(); 2632 const map = this._map; 2633 if (map.fire) map.fire('draw:created', { layerType: 'polygon', layer: layer }); 2634 } 2635 } 2636 2637 class DrawMarker { 2638 constructor(map, options) { 2639 this._map = map; 2640 this._options = options || {}; 2641 this._clickHandler = null; 2642 } 2643 enable() { 2644 if (_activeDrawHandler) _activeDrawHandler.disable(); 2645 _activeDrawHandler = this; 2646 const mlMap = this._map._mlMap || this._map; 2647 mlMap.getCanvas().style.cursor = 'crosshair'; 2648 2649 this._clickHandler = (e) => { 2650 const latlng = L.latLng(e.lngLat.lat, e.lngLat.lng); 2651 const icon = this._options?.icon || null; 2652 const layer = L.marker(latlng, { icon: icon }); 2653 layer.feature = { type: 'Feature', geometry: { type: 'Point', coordinates: [e.lngLat.lng, e.lngLat.lat] }, properties: {} }; 2654 this.disable(); 2655 const map = this._map; 2656 if (map.fire) map.fire('draw:created', { layerType: 'marker', layer: layer }); 2657 }; 2658 mlMap.on('click', this._clickHandler); 2659 } 2660 disable() { 2661 const mlMap = this._map._mlMap || this._map; 2662 if (this._clickHandler) mlMap.off('click', this._clickHandler); 2663 mlMap.getCanvas().style.cursor = ''; 2664 if (_activeDrawHandler === this) _activeDrawHandler = null; 2665 } 2666 } 2667 2668 L.Draw = { 2669 Event: { 2670 CREATED: 'draw:created', 2671 EDITED: 'draw:edited', 2672 DELETED: 'draw:deleted' 2673 }, 2674 Polyline: DrawPolyline, 2675 Polygon: DrawPolygon, 2676 Marker: DrawMarker 2677 }; 2678 2679 // ============================================================ 2680 // EXPOSE 2681 // ============================================================ 2682 // Expose color cache clear for theme changes 2683 L.clearColorCache = clearColorCache; 2684 L._resolveColor = resolveColor; 2685 2686 win.L = L; 2687 2688})(window);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.