PageSourceSearch

https://staging-grandsprojets.netlify.app/modules/maplibre-compat.js

js staging-grandsprojets.netlify.app collected 2026-10-03 11:26:42 UTC 105,213 bytes, 2,688 lines download raw bytes

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.