PageSourceSearch

https://geoconfirmed.org/Components/Map/MapLibre.mrzap9dn3x.razor.js

js geoconfirmed.org collected 2026-09-28 11:04:31 UTC 210,365 bytes, 5,270 lines download raw bytes

1const instances = new Map();
2let idCounter = 0;
3
4// ===== MAP INITIALIZATION =====
5
6export function initMap(element, dotnetRef, config) {
7    // The caller can be torn down between requesting a map and this running, leaving `element`
8    // detached (or null). MapLibre would throw out of a Blazor lifecycle method and take the page
9    // down with it, so report "no map" instead and let the caller stay inert.
10    if (!element || !(element instanceof HTMLElement) || !element.isConnected) return null;
11
12    const mapId = `map-${++idCounter}`;
13
14    const map = new maplibregl.Map({
15        container: element,
16        style: buildStyle(config),
17        center: config.center || [30, 48],
18        zoom: config.zoom || 5,
19        maxZoom: config.maxZoom || 18,
20        minZoom: config.minZoom || 2,
21        maxPitch: 85,
22        attributionControl: false,
23        hash: config.hash !== false,
24        pixelRatio: 2
25    });
26
27    const instance = {
28        map,
29        dotnetRef,
30        iconZoomThreshold: 7,
31        measureState: null,
32        measureSources: [],
33        measureLayers: [],
34        layerDisplayNames: {},
35        layerColors: {},
36        originalSourceData: {},
37        // Query-builder filter on the merged placemarks source: a Set of placemark ids, or null
38        // when no filter is applied. See _filteredPlacemarkFeatures.
39        activeIdFilter: null,
40        _measureClickHandler: null,
41        _measureMoveHandler: null,
42        _measureDblClickHandler: null,
43        drawFeatures: [],
44        drawState: null
45    };
46
47    instances.set(mapId, instance);
48
49    map.on('load', () => {
50        _addBuildingsLayer(map);
51        dotnetRef.invokeMethodAsync('OnMapLoaded');
52    });
53
54    // Suppress native context menu when Ctrl or Alt is held so copy-coords works
55    element.addEventListener('contextmenu', (e) => {
56        if (e.ctrlKey || e.altKey) {
57            e.preventDefault();
58        }
59    });
60
61    map.on('contextmenu', (e) => {
62        if (e.originalEvent.ctrlKey) {
63            e.originalEvent.preventDefault();
64            const { lng, lat } = e.lngLat;
65            const coords = `${lat.toFixed(6)}, ${lng.toFixed(6)}`;
66            navigator.clipboard.writeText(coords);
67            _showCopyToast(map.getContainer(), coords, e.originalEvent);
68        } else if (e.originalEvent.altKey) {
69            e.originalEvent.preventDefault();
70            const { lng, lat } = e.lngLat;
71            const mgrs = _latLngToMGRS(lat, lng);
72            navigator.clipboard.writeText(mgrs);
73            _showCopyToast(map.getContainer(), mgrs, e.originalEvent);
74        }
75    });
76
77    map.on('moveend', () => {
78        const center = map.getCenter();
79        const zoom = map.getZoom();
80        const bounds = map.getBounds();
81        dotnetRef.invokeMethodAsync('OnViewChanged', {
82            centerLng: center.lng,
83            centerLat: center.lat,
84            zoom,
85            west: bounds.getWest(),
86            south: bounds.getSouth(),
87            east: bounds.getEast(),
88            north: bounds.getNorth()
89        });
90    });
91
92    return mapId;
93}
94
95function _addBuildingsLayer(map) {
96    if (!map.getSource('openmaptiles')) {
97        map.addSource('openmaptiles', {
98            type: 'vector',
99            url: 'https://tiles.openfreemap.org/planet'
100        });
101    }
102    if (!map.getLayer('3d-buildings')) {
103        map.addLayer({
104            id: '3d-buildings',
105            type: 'fill-extrusion',
106            source: 'openmaptiles',
107            'source-layer': 'building',
108            minzoom: 15,
109            paint: {
110                'fill-extrusion-color': '#d4cfc8',
111                'fill-extrusion-height': ['get', 'render_height'],
112                'fill-extrusion-base': ['get', 'render_min_height'],
113                'fill-extrusion-opacity': [
114                    'interpolate', ['linear'], ['zoom'],
115                    15, 0,
116                    15.5, 0.65
117                ]
118            }
119        });
120    }
121}
122
123function buildStyle(config) {
124    return {
125        version: 8,
126        sources: {
127            'base-tiles': {
128                type: 'raster',
129                tiles: [config.tileUrl || '/api/files/maps/topo-v4/{x}/{y}/{z}.png'],
130                tileSize: config.tileSize || 512,
131                maxzoom: config.maxZoom || 18
132            }
133        },
134        layers: [{
135            id: 'base-layer',
136            type: 'raster',
137            source: 'base-tiles'
138        }],
139        glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf'
140    };
141}
142
143// Run `fn` once the map's style is ready. MapLibre throws "Style is not done
144// loading" from addSource/addLayer/getStyle.layers if invoked mid-load — which
145// happens when a basemap switch is triggered before a prior switch finishes.
146function _whenStyleLoaded(map, fn) {
147    if (map.isStyleLoaded()) {
148        fn();
149    } else {
150        map.once('idle', fn);
151    }
152}
153
154export function setBaseLayer(mapId, tileUrls, tileSize) {
155    const instance = instances.get(mapId);
156    const { map } = instance;
157
158    if (instance._isVectorBase) {
159        // Switching from vector style back to raster — need full style swap
160        const overlays = _saveOverlays(instance);
161        const style = buildStyle({ tileUrl: tileUrls[0], tileSize: tileSize || 256, maxZoom: 18 });
162        // Override tiles array to support multiple URLs
163        style.sources['base-tiles'].tiles = tileUrls;
164        map.setStyle(style);
165        instance._isVectorBase = false;
166        _queueRestoreOverlays(instance, overlays);
167    } else {
168        _whenStyleLoaded(map, () => {
169            const source = map.getSource('base-tiles');
170            if (source) {
171                map.removeLayer('base-layer');
172                map.removeSource('base-tiles');
173            }
174            map.addSource('base-tiles', {
175                type: 'raster',
176                tiles: tileUrls,
177                tileSize: tileSize || 256,
178                maxzoom: 18
179            });
180            // Add base layer below all other layers
181            const layers = map.getStyle()?.layers ?? [];
182            const firstNonBaseId = layers.length > 0 ? layers[0].id : undefined;
183            map.addLayer({
184                id: 'base-layer',
185                type: 'raster',
186                source: 'base-tiles'
187            }, firstNonBaseId);
188        });
189    }
190}
191
192export async function setVectorBaseLayer(mapId, styleUrl) {
193    const instance = instances.get(mapId);
194    const { map } = instance;
195
196    const overlays = _saveOverlays(instance);
197    map.setStyle(styleUrl);
198    instance._isVectorBase = true;
199    _queueRestoreOverlays(instance, overlays);
200}
201
202// Restores the overlays on the swapped style's idle. `_styleSwapPending` is true from setStyle
203// until then, so an overlay load (loadFrontlines) can wait for the swap alone: map.isStyleLoaded()
204// is also false while tiles are loading, which would stall a load that needs no wait.
205function _queueRestoreOverlays(instance, overlays) {
206    instance._styleSwapPending = true;
207    instance.map.once('idle', () => {
208        instance._styleSwapPending = false;
209        _restoreOverlays(instance, overlays);
210    });
211}
212
213function _saveOverlays(instance) {
214    // Save only the instance metadata — actual data will be re-added from cached copies
215    return {
216        displayNames: { ...instance.layerDisplayNames },
217        colors: { ...instance.layerColors }
218    };
219}
220
221function _restoreOverlays(instance, overlays) {
222    const { map, dotnetRef } = instance;
223
224    // Re-add 3D buildings layer
225    _addBuildingsLayer(map);
226
227    // Restore metadata
228    instance.layerDisplayNames = overlays.displayNames;
229    instance.layerColors = overlays.colors;
230
231    // Re-add placemarks from cached data — through the query-builder filter, so a base-style
232    // swap does not resurrect placemarks the Filters panel has hidden. Guarded like the other
233    // overlays below: two quick base swaps queue two restores on the same idle, and a conflict
234    // load can land between setStyle and idle — addSource would throw "already exists".
235    if (instance.allFeatures && instance.factionMeta && !map.getSource('placemarks-all')) {
236        const geojson = { type: 'FeatureCollection', features: _filteredPlacemarkFeatures(instance) };
237        const sourceId = 'placemarks-all';
238        const { iconZoomThreshold, factionMeta } = instance;
239
240        map.addSource(sourceId, { type: 'geojson', data: geojson, buffer: 128, tolerance: 0.375 });
241
242        const circlesId = `${sourceId}-circles`;
243        const pointsId = `${sourceId}-points`;
244
245        const colorExpr = _factionCircleColor(factionMeta);
246
247        const opacityExpr = ['case', ['==', ['get', 'unapproved'], 1], 0.4, 1];
248
249        map.addLayer({
250            id: circlesId, type: 'circle', source: sourceId,
251            maxzoom: iconZoomThreshold,
252            layout: { 'circle-sort-key': ['get', 'dateSort'] },
253            paint: { 'circle-radius': 4, 'circle-color': colorExpr, 'circle-stroke-width': 1, 'circle-stroke-color': '#ffffff', 'circle-opacity': opacityExpr, 'circle-stroke-opacity': opacityExpr }
254        });
255        map.addLayer({
256            id: pointsId, type: 'symbol', source: sourceId,
257            minzoom: iconZoomThreshold,
258            layout: { 'icon-image': ['get', 'icon'], 'icon-size': 0.5, 'icon-allow-overlap': true, 'symbol-sort-key': ['get', 'dateSort'] },
259            paint: { 'icon-opacity': opacityExpr }
260        });
261
262        instance.placemarkLayerIds = [circlesId, pointsId];
263
264        const onClick = (e) => {
265            if (!e.features?.length) return;
266            if (e.originalEvent._placemarkHandled) return;
267            if (_mentionUnderCursor(instance, e)) return;
268            e.originalEvent._placemarkHandled = true;
269            const feature = e.features[0];
270            dotnetRef.invokeMethodAsync('OnFeatureClicked', JSON.stringify({
271                id: feature.id, properties: feature.properties, coordinates: feature.geometry.coordinates
272            }));
273        };
274        map.on('click', circlesId, onClick);
275        map.on('click', pointsId, onClick);
276        map.on('mouseenter', circlesId, () => { map.getCanvas().style.cursor = 'pointer'; });
277        map.on('mouseleave', circlesId, () => { map.getCanvas().style.cursor = ''; });
278        map.on('mouseenter', pointsId, () => { map.getCanvas().style.cursor = 'pointer'; });
279        map.on('mouseleave', pointsId, () => { map.getCanvas().style.cursor = ''; });
280
281        // Reload icon images
282        const iconUrls = new Set();
283        for (const f of instance.allFeatures) {
284            if (f.properties.icon) iconUrls.add(f.properties.icon);
285        }
286        _loadImagesBatched(map, iconUrls).then(() => _applyComposedFilter(instance))
287            .catch(err => console.warn('[MapLibre] Icon reload error:', err));
288    }
289
290    // Re-add frontlines from cached data
291    if (instance.frontlineFeatures && !map.getSource('frontlines')) {
292        const geojson = { type: 'FeatureCollection', features: instance.frontlineFeatures };
293        const sourceId = 'frontlines';
294        const layerId = 'frontlines-line';
295        const hitLayerId = 'frontlines-hit';
296        // Respect the last toggle state — a hidden overlay must not resurrect
297        // itself visible after a base-style swap.
298        const visibility = instance.frontlineVisible === false ? 'none' : 'visible';
299
300        map.addSource(sourceId, { type: 'geojson', data: geojson });
301        instance.originalSourceData[sourceId] = geojson;
302
303        map.addLayer({ id: hitLayerId, type: 'line', source: sourceId, layout: { visibility }, paint: { 'line-color': 'transparent', 'line-width': 14 } });
304        map.addLayer({ id: layerId, type: 'line', source: sourceId, layout: { visibility, 'line-cap': 'round', 'line-join': 'round' }, paint: { 'line-color': '#e53935', 'line-width': 3, 'line-opacity': 0.7 } });
305
306        _applyFrontlineColors(instance);
307        _registerFrontlineHandlers(instance, hitLayerId);
308        _applyComposedFilter(instance);
309    }
310
311    // Re-add FIRMS hotspots from cached data
312    if (instance.firmsFeatures) {
313        _addFirmsLayers(instance);
314    }
315
316    // Re-add the unprocessed-mentions overlay from cached data
317    if (instance.mentionsFeatures) {
318        _addMentionsLayers(instance);
319    }
320
321    // Re-add the Copernicus imagery layer from cached state
322    if (instance.copernicusUrl) {
323        _addCopernicusLayer(instance);
324    }
325
326    // Re-add drawings from cached data
327    if (instance.drawFeatures && instance.drawFeatures.length > 0) {
328        _ensureDrawLayers(instance);
329        _updateDrawDisplay(instance);
330    }
331}
332
333function _registerFrontlineHandlers(instance, hitLayerId) {
334    // Delegated listeners live on the Map (not the style) and keep working
335    // when a layer with the same id is re-added — register exactly once or
336    // popups would stack one copy per base-style swap / conflict change.
337    if (instance._frontlineHandlersRegistered) return;
338    instance._frontlineHandlersRegistered = true;
339
340    const { map } = instance;
341    map.on('click', hitLayerId, (e) => {
342        if (!e.features?.length) return;
343        const props = e.features[0].properties;
344        const date = props.date ? new Date(props.date).toLocaleDateString('en-GB', {
345            day: '2-digit', month: 'short', year: 'numeric'
346        }).toUpperCase() : 'Unknown date';
347        // The name comes from a third-party KML source: escape it before it reaches HTML.
348        const name = props.name || '';
349        // On the global map several conflicts' lines share one layer, so say whose line it is.
350        const conflict = instance.frontlineMultiConflict && props.conflict
351            ? (instance.frontlineConflictNames?.get(props.conflict) ?? props.conflict)
352            : '';
353        const html = '<div class="overlay-popup">'
354            + `<div style="font-size:13px;font-weight:600;">${date}</div>`
355            + (conflict ? `<div style="font-size:12px;">${_escapeHtml(conflict)}</div>` : '')
356            + (name ? `<div style="font-size:12px;color:#888;">${_escapeHtml(name)}</div>` : '')
357            + '</div>';
358        new maplibregl.Popup({ closeOnClick: true, maxWidth: '200px' })
359            .setLngLat(e.lngLat)
360            .setHTML(html)
361            .addTo(map);
362    });
363    map.on('mouseenter', hitLayerId, () => { map.getCanvas().style.cursor = 'pointer'; });
364    map.on('mouseleave', hitLayerId, () => { map.getCanvas().style.cursor = ''; });
365}
366
367export function dispose(mapId) {
368    const instance = instances.get(mapId);
369    if (instance) {
370        // Clean up timeline (stops playback interval, removes listeners)
371        const timeline = _timelineState.get(mapId);
372        if (timeline) timeline.destroy();
373
374        // Remove all open popups
375        if (instance._popups) {
376            for (const popup of instance._popups.values()) popup.remove();
377            instance._popups.clear();
378        }
379
380        disableMeasureMode(mapId);
381        instance.map.remove();
382        instances.delete(mapId);
383    }
384}
385
386// ===== CONTROLS =====
387
388export function addNavigationControl(mapId, position) {
389    const { map } = instances.get(mapId);
390    map.addControl(new maplibregl.NavigationControl({
391        showCompass: true,
392        showZoom: true,
393        visualizePitch: true
394    }), position || 'top-right');
395}
396
397export function addScaleControl(mapId, position) {
398    const { map } = instances.get(mapId);
399    map.addControl(new maplibregl.ScaleControl({
400        maxWidth: 200,
401        unit: 'metric'
402    }), position || 'bottom-left');
403}
404
405export function addProjectionControl(mapId, position) {
406    const { map } = instances.get(mapId);
407
408    const STORAGE_KEY = 'maplibre-projection';
409
410    class ProjectionControl {
411        onAdd(m) {
412            this._map = m;
413            this._container = document.createElement('div');
414            this._container.className = 'maplibregl-ctrl maplibregl-ctrl-group';
415
416            this._button = document.createElement('button');
417            this._button.type = 'button';
418            this._button.className = 'maplibregl-ctrl-icon map-projection-toggle';
419            this._button.setAttribute('aria-label', 'Toggle globe projection');
420            this._button.title = 'Toggle globe projection';
421
422            let saved = 'mercator';
423            try { saved = localStorage.getItem(STORAGE_KEY) || 'mercator'; } catch { /* localStorage unavailable */ }
424            if (saved === 'globe') {
425                try { m.setProjection({ type: 'globe' }); } catch { /* ignore */ }
426            }
427            this._updateIcon(saved === 'globe');
428
429            this._button.addEventListener('click', () => {
430                const current = (m.getProjection && m.getProjection()?.type) || 'mercator';
431                const next = current === 'globe' ? 'mercator' : 'globe';
432                m.setProjection({ type: next });
433                this._updateIcon(next === 'globe');
434                try { localStorage.setItem(STORAGE_KEY, next); } catch { /* ignore */ }
435            });
436
437            this._container.appendChild(this._button);
438            return this._container;
439        }
440        onRemove() {
441            this._container?.parentNode?.removeChild(this._container);
442            this._map = undefined;
443        }
444        _updateIcon(isGlobe) {
445            // Inline SVG: globe outline when flat, flat-map when globe is active
446            const svg = isGlobe
447                ? '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="6" width="18" height="12" rx="1"/><path d="M3 10h18M3 14h18M9 6v12M15 6v12"/></svg>'
448                : '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/></svg>';
449            this._button.innerHTML = svg;
450        }
451    }
452
453    map.addControl(new ProjectionControl(), position || 'top-right');
454}
455
456// ===== LAYERS =====
457
458export function addGeoJsonSource(mapId, sourceId, geojson, clusterOptions) {
459    const { map } = instances.get(mapId);
460    const sourceConfig = {
461        type: 'geojson',
462        data: geojson
463    };
464
465    if (clusterOptions) {
466        sourceConfig.cluster = true;
467        sourceConfig.clusterMaxZoom = clusterOptions.maxZoom || 14;
468        sourceConfig.clusterRadius = clusterOptions.radius || 50;
469        sourceConfig.clusterProperties = clusterOptions.properties || {};
470    }
471
472    map.addSource(sourceId, sourceConfig);
473}
474
475export function updateGeoJsonSource(mapId, sourceId, geojson) {
476    const { map } = instances.get(mapId);
477    const source = map.getSource(sourceId);
478    if (source) source.setData(geojson);
479}
480
481export function addLayer(mapId, layerConfig) {
482    const { map } = instances.get(mapId);
483    map.addLayer(layerConfig);
484}
485
486export function removeLayer(mapId, layerId) {
487    const instance = instances.get(mapId);
488    const { map } = instance;
489    if (map.getLayer(layerId)) map.removeLayer(layerId);
490    if (instance.placemarkLayerIds) {
491        const idx = instance.placemarkLayerIds.indexOf(layerId);
492        if (idx !== -1) instance.placemarkLayerIds.splice(idx, 1);
493    }
494}
495
496export function removeSource(mapId, sourceId) {
497    const instance = instances.get(mapId);
498    const { map } = instance;
499    if (map.getSource(sourceId)) map.removeSource(sourceId);
500    if (instance.originalSourceData) delete instance.originalSourceData[sourceId];
501
502    // When the unified placemarks source is removed, clear all related state
503    if (sourceId === 'placemarks-all') {
504        instance.allFeatures = null;
505        instance.factionMeta = null;
506        // The stats overlay caches "Latest" once per dataset; the next load must recompute it.
507        instance._latestDate = null;
508        instance.activeIdFilter = null;
509        instance.factionFilter = null;
510        instance.dateFilter = null;
511        instance.placemarkLayerIds = null;
512
513        // Remove all open popups
514        if (instance._popups) {
515            for (const popup of instance._popups.values()) popup.remove();
516            instance._popups.clear();
517        }
518    }
519}
520
521/**
522 * Fade every placemark whose unit path doesn't contain `token` (e.g. "/204/"), leaving the selected
523 * ORBAT subtree at full opacity. Pass a null/empty token to restore everything.
524 *
525 * The expression is built here rather than passed in from .NET on purpose: marshalling a nested
526 * object[] graph through JS interop leans on reflection-based serialization, which is exactly what
527 * the Release WASM build trims. Two primitives cross the boundary instead.
528 *
529 * `["in", needle, haystack]` is a substring test when the haystack is a string. The token's leading
530 * and trailing slashes are what keep "/204/" from also matching units 1204 and 2041.
531 */
532export function setSubtreeHighlight(mapId, sourceId, token, dimmedOpacity) {
533    const instance = instances.get(mapId);
534    if (!instance) return;
535    const { map } = instance;
536
537    const inSubtree = ['in', token, ['coalesce', ['get', 'p'], '']];
538    const opacity = token ? ['case', inSubtree, 1, dimmedOpacity] : 1;
539
540    const paint = [
541        [`${sourceId}-circles`, 'circle-opacity'],
542        [`${sourceId}-circles`, 'circle-stroke-opacity'],
543        [`${sourceId}-points`, 'icon-opacity'],
544    ];
545    for (const [layerId, property] of paint) {
546        if (map.getLayer(layerId)) map.setPaintProperty(layerId, property, opacity);
547    }
548}
549
550// Reads a paint property back out. Dimming lives entirely in the paint expression, so this is the
551// only way a test can tell "highlighted a subtree" apart from "left the map alone" — the pins are
552// drawn on a canvas and the DOM says nothing about their opacity. Returns JSON, since paint values
553// are expression arrays, and null when the map or layer isn't there yet.
554export function getPaintProperty(mapId, layerId, property) {
555    const instance = instances.get(mapId);
556    if (!instance) return null;
557    const { map } = instance;
558    if (!map.getLayer(layerId)) return null;
559    return JSON.stringify(map.getPaintProperty(layerId, property) ?? null);
560}
561
562export function setLayerVisibility(mapId, layerId, visible) {
563    const instance = instances.get(mapId);
564    const { map } = instance;
565
566    // Check if this is a virtual faction layer ID (e.g. "placemarks-{factionId}-circles")
567    if (instance.factionMeta) {
568        const match = layerId.match(/^placemarks-(.+)-(circles|points)$/);
569        if (match && match[1] !== 'all') {
570            const raw = match[1];
571            const factionId = isNaN(raw) ? raw : Number(raw);
572            const meta = instance.factionMeta.get(factionId);
573            if (meta) {
574                meta.visible = visible;
575                _rebuildFactionFilter(instance);
576                return;
577            }
578        }
579    }
580
581    // Real MapLibre layer
582    if (map.getLayer(layerId)) {
583        map.setLayoutProperty(layerId, 'visibility', visible ? 'visible' : 'none');
584    }
585
586    // Also toggle the hit layer when toggling the frontline
587    if (layerId === 'frontlines-line') {
588        if (map.getLayer('frontlines-hit')) {
589            map.setLayoutProperty('frontlines-hit', 'visibility', visible ? 'visible' : 'none');
590        }
591    }
592}
593
594export function setIconZoomThreshold(mapId, threshold) {
595    const instance = instances.get(mapId);
596    const { map } = instance;
597    instance.iconZoomThreshold = threshold;
598    for (const layer of map.getStyle().layers) {
599        if (layer.id.endsWith('-circles')) {
600            map.setLayerZoomRange(layer.id, map.getLayer(layer.id).minzoom ?? 0, threshold);
601        } else if (layer.id.endsWith('-points')) {
602            map.setLayerZoomRange(layer.id, threshold, map.getLayer(layer.id).maxzoom ?? 24);
603        }
604    }
605}
606
607export function setFilter(mapId, layerId, filter) {
608    const { map } = instances.get(mapId);
609    if (map.getLayer(layerId)) {
610        // MapLibre v5 rejects null in style validation; undefined clears the filter.
611        map.setFilter(layerId, filter ?? undefined);
612    }
613}
614
615export function getLayers(mapId) {
616    const instance = instances.get(mapId);
617    const { map } = instance;
618    const displayNames = instance.layerDisplayNames || {};
619    const colors = instance.layerColors || {};
620
621    const result = [];
622
623    // Known overlay layer IDs that we manage (not part of any base style)
624    const knownOverlayIds = new Set(['frontlines-line', '3d-buildings']);
625
626    // Add only our overlay layers from the actual map style (skip base style layers).
627    // getStyle() returns undefined before the style is loaded — bail out cleanly.
628    const styleLayers = map.getStyle()?.layers;
629    if (!styleLayers) return result;
630    for (const l of styleLayers) {
631        if (!displayNames[l.id] && !knownOverlayIds.has(l.id)) continue;
632        if (l.id === 'placemarks-all-circles' || l.id === 'placemarks-all-points') continue;
633        if (l.id === 'frontlines-hit') continue;
634        result.push({
635            id: l.id,
636            type: l.type,
637            sourceId: l.source || null,
638            visible: map.getLayoutProperty(l.id, 'visibility') !== 'none',
639            displayName: displayNames[l.id] || null,
640            color: colors[l.id] || null
641        });
642    }
643
644    // Synthesize per-faction virtual entries for the layer control. The conflict fields are
645    // only set by the global GeoJSON, where the panel groups factions per conflict.
646    if (instance.factionMeta) {
647        for (const [factionId, meta] of instance.factionMeta) {
648            const circlesId = `placemarks-${factionId}-circles`;
649            const pointsId = `placemarks-${factionId}-points`;
650            const conflictFields = {
651                conflict: meta.conflict || null,
652                conflictName: meta.conflictName || null,
653                conflictUrl: meta.conflictUrl || null,
654                conflictCode: meta.conflictCode || null
655            };
656            result.push({
657                id: circlesId,
658                type: 'circle',
659                sourceId: `placemarks-${factionId}`,
660                visible: meta.visible,
661                displayName: meta.displayName || null,
662                color: meta.color || null,
663                ...conflictFields
664            });
665            result.push({
666                id: pointsId,
667                type: 'symbol',
668                sourceId: `placemarks-${factionId}`,
669                visible: meta.visible,
670                displayName: meta.displayName || null,
671                color: meta.color || null,
672                ...conflictFields
673            });
674        }
675    }
676
677    return result;
678}
679
680// ===== NAVIGATION =====
681
682export function flyTo(mapId, lng, lat, zoom) {
683    const { map } = instances.get(mapId);
684    const opts = { center: [lng, lat], duration: 1500 };
685    if (zoom != null) opts.zoom = zoom;
686    map.flyTo(opts);
687}
688
689export function fitBounds(mapId, west, south, east, north, padding) {
690    const { map } = instances.get(mapId);
691    map.fitBounds([[west, south], [east, north]], {
692        padding: padding || 50,
693        duration: 1000
694    });
695}
696
697// ===== MEASUREMENT =====
698
699export function enableMeasureMode(mapId, mode) {
700    const instance = instances.get(mapId);
701    const { map } = instance;
702
703    clearMeasurements(mapId);
704
705    // Read the primary color from CSS variables
706    const primaryColor = getComputedStyle(document.documentElement)
707        .getPropertyValue('--mud-palette-primary').trim() || '#7b5dc9';
708
709    instance.measureState = {
710        mode, // 'distance' or 'area'
711        points: [],
712        active: true,
713        primaryColor,
714        draggingIndex: -1
715    };
716
717    map.addSource('measure-points', {
718        type: 'geojson',
719        data: { type: 'FeatureCollection', features: [] }
720    });
721
722    map.addSource('measure-lines', {
723        type: 'geojson',
724        data: { type: 'FeatureCollection', features: [] }
725    });
726
727    map.addLayer({
728        id: 'measure-lines-layer',
729        type: 'line',
730        source: 'measure-lines',
731        paint: {
732            'line-color': primaryColor,
733            'line-width': 2.5,
734            'line-dasharray': [3, 2]
735        }
736    });
737
738    if (mode === 'area') {
739        map.addSource('measure-fill', {
740            type: 'geojson',
741            data: { type: 'FeatureCollection', features: [] }
742        });
743        map.addLayer({
744            id: 'measure-fill-layer',
745            type: 'fill',
746            source: 'measure-fill',
747            paint: {
748                'fill-color': primaryColor,
749                'fill-opacity': 0.1
750            }
751        });
752        instance.measureSources.push('measure-fill');
753        instance.measureLayers.push('measure-fill-layer');
754    }
755
756    map.addLayer({
757        id: 'measure-points-layer',
758        type: 'circle',
759        source: 'measure-points',
760        paint: {
761            'circle-radius': 6,
762            'circle-color': primaryColor,
763            'circle-stroke-width': 2,
764            'circle-stroke-color': '#ffffff'
765        }
766    });
767
768    map.addSource('measure-labels', {
769        type: 'geojson',
770        data: { type: 'FeatureCollection', features: [] }
771    });
772    map.addLayer({
773        id: 'measure-labels-layer',
774        type: 'symbol',
775        source: 'measure-labels',
776        layout: {
777            'text-field': ['get', 'label'],
778            'text-offset': [0, -1.5],
779            'text-size': 14,
780            'text-font': ['Open Sans Regular']
781        },
782        paint: {
783            'text-color': primaryColor,
784            'text-halo-color': '#ffffff',
785            'text-halo-width': 2
786        }
787    });
788
789    instance.measureSources.push('measure-points', 'measure-lines', 'measure-labels');
790    instance.measureLayers.push('measure-lines-layer', 'measure-points-layer', 'measure-labels-layer');
791
792    instance._measureClickHandler = (e) => onMeasureClick(mapId, e);
793    instance._measureMoveHandler = (e) => onMeasureMouseMove(mapId, e);
794    instance._measureDblClickHandler = (e) => onMeasureDoubleClick(mapId, e);
795
796    map.on('click', instance._measureClickHandler);
797    map.on('mousemove', instance._measureMoveHandler);
798    map.on('dblclick', instance._measureDblClickHandler);
799    map.doubleClickZoom.disable();
800    map.getCanvas().style.cursor = 'crosshair';
801}
802
803function onMeasureClick(mapId, e) {
804    const instance = instances.get(mapId);
805    if (!instance.measureState?.active) return;
806
807    instance.measureState.points.push([e.lngLat.lng, e.lngLat.lat]);
808    updateMeasureDisplay(mapId);
809}
810
811function onMeasureMouseMove(mapId, e) {
812    const instance = instances.get(mapId);
813    if (!instance.measureState?.active || instance.measureState.points.length === 0) return;
814
815    updateMeasureDisplay(mapId, [e.lngLat.lng, e.lngLat.lat]);
816}
817
818function onMeasureDoubleClick(mapId, e) {
819    e.preventDefault();
820    const instance = instances.get(mapId);
821    if (!instance.measureState?.active) return;
822
823    instance.measureState.active = false;
824
825    instance.map.off('click', instance._measureClickHandler);
826    instance.map.off('mousemove', instance._measureMoveHandler);
827    instance.map.off('dblclick', instance._measureDblClickHandler);
828    instance.map.doubleClickZoom.enable();
829    instance.map.getCanvas().style.cursor = '';
830
831    updateMeasureDisplay(mapId);
832
833    const measurement = calculateMeasurement(instance.measureState);
834    instance.dotnetRef.invokeMethodAsync('OnMeasurementComplete', measurement);
835
836    // Enable dragging of measurement points
837    _enableMeasureDrag(mapId);
838}
839
840function _enableMeasureDrag(mapId) {
841    const instance = instances.get(mapId);
842    const { map } = instance;
843
844    const onMouseEnter = () => {
845        if (instance.measureState?.draggingIndex === -1) {
846            map.getCanvas().style.cursor = 'grab';
847        }
848    };
849    const onMouseLeave = () => {
850        if (instance.measureState?.draggingIndex === -1) {
851            map.getCanvas().style.cursor = '';
852        }
853    };
854
855    const onMouseDown = (e) => {
856        const features = map.queryRenderedFeatures(e.point, { layers: ['measure-points-layer'] });
857        if (!features.length || !instance.measureState) return;
858
859        const clickedCoord = [e.lngLat.lng, e.lngLat.lat];
860        let closestIdx = 0;
861        let closestDist = Infinity;
862        for (let i = 0; i < instance.measureState.points.length; i++) {
863            const p = instance.measureState.points[i];
864            const d = (p[0] - clickedCoord[0]) ** 2 + (p[1] - clickedCoord[1]) ** 2;
865            if (d < closestDist) {
866                closestDist = d;
867                closestIdx = i;
868            }
869        }
870
871        instance.measureState.draggingIndex = closestIdx;
872        map.getCanvas().style.cursor = 'grabbing';
873        map.dragPan.disable();
874
875        e.preventDefault();
876    };
877
878    const onMouseMove = (e) => {
879        if (!instance.measureState || instance.measureState.draggingIndex === -1) return;
880        instance.measureState.points[instance.measureState.draggingIndex] = [e.lngLat.lng, e.lngLat.lat];
881        updateMeasureDisplay(mapId);
882    };
883
884    const onMouseUp = () => {
885        if (!instance.measureState || instance.measureState.draggingIndex === -1) return;
886        instance.measureState.draggingIndex = -1;
887        map.getCanvas().style.cursor = '';
888        map.dragPan.enable();
889
890        const measurement = calculateMeasurement(instance.measureState);
891        instance.dotnetRef.invokeMethodAsync('OnMeasurementComplete', measurement);
892    };
893
894    map.on('mouseenter', 'measure-points-layer', onMouseEnter);
895    map.on('mouseleave', 'measure-points-layer', onMouseLeave);
896    map.on('mousedown', 'measure-points-layer', onMouseDown);
897    map.on('mousemove', onMouseMove);
898    map.on('mouseup', onMouseUp);
899
900    // Store handlers for cleanup
901    instance._measureDragHandlers = { onMouseEnter, onMouseLeave, onMouseDown, onMouseMove, onMouseUp };
902}
903
904function updateMeasureDisplay(mapId, mousePoint) {
905    const instance = instances.get(mapId);
906    const { map, measureState } = instance;
907    const points = [...measureState.points];
908    if (mousePoint) points.push(mousePoint);
909
910    map.getSource('measure-points').setData({
911        type: 'FeatureCollection',
912        features: points.map((p, i) => ({
913            type: 'Feature',
914            geometry: { type: 'Point', coordinates: p },
915            properties: { index: i }
916        }))
917    });
918
919    if (points.length >= 2) {
920        const lineCoords = measureState.mode === 'area' && points.length >= 3
921            ? [...points, points[0]]
922            : points;
923
924        map.getSource('measure-lines').setData({
925            type: 'FeatureCollection',
926            features: [{
927                type: 'Feature',
928                geometry: { type: 'LineString', coordinates: lineCoords }
929            }]
930        });
931
932        if (measureState.mode === 'area' && points.length >= 3 && map.getSource('measure-fill')) {
933            map.getSource('measure-fill').setData({
934                type: 'FeatureCollection',
935                features: [{
936                    type: 'Feature',
937                    geometry: { type: 'Polygon', coordinates: [lineCoords] }
938                }]
939            });
940        }
941
942        const measurement = calculateMeasurement({ ...measureState, points });
943        const midPoint = points[Math.floor(points.length / 2)];
944        map.getSource('measure-labels').setData({
945            type: 'FeatureCollection',
946            features: [{
947                type: 'Feature',
948                geometry: { type: 'Point', coordinates: midPoint },
949                properties: { label: measurement.displayText }
950            }]
951        });
952    }
953}
954
955function calculateMeasurement(state) {
956    const { mode, points } = state;
957
958    if (mode === 'distance' && points.length >= 2) {
959        const line = turf.lineString(points);
960        const length = turf.length(line, { units: 'kilometers' });
961        return {
962            mode: 'distance',
963            value: length,
964            unit: 'km',
965            displayText: length < 1
966                ? `${(length * 1000).toFixed(0)} m`
967                : `${length.toFixed(2)} km`
968        };
969    } else if (mode === 'area' && points.length >= 3) {
970        const polygon = turf.polygon([[...points, points[0]]]);
971        const area = turf.area(polygon);
972        return {
973            mode: 'area',
974            value: area,
975            unit: 'm\u00B2',
976            displayText: area < 1e6
977                ? `${area.toFixed(0)} m\u00B2`
978                : `${(area / 1e6).toFixed(2)} km\u00B2`
979        };
980    }
981
982    return { mode, value: 0, unit: '', displayText: '' };
983}
984
985export function disableMeasureMode(mapId) {
986    const instance = instances.get(mapId);
987    if (!instance) return;
988
989    if (instance._measureClickHandler) {
990        instance.map.off('click', instance._measureClickHandler);
991        instance._measureClickHandler = null;
992    }
993    if (instance._measureMoveHandler) {
994        instance.map.off('mousemove', instance._measureMoveHandler);
995        instance._measureMoveHandler = null;
996    }
997    if (instance._measureDblClickHandler) {
998        instance.map.off('dblclick', instance._measureDblClickHandler);
999        instance._measureDblClickHandler = null;
1000    }
1001
1002    // Clean up drag handlers
1003    if (instance._measureDragHandlers) {
1004        const dh = instance._measureDragHandlers;
1005        if (instance.map.getLayer('measure-points-layer')) {
1006            instance.map.off('mouseenter', 'measure-points-layer', dh.onMouseEnter);
1007            instance.map.off('mouseleave', 'measure-points-layer', dh.onMouseLeave);
1008            instance.map.off('mousedown', 'measure-points-layer', dh.onMouseDown);
1009        }
1010        instance.map.off('mousemove', dh.onMouseMove);
1011        instance.map.off('mouseup', dh.onMouseUp);
1012        instance._measureDragHandlers = null;
1013    }
1014
1015    instance.map.doubleClickZoom.enable();
1016    instance.map.dragPan.enable();
1017    instance.map.getCanvas().style.cursor = '';
1018    if (instance.measureState) instance.measureState.active = false;
1019}
1020
1021export function clearMeasurements(mapId) {
1022    const instance = instances.get(mapId);
1023    if (!instance) return;
1024
1025    disableMeasureMode(mapId);
1026
1027    for (const layerId of instance.measureLayers) {
1028        if (instance.map.getLayer(layerId)) instance.map.removeLayer(layerId);
1029    }
1030    for (const sourceId of instance.measureSources) {
1031        if (instance.map.getSource(sourceId)) instance.map.removeSource(sourceId);
1032    }
1033
1034    instance.measureLayers = [];
1035    instance.measureSources = [];
1036    instance.measureState = null;
1037}
1038
1039// ===== DRAWING =====
1040
1041function _ensureDrawLayers(instance) {
1042    const { map } = instance;
1043    if (map.getSource('draw-fills')) return; // already created
1044
1045    const empty = { type: 'FeatureCollection', features: [] };
1046    map.addSource('draw-fills', { type: 'geojson', data: empty });
1047    map.addSource('draw-lines', { type: 'geojson', data: empty });
1048    map.addSource('draw-points', { type: 'geojson', data: empty });
1049    map.addSource('draw-labels', { type: 'geojson', data: empty });
1050    map.addSource('draw-preview', { type: 'geojson', data: empty });
1051
1052    map.addLayer({
1053        id: 'draw-fills-layer', type: 'fill', source: 'draw-fills',
1054        paint: { 'fill-color': ['get', 'color'], 'fill-opacity': ['coalesce', ['get', 'fillOpacity'], 0.2] }
1055    });
1056    map.addLayer({
1057        id: 'draw-lines-layer', type: 'line', source: 'draw-lines',
1058        layout: { 'line-cap': 'round', 'line-join': 'round' },
1059        paint: { 'line-color': ['get', 'color'], 'line-width': ['coalesce', ['get', 'lineWidth'], 3] }
1060    });
1061    map.addLayer({
1062        id: 'draw-points-layer', type: 'circle', source: 'draw-points',
1063        paint: {
1064            'circle-radius': ['coalesce', ['get', 'lineWidth'], 6],
1065            'circle-color': ['get', 'color'],
1066            'circle-stroke-width': 2,
1067            'circle-stroke-color': '#ffffff'
1068        }
1069    });
1070    map.addLayer({
1071        id: 'draw-labels-layer', type: 'symbol', source: 'draw-labels',
1072        layout: {
1073            'text-field': ['get', 'label'],
1074            'text-size': 13,
1075            'text-offset': [0, 1.5],
1076            'text-anchor': 'top',
1077            'text-allow-overlap': true
1078        },
1079        paint: {
1080            'text-color': ['get', 'color'],
1081            'text-halo-color': '#000000',
1082            'text-halo-width': 1.5
1083        }
1084    });
1085    // Preview layer for in-progress drawing
1086    map.addLayer({
1087        id: 'draw-preview-line', type: 'line', source: 'draw-preview',
1088        layout: { 'line-cap': 'round', 'line-join': 'round' },
1089        paint: { 'line-color': ['get', 'color'], 'line-width': ['coalesce', ['get', 'lineWidth'], 3], 'line-dasharray': [2, 2] }
1090    });
1091    map.addLayer({
1092        id: 'draw-preview-fill', type: 'fill', source: 'draw-preview',
1093        paint: { 'fill-color': ['get', 'color'], 'fill-opacity': 0.1 },
1094        filter: ['==', ['geometry-type'], 'Polygon']
1095    });
1096}
1097
1098function _updateDrawDisplay(instance) {
1099    const { map, drawFeatures } = instance;
1100    if (!map.getSource('draw-fills')) return;
1101
1102    const points = [], lines = [], fills = [], labels = [];
1103
1104    for (const f of drawFeatures) {
1105        const gt = f.geometry?.type;
1106        if (gt === 'Point') points.push(f);
1107        else if (gt === 'LineString') lines.push(f);
1108        else if (gt === 'Polygon') {
1109            fills.push(f);
1110            // Also add polygon border as a line
1111            lines.push({ type: 'Feature', geometry: { type: 'LineString', coordinates: f.geometry.coordinates[0] }, properties: f.properties });
1112        }
1113
1114        if (f.properties?.label) {
1115            const centroid = _featureCentroid(f);
1116            if (centroid) {
1117                labels.push({ type: 'Feature', geometry: { type: 'Point', coordinates: centroid }, properties: { label: f.properties.label, color: f.properties.color || '#ffffff' } });
1118            }
1119        }
1120    }
1121
1122    map.getSource('draw-points').setData({ type: 'FeatureCollection', features: points });
1123    map.getSource('draw-lines').setData({ type: 'FeatureCollection', features: lines });
1124    map.getSource('draw-fills').setData({ type: 'FeatureCollection', features: fills });
1125    map.getSource('draw-labels').setData({ type: 'FeatureCollection', features: labels });
1126}
1127
1128function _featureCentroid(f) {
1129    const gt = f.geometry?.type;
1130    if (gt === 'Point') return f.geometry.coordinates;
1131    if (gt === 'LineString') {
1132        const coords = f.geometry.coordinates;
1133        const mid = coords[Math.floor(coords.length / 2)];
1134        return mid;
1135    }
1136    if (gt === 'Polygon') {
1137        try { return turf.centroid(f).geometry.coordinates; } catch { return f.geometry.coordinates[0][0]; }
1138    }
1139    return null;
1140}
1141
1142function _clearDrawPreview(instance) {
1143    const { map } = instance;
1144    if (map.getSource('draw-preview')) {
1145        map.getSource('draw-preview').setData({ type: 'FeatureCollection', features: [] });
1146    }
1147}
1148
1149export function enableDrawMode(mapId, mode, options) {
1150    const instance = instances.get(mapId);
1151    if (!instance) return;
1152
1153    // Mutual exclusivity with measurement
1154    disableMeasureMode(mapId);
1155    // Cancel any previous draw mode
1156    disableDrawMode(mapId);
1157
1158    _ensureDrawLayers(instance);
1159
1160    const opts = {
1161        color: options?.color || '#e53935',
1162        lineWidth: options?.lineWidth || 3,
1163        label: options?.label || null
1164    };
1165
1166    instance.drawState = { mode, active: true, currentPoints: [], options: opts };
1167
1168    const { map } = instance;
1169    map.getCanvas().style.cursor = 'crosshair';
1170    map.doubleClickZoom.disable();
1171
1172    // Escape key to finish or cancel the current drawing
1173    instance._drawKeyHandler = (e) => {
1174        if (e.key === 'Escape') {
1175            e.preventDefault();
1176            finishDrawing(mapId);
1177        }
1178    };
1179    document.addEventListener('keydown', instance._drawKeyHandler);
1180
1181    if (mode === 'marker') {
1182        instance._drawClickHandler = (e) => _onDrawMarkerClick(mapId, e);
1183        map.on('click', instance._drawClickHandler);
1184    } else if (mode === 'line' || mode === 'polygon') {
1185        instance._drawClickHandler = (e) => _onDrawLinePolyClick(mapId, e);
1186        instance._drawMoveHandler = (e) => _onDrawLinePolyMove(mapId, e);
1187        instance._drawDblClickHandler = (e) => _onDrawLinePolyDblClick(mapId, e);
1188        map.on('click', instance._drawClickHandler);
1189        map.on('mousemove', instance._drawMoveHandler);
1190        map.on('dblclick', instance._drawDblClickHandler);
1191    } else if (mode === 'circle') {
1192        instance._drawClickHandler = (e) => _onDrawCircleClick(mapId, e);
1193        instance._drawMoveHandler = (e) => _onDrawCircleMove(mapId, e);
1194        map.on('click', instance._drawClickHandler);
1195        map.on('mousemove', instance._drawMoveHandler);
1196    } else if (mode === 'freehand') {
1197        instance._drawMouseDownHandler = (e) => _onDrawFreehandDown(mapId, e);
1198        instance._drawMoveHandler = (e) => _onDrawFreehandMove(mapId, e);
1199        instance._drawMouseUpHandler = (e) => _onDrawFreehandUp(mapId, e);
1200        map.on('mousedown', instance._drawMouseDownHandler);
1201        map.on('mousemove', instance._drawMoveHandler);
1202        map.on('mouseup', instance._drawMouseUpHandler);
1203    }
1204}
1205
1206export function updateDrawOptions(mapId, options) {
1207    const instance = instances.get(mapId);
1208    if (!instance?.drawState) return;
1209    if (options.color != null) instance.drawState.options.color = options.color;
1210    if (options.lineWidth != null) instance.drawState.options.lineWidth = options.lineWidth;
1211    if (options.label !== undefined) instance.drawState.options.label = options.label || null;
1212}
1213
1214export function disableDrawMode(mapId) {
1215    const instance = instances.get(mapId);
1216    if (!instance || !instance.drawState) return;
1217
1218    const { map } = instance;
1219    if (instance._drawClickHandler) { map.off('click', instance._drawClickHandler); instance._drawClickHandler = null; }
1220    if (instance._drawMoveHandler) { map.off('mousemove', instance._drawMoveHandler); instance._drawMoveHandler = null; }
1221    if (instance._drawDblClickHandler) { map.off('dblclick', instance._drawDblClickHandler); instance._drawDblClickHandler = null; }
1222    if (instance._drawMouseDownHandler) { map.off('mousedown', instance._drawMouseDownHandler); instance._drawMouseDownHandler = null; }
1223    if (instance._drawMouseUpHandler) { map.off('mouseup', instance._drawMouseUpHandler); instance._drawMouseUpHandler = null; }
1224    if (instance._drawKeyHandler) { document.removeEventListener('keydown', instance._drawKeyHandler); instance._drawKeyHandler = null; }
1225
1226    map.getCanvas().style.cursor = '';
1227    map.doubleClickZoom.enable();
1228    map.dragPan.enable();
1229    _clearDrawPreview(instance);
1230    instance.drawState = null;
1231}
1232
1233export function finishDrawing(mapId) {
1234    const instance = instances.get(mapId);
1235    if (!instance?.drawState) return;
1236
1237    const { currentPoints, options, mode } = instance.drawState;
1238
1239    if ((mode === 'line' || mode === 'polygon') && currentPoints.length >= 2) {
1240        let feature;
1241        if (mode === 'polygon' && currentPoints.length >= 3) {
1242            feature = {
1243                type: 'Feature',
1244                geometry: { type: 'Polygon', coordinates: [[...currentPoints, currentPoints[0]]] },
1245                properties: { drawType: 'polygon', color: options.color, lineWidth: options.lineWidth, fillOpacity: 0.2, label: options.label || null }
1246            };
1247        } else {
1248            feature = {
1249                type: 'Feature',
1250                geometry: { type: 'LineString', coordinates: currentPoints },
1251                properties: { drawType: 'line', color: options.color, lineWidth: options.lineWidth, label: options.label || null }
1252            };
1253        }
1254        instance.drawFeatures.push(feature);
1255        instance.drawState.currentPoints = [];
1256        _clearDrawPreview(instance);
1257        _updateDrawDisplay(instance);
1258        _notifyDrawingUpdated(instance, feature.properties.drawType);
1259    } else {
1260        // Not enough points — just cancel preview
1261        instance.drawState.currentPoints = [];
1262        _clearDrawPreview(instance);
1263    }
1264}
1265
1266// --- Marker ---
1267function _onDrawMarkerClick(mapId, e) {
1268    const instance = instances.get(mapId);
1269    if (!instance?.drawState) return;
1270    const { options } = instance.drawState;
1271
1272    const feature = {
1273        type: 'Feature',
1274        geometry: { type: 'Point', coordinates: [e.lngLat.lng, e.lngLat.lat] },
1275        properties: { drawType: 'marker', color: options.color, lineWidth: Math.max(options.lineWidth + 3, 6), label: options.label || null }
1276    };
1277    instance.drawFeatures.push(feature);
1278    _updateDrawDisplay(instance);
1279    _notifyDrawingUpdated(instance, 'marker');
1280}
1281
1282// --- Line / Polygon ---
1283function _onDrawLinePolyClick(mapId, e) {
1284    const instance = instances.get(mapId);
1285    if (!instance?.drawState) return;
1286    instance.drawState.currentPoints.push([e.lngLat.lng, e.lngLat.lat]);
1287}
1288
1289function _onDrawLinePolyMove(mapId, e) {
1290    const instance = instances.get(mapId);
1291    if (!instance?.drawState || instance.drawState.currentPoints.length === 0) return;
1292    const { currentPoints, options, mode } = instance.drawState;
1293    const mousePoint = [e.lngLat.lng, e.lngLat.lat];
1294    const allPts = [...currentPoints, mousePoint];
1295
1296    const features = [];
1297    if (mode === 'polygon' && allPts.length >= 3) {
1298        features.push({
1299            type: 'Feature',
1300            geometry: { type: 'Polygon', coordinates: [[...allPts, allPts[0]]] },
1301            properties: { color: options.color, lineWidth: options.lineWidth }
1302        });
1303    } else {
1304        features.push({
1305            type: 'Feature',
1306            geometry: { type: 'LineString', coordinates: allPts },
1307            properties: { color: options.color, lineWidth: options.lineWidth }
1308        });
1309    }
1310
1311    instance.map.getSource('draw-preview').setData({ type: 'FeatureCollection', features });
1312}
1313
1314function _onDrawLinePolyDblClick(mapId, e) {
1315    const instance = instances.get(mapId);
1316    if (!instance?.drawState) return;
1317    e.preventDefault();
1318
1319    const { currentPoints, options, mode } = instance.drawState;
1320    if (currentPoints.length < 2) return;
1321
1322    let feature;
1323    if (mode === 'polygon' && currentPoints.length >= 3) {
1324        feature = {
1325            type: 'Feature',
1326            geometry: { type: 'Polygon', coordinates: [[...currentPoints, currentPoints[0]]] },
1327            properties: { drawType: 'polygon', color: options.color, lineWidth: options.lineWidth, fillOpacity: 0.2, label: options.label || null }
1328        };
1329    } else {
1330        feature = {
1331            type: 'Feature',
1332            geometry: { type: 'LineString', coordinates: currentPoints },
1333            properties: { drawType: 'line', color: options.color, lineWidth: options.lineWidth, label: options.label || null }
1334        };
1335    }
1336
1337    instance.drawFeatures.push(feature);
1338    instance.drawState.currentPoints = [];
1339    _clearDrawPreview(instance);
1340    _updateDrawDisplay(instance);
1341    _notifyDrawingUpdated(instance, feature.properties.drawType);
1342}
1343
1344// --- Circle ---
1345function _onDrawCircleClick(mapId, e) {
1346    const instance = instances.get(mapId);
1347    if (!instance?.drawState) return;
1348    const { drawState } = instance;
1349
1350    if (drawState.currentPoints.length === 0) {
1351        // First click = center
1352        drawState.currentPoints.push([e.lngLat.lng, e.lngLat.lat]);
1353    } else {
1354        // Second click = finalize
1355        const center = drawState.currentPoints[0];
1356        const edge = [e.lngLat.lng, e.lngLat.lat];
1357        const radius = turf.distance(turf.point(center), turf.point(edge), { units: 'meters' });
1358        if (radius < 1) return;
1359
1360        const circleGeom = turf.circle(center, radius, { units: 'meters', steps: 64 });
1361        const feature = {
1362            type: 'Feature',
1363            geometry: circleGeom.geometry,
1364            properties: {
1365                drawType: 'circle', color: drawState.options.color, lineWidth: drawState.options.lineWidth,
1366                fillOpacity: 0.2, circleCenter: center, circleRadius: radius,
1367                label: drawState.options.label || null
1368            }
1369        };
1370
1371        instance.drawFeatures.push(feature);
1372        drawState.currentPoints = [];
1373        _clearDrawPreview(instance);
1374        _updateDrawDisplay(instance);
1375        _notifyDrawingUpdated(instance, 'circle');
1376    }
1377}
1378
1379function _onDrawCircleMove(mapId, e) {
1380    const instance = instances.get(mapId);
1381    if (!instance?.drawState || instance.drawState.currentPoints.length === 0) return;
1382
1383    const center = instance.drawState.currentPoints[0];
1384    const edge = [e.lngLat.lng, e.lngLat.lat];
1385    const radius = turf.distance(turf.point(center), turf.point(edge), { units: 'meters' });
1386    if (radius < 1) return;
1387
1388    try {
1389        const circleGeom = turf.circle(center, radius, { units: 'meters', steps: 64 });
1390        const feature = {
1391            type: 'Feature',
1392            geometry: circleGeom.geometry,
1393            properties: { color: instance.drawState.options.color, lineWidth: instance.drawState.options.lineWidth }
1394        };
1395        instance.map.getSource('draw-preview').setData({ type: 'FeatureCollection', features: [feature] });
1396    } catch { /* ignore turf errors during preview */ }
1397}
1398
1399// --- Freehand ---
1400function _onDrawFreehandDown(mapId, e) {
1401    const instance = instances.get(mapId);
1402    if (!instance?.drawState) return;
1403    // Only left button
1404    if (e.originalEvent.button !== 0) return;
1405
1406    instance.drawState.active = true;
1407    instance.drawState.currentPoints = [[e.lngLat.lng, e.lngLat.lat]];
1408    instance.map.dragPan.disable();
1409}
1410
1411function _onDrawFreehandMove(mapId, e) {
1412    const instance = instances.get(mapId);
1413    if (!instance?.drawState?.active || instance.drawState.currentPoints.length === 0) return;
1414
1415    instance.drawState.currentPoints.push([e.lngLat.lng, e.lngLat.lat]);
1416    const pts = instance.drawState.currentPoints;
1417
1418    if (pts.length >= 2) {
1419        const feature = {
1420            type: 'Feature',
1421            geometry: { type: 'LineString', coordinates: pts },
1422            properties: { color: instance.drawState.options.color, lineWidth: instance.drawState.options.lineWidth }
1423        };
1424        instance.map.getSource('draw-preview').setData({ type: 'FeatureCollection', features: [feature] });
1425    }
1426}
1427
1428function _onDrawFreehandUp(mapId, e) {
1429    const instance = instances.get(mapId);
1430    if (!instance?.drawState?.active) return;
1431
1432    instance.map.dragPan.enable();
1433    const pts = instance.drawState.currentPoints;
1434    instance.drawState.active = false;
1435
1436    if (pts.length < 2) { instance.drawState.currentPoints = []; _clearDrawPreview(instance); return; }
1437
1438    const { options } = instance.drawState;
1439    const feature = {
1440        type: 'Feature',
1441        geometry: { type: 'LineString', coordinates: pts },
1442        properties: { drawType: 'freehand', color: options.color, lineWidth: options.lineWidth, label: options.label || null }
1443    };
1444
1445    instance.drawFeatures.push(feature);
1446    instance.drawState.currentPoints = [];
1447    _clearDrawPreview(instance);
1448    _updateDrawDisplay(instance);
1449    _notifyDrawingUpdated(instance, 'freehand');
1450}
1451
1452// --- Utility ---
1453function _notifyDrawingUpdated(instance, drawType) {
1454    if (instance.dotnetRef) {
1455        instance.dotnetRef.invokeMethodAsync('OnDrawingUpdated', {
1456            drawType,
1457            featureCount: instance.drawFeatures.length
1458        });
1459    }
1460}
1461
1462export function deleteDrawing(mapId, index) {
1463    const instance = instances.get(mapId);
1464    if (!instance) return;
1465    if (index >= 0 && index < instance.drawFeatures.length) {
1466        const [removed] = instance.drawFeatures.splice(index, 1);
1467        _updateDrawDisplay(instance);
1468        _notifyDrawingUpdated(instance, removed?.properties?.drawType || 'unknown');
1469    }
1470}
1471
1472export function clearDrawings(mapId) {
1473    const instance = instances.get(mapId);
1474    if (!instance) return;
1475    instance.drawFeatures = [];
1476    _updateDrawDisplay(instance);
1477    _notifyDrawingUpdated(instance, 'clear');
1478}
1479
1480export function exportDrawings(mapId) {
1481    const instance = instances.get(mapId);
1482    if (!instance || !instance.drawFeatures || instance.drawFeatures.length === 0) return null;
1483    return JSON.stringify({ type: 'FeatureCollection', features: instance.drawFeatures });
1484}
1485
1486export function importDrawings(mapId, geojsonString) {
1487    const instance = instances.get(mapId);
1488    if (!instance) { console.warn('[Draw] importDrawings: no instance for', mapId); return; }
1489    try {
1490        const fc = JSON.parse(geojsonString);
1491        if (fc?.type !== 'FeatureCollection' || !Array.isArray(fc.features)) { console.warn('[Draw] importDrawings: invalid FeatureCollection'); return; }
1492
1493        // Filter to valid features only
1494        const valid = fc.features.filter(f =>
1495            f?.type === 'Feature' && f.geometry?.type && f.geometry?.coordinates &&
1496            ['Point', 'LineString', 'Polygon'].includes(f.geometry.type)
1497        );
1498        if (valid.length === 0) { console.warn('[Draw] importDrawings: no valid features'); return; }
1499
1500        // Merge with defaults for missing properties
1501        for (const f of valid) {
1502            f.properties = f.properties || {};
1503            f.properties.color = f.properties.color || '#e53935';
1504            f.properties.lineWidth = f.properties.lineWidth || 3;
1505            f.properties.drawType = f.properties.drawType || 'unknown';
1506        }
1507
1508        instance.drawFeatures = valid;
1509        _ensureDrawLayers(instance);
1510        _updateDrawDisplay(instance);
1511        _notifyDrawingUpdated(instance, 'import');
1512        console.log(`[Draw] Imported ${valid.length} drawings`);
1513    } catch (err) { console.warn('[Draw] importDrawings error:', err); }
1514}
1515
1516export function getDrawingCount(mapId) {
1517    const instance = instances.get(mapId);
1518    return instance?.drawFeatures?.length || 0;
1519}
1520
1521// ===== SEARCH (MapTiler via backend + coordinate parsing) =====
1522
1523export async function searchLocation(query) {
1524    if (!query || query.length < 2) return [];
1525
1526    // Try parsing as coordinates first
1527    const coordResult = _tryParseCoordinates(query);
1528    if (coordResult) return [coordResult];
1529
1530    // Fall back to MapTiler geocoding via backend
1531    const response = await fetch(`/api/tiles/search/${encodeURIComponent(query)}.json`);
1532    if (!response.ok) return [];
1533
1534    const data = await response.json();
1535    if (!data.features || data.features.length === 0) return [];
1536
1537    return data.features.slice(0, 5).map(f => ({
1538        displayName: f.place_name || f.text || '',
1539        lat: f.center[1],
1540        lng: f.center[0],
1541        boundingBox: f.bbox ? [f.bbox[1], f.bbox[3], f.bbox[0], f.bbox[2]] : null,
1542        type: f.place_type?.[0] || 'place',
1543        featureId: f.id || null
1544    }));
1545}
1546
1547function _tryParseCoordinates(query) {
1548    const q = query.trim();
1549
1550    // Try DEC: "48.123, 30.456" or "48.123 30.456" or "-48.123, -30.456"
1551    const decMatch = q.match(/^([+-]?\d+\.?\d*)[,\s]+([+-]?\d+\.?\d*)$/);
1552    if (decMatch) {
1553        const lat = parseFloat(decMatch[1]);
1554        const lng = parseFloat(decMatch[2]);
1555        if (lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) {
1556            return {
1557                displayName: `${lat.toFixed(6)}, ${lng.toFixed(6)}`,
1558                lat, lng, boundingBox: null, type: 'coordinate'
1559            };
1560        }
1561    }
1562
1563    // Try DEC with hemisphere: "44.599157°N 38.032557°E" (° optional, either axis order)
1564    const decHemiMatch = q.match(
1565        /^(\d+(?:\.\d+)?)\s*°?\s*([NSEW])[,\s]+(\d+(?:\.\d+)?)\s*°?\s*([NSEW])$/i
1566    );
1567    if (decHemiMatch) {
1568        const parsed = _assignHemisphere(
1569            parseFloat(decHemiMatch[1]), decHemiMatch[2].toUpperCase(),
1570            parseFloat(decHemiMatch[3]), decHemiMatch[4].toUpperCase()
1571        );
1572        if (parsed && parsed.lat >= -90 && parsed.lat <= 90 && parsed.lng >= -180 && parsed.lng <= 180) {
1573            return {
1574                displayName: `${parsed.lat.toFixed(6)}, ${parsed.lng.toFixed(6)}`,
1575                lat: parsed.lat, lng: parsed.lng, boundingBox: null, type: 'coordinate'
1576            };
1577        }
1578    }
1579
1580    // Try DMS: 48°07'24.4"N 30°07'24.4"E (various separators)
1581    const dmsMatch = q.match(
1582        /(\d+)[°]\s*(\d+)[′']\s*([\d.]+)[″"]\s*([NSns])[,\s]+(\d+)[°]\s*(\d+)[′']\s*([\d.]+)[″"]\s*([EWew])/
1583    );
1584    if (dmsMatch) {
1585        let lat = parseInt(dmsMatch[1]) + parseInt(dmsMatch[2]) / 60 + parseFloat(dmsMatch[3]) / 3600;
1586        let lng = parseInt(dmsMatch[5]) + parseInt(dmsMatch[6]) / 60 + parseFloat(dmsMatch[7]) / 3600;
1587        if (dmsMatch[4].toUpperCase() === 'S') lat = -lat;
1588        if (dmsMatch[8].toUpperCase() === 'W') lng = -lng;
1589        if (lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) {
1590            return {
1591                displayName: `${_formatDMS(lat, lng)}`,
1592                lat, lng, boundingBox: null, type: 'coordinate'
1593            };
1594        }
1595    }
1596
1597    // Try MGRS: "36T UL 12345 67890" or "36TUL1234567890"
1598    const mgrsMatch = q.match(
1599        /^(\d{1,2}
1599)\s*([C-HJ-NP-X])\s*([A-HJ-NP-Z])([A-HJ-NP-V])\s*(\d+)(?:\s+(\d+))?$/i
1600    );
1601    // The digits are two space-separated groups of equal length, or one even-length run
1602    // split in half ("7629694933" -> "76296" / "94933"); 1-5 digits per side
1603    let mgrsDigits = null;
1604    if (mgrsMatch) {
1605        const run = mgrsMatch[5];
1606        if (mgrsMatch[6] !== undefined) {
1607            if (run.length === mgrsMatch[6].length) mgrsDigits = [run, mgrsMatch[6]];
1608        } else if (run.length % 2 === 0) {
1609            mgrsDigits = [run.slice(0, run.length / 2), run.slice(run.length / 2)];
1610        }
1611        if (mgrsDigits && mgrsDigits[0].length > 5) mgrsDigits = null;
1612    }
1613    if (mgrsDigits) {
1614        const result = _mgrsToLatLng(
1615            parseInt(mgrsMatch[1]),
1616            mgrsMatch[2].toUpperCase(),
1617            mgrsMatch[3].toUpperCase(),
1618            mgrsMatch[4].toUpperCase(),
1619            mgrsDigits[0],
1620            mgrsDigits[1]
1621        );
1622        if (result && result.lat >= -90 && result.lat <= 90) {
1623            return {
1624                displayName: `MGRS ${q.toUpperCase()}`,
1625                lat: result.lat, lng: result.lng,
1626                boundingBox: null, type: 'coordinate'
1627            };
1628        }
1629    }
1630
1631    // Try ITM: "219530, 626907" — two numbers where easting is ~100k-300k and northing is ~350k-850k
1632    const itmMatch = q.match(/^(\d{5,6})[,\s]+(\d{5,7})$/);
1633    if (itmMatch) {
1634        const e = parseInt(itmMatch[1]);
1635        const n = parseInt(itmMatch[2]);
1636        // ITM easting range roughly 100000-300000, northing 350000-850000 (Mount Hermon is ~813500)
1637        if (e >= 100000 && e <= 300000 && n >= 350000 && n <= 850000) {
1638            const result = _itmToLatLng(e, n);
1639            if (result) {
1640                return {
1641                    displayName: `ITM ${e}, ${n}`,
1642                    lat: result.lat, lng: result.lng,
1643                    boundingBox: null, type: 'coordinate'
1644                };
1645            }
1646        }
1647    }
1648
1649    return null;
1650}
1651
1652// Assigns two magnitude/hemisphere tokens to lat/lng by their N/S vs E/W letter,
1653// so "44.6°N 38.0°E" and "38.0°E 44.6°N" both parse. Returns null if both tokens
1654// are the same axis (e.g. "N … N").
1655function _assignHemisphere(v1, h1, v2, h2) {
1656    const isLat = (h) => h === 'N' || h === 'S';
1657    const isLng = (h) => h === 'E' || h === 'W';
1658    if (isLat(h1) && isLng(h2)) {
1659        return { lat: h1 === 'S' ? -v1 : v1, lng: h2 === 'W' ? -v2 : v2 };
1660    }
1661    if (isLng(h1) && isLat(h2)) {
1662        return { lat: h2 === 'S' ? -v2 : v2, lng: h1 === 'W' ? -v1 : v1 };
1663    }
1664    return null;
1665}
1666
1667function _mgrsToLatLng(zone, band, colLetter, rowLetter, eastingStr, northingStr) {
1668    // UTM zones are 1-60 (CoordinateParser.TryParseMgrs rejects the rest too)
1669    if (zone < 1 || zone > 60) return null;
1670
1671    // Scale easting/northing up to metres (the SW corner of the cell at this precision)
1672    const precision = eastingStr.length;
1673    const easting = parseInt(eastingStr) * Math.pow(10, 5 - precision);
1674    const northing = parseInt(northingStr) * Math.pow(10, 5 - precision);
1675
1676    const colLetters = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
1677    const rowLetters = 'ABCDEFGHJKLMNPQRSTUV';
1678
1679    const setParm = _mgrsSetParams(zone);
1680    const colIdx = colLetters.indexOf(colLetter);
1681    const rowIdx = rowLetters.indexOf(rowLetter);
1682
1683    // Reverse the column offset to get the 100km column; a zone uses only 8 of the 24
1684    // column letters, so a letter from another zone's set is not a real square
1685    const col = ((colIdx - setParm.colOffset + 24) % 24) + 1;
1686    if (col > 8) return null;
1687    const utmEasting = col * 100000 + easting;
1688
1689    // Reverse the row offset to get the row index
1690    const row = (rowIdx - setParm.rowOffset + 20) % 20;
1691
1692    // Lowest northing of each latitude band C..X (GEOTRANS band table); southern bands
1693    // include the 10,000,000 m false northing
1694    const bandLetters = 'CDEFGHJKLMNPQRSTUVWX';
1695    const bandMinNorthing = [
1696        1100000, 2000000, 2800000, 3700000, 4600000, 5500000, 6400000, 7300000, 8200000, 9100000,
1697        0, 800000, 1700000, 2600000, 3500000, 4400000, 5300000, 6200000, 7000000, 7900000
1698    ];
1699    const bandIdx = bandLetters.indexOf(band);
1700    if (bandIdx < 0) return null;
1701
1702    // Row letters repeat every 2,000 km: add whole cycles until the band's minimum is reached
1703    let utmNorthing = row * 100000 + northing;
1704    while (utmNorthing < bandMinNorthing[bandIdx]) utmNorthing += 2000000;
1705
1706    // Convert UTM to lat/lng (bands C..M are southern; _utmToLatLng removes the false northing)
1707    const result = _utmToLatLng(utmEasting, utmNorthing, zone, band >= 'N');
1708
1709    // Zone 1 and 60 cells can straddle the antimeridian
1710    if (result.lng < -180) result.lng += 360;
1711    else if (result.lng > 180) result.lng -= 360;
1712    return result;
1713}
1714
1715function _utmToLatLng(easting, northing, zone, northern) {
1716    const a = 6378137;
1717    const f = 1 / 298.257223563;
1718    const e2 = 2 * f - f * f;
1719    const e1 = (1 - Math.sqrt(1 - e2)) / (1 + Math.sqrt(1 - e2));
1720    const ep2 = e2 / (1 - e2);
1721    const k0 = 0.9996;
1722
1723    const x = easting - 500000;
1724    const y = northern ? northing : northing - 10000000;
1725
1726    const M = y / k0;
1727    const mu = M / (a * (1 - e2 / 4 - 3 * e2 * e2 / 64 - 5 * e2 * e2 * e2 / 256));
1728
1729    const phi1 = mu + (3 * e1 / 2 - 27 * e1 * e1 * e1 / 32) * Math.sin(2 * mu)
1730        + (21 * e1 * e1 / 16 - 55 * e1 * e1 * e1 * e1 / 32) * Math.sin(4 * mu)
1731        + (151 * e1 * e1 * e1 / 96) * Math.sin(6 * mu)
1732        + (1097 * e1 * e1 * e1 * e1 / 512) * Math.sin(8 * mu);
1733
1734    const sinPhi1 = Math.sin(phi1);
1735    const cosPhi1 = Math.cos(phi1);
1736    const tanPhi1 = Math.tan(phi1);
1737    const N1 = a / Math.sqrt(1 - e2 * sinPhi1 * sinPhi1);
1738    const T1 = tanPhi1 * tanPhi1;
1739    const C1 = ep2 * cosPhi1 * cosPhi1;
1740    const R1 = a * (1 - e2) / Math.pow(1 - e2 * sinPhi1 * sinPhi1, 1.5);
1741    const D = x / (N1 * k0);
1742
1743    const lat = phi1 - (N1 * tanPhi1 / R1) * (
1744        D * D / 2
1745        - (5 + 3 * T1 + 10 * C1 - 4 * C1 * C1 - 9 * ep2) * D * D * D * D / 24
1746        + (61 + 90 * T1 + 298 * C1 + 45 * T1 * T1 - 252 * ep2 - 3 * C1 * C1) * D * D * D * D * D * D / 720
1747    );
1748
1749    const lng = (D - (1 + 2 * T1 + C1) * D * D * D / 6
1750        + (5 - 2 * C1 + 28 * T1 - 3 * C1 * C1 + 8 * ep2 + 24 * T1 * T1) * D * D * D * D * D / 120
1751    ) / cosPhi1;
1752
1753    const lonOrigin = ((zone - 1) * 6 - 180 + 3) * Math.PI / 180;
1754    return {
1755        lat: lat * 180 / Math.PI,
1756        lng: (lonOrigin + lng) * 180 / Math.PI
1757    };
1758}
1759
1760function _itmToLatLng(easting, northing) {
1761    // GRS80 ellipsoid
1762    const a = 6378137;
1763    const f = 1 / 298.257222101;
1764    const e2 = 2 * f - f * f;
1765    const e1 = (1 - Math.sqrt(1 - e2)) / (1 + Math.sqrt(1 - e2));
1766    const ep2 = e2 / (1 - e2);
1767
1768    // ITM projection parameters
1769    const k0 = 1.0000067;
1770    const latOrigin = 31.7343936111111 * Math.PI / 180;
1771    const lonOrigin = 35.2045169444444 * Math.PI / 180;
1772    const falseEasting = 219529.584;
1773    const falseNorthing = 626907.390;
1774
1775    const x = easting - falseEasting;
1776    const y = northing - falseNorthing;
1777
1778    // The false northing is measured from the latitude of origin, not the equator
1779    const M = _meridionalArc(a, e2, latOrigin) + y / k0;
1780    const mu = M / (a * (1 - e2 / 4 - 3 * e2 * e2 / 64 - 5 * e2 * e2 * e2 / 256));
1781
1782    const phi1 = mu + (3 * e1 / 2 - 27 * e1 * e1 * e1 / 32) * Math.sin(2 * mu)
1783        + (21 * e1 * e1 / 16 - 55 * e1 * e1 * e1 * e1 / 32) * Math.sin(4 * mu)
1784        + (151 * e1 * e1 * e1 / 96) * Math.sin(6 * mu)
1785        + (1097 * e1 * e1 * e1 * e1 / 512) * Math.sin(8 * mu);
1786
1787    const sinPhi1 = Math.sin(phi1);
1788    const cosPhi1 = Math.cos(phi1);
1789    const tanPhi1 = Math.tan(phi1);
1790    const N1 = a / Math.sqrt(1 - e2 * sinPhi1 * sinPhi1);
1791    const T1 = tanPhi1 * tanPhi1;
1792    const C1 = ep2 * cosPhi1 * cosPhi1;
1793    const R1 = a * (1 - e2) / Math.pow(1 - e2 * sinPhi1 * sinPhi1, 1.5);
1794    const D = x / (N1 * k0);
1795
1796    const lat = phi1 - (N1 * tanPhi1 / R1) * (
1797        D * D / 2
1798        - (5 + 3 * T1 + 10 * C1 - 4 * C1 * C1 - 9 * ep2) * D * D * D * D / 24
1799        + (61 + 90 * T1 + 298 * C1 + 45 * T1 * T1 - 252 * ep2 - 3 * C1 * C1) * D * D * D * D * D * D / 720
1800    );
1801
1802    const lng = (D - (1 + 2 * T1 + C1) * D * D * D / 6
1803        + (5 - 2 * C1 + 28 * T1 - 3 * C1 * C1 + 8 * ep2 + 24 * T1 * T1) * D * D * D * D * D / 120
1804    ) / cosPhi1;
1805
1806    // That point is on the Israel 1993 datum: shift it to WGS84
1807    const wgs84 = _israel1993ToWgs84(lat, lonOrigin + lng);
1808    return {
1809        lat: wgs84.lat * 180 / Math.PI,
1810        lng: wgs84.lng * 180 / Math.PI
1811    };
1812}
1813
1814// ===== CLUSTERED PLACEMARK SOURCE =====
1815
1816export function addClusteredSource(mapId, sourceId, geojson, options) {
1817    const { map, dotnetRef } = instances.get(mapId);
1818
1819    map.addSource(sourceId, {
1820        type: 'geojson',
1821        data: geojson,
1822        cluster: true,
1823        clusterMaxZoom: options?.clusterMaxZoom || 14,
1824        clusterRadius: options?.clusterRadius || 50,
1825        clusterProperties: options?.clusterProperties || {},
1826        maxzoom: options?.maxZoom || 20,
1827        buffer: 128,
1828        tolerance: 0.375
1829    });
1830
1831    // Cluster circles
1832    map.addLayer({
1833        id: `${sourceId}-clusters`,
1834        type: 'circle',
1835        source: sourceId,
1836        filter: ['has', 'point_count'],
1837        paint: {
1838            'circle-color': [
1839                'step', ['get', 'point_count'],
1840                '#51bbd6', 100,
1841                '#f1f075', 750,
1842                '#f28cb1'
1843            ],
1844            'circle-radius': [
1845                'step', ['get', 'point_count'],
1846                20, 100, 30, 750, 40
1847            ],
1848            'circle-stroke-width': 2,
1849            'circle-stroke-color': '#ffffff'
1850        }
1851    });
1852
1853    // Cluster count labels
1854    map.addLayer({
1855        id: `${sourceId}-cluster-count`,
1856        type: 'symbol',
1857        source: sourceId,
1858        filter: ['has', 'point_count'],
1859        layout: {
1860            'text-field': '{point_count_abbreviated}',
1861            'text-size': 12,
1862            'text-font': ['Open Sans Regular']
1863        },
1864        paint: {
1865            'text-color': '#000000'
1866        }
1867    });
1868
1869    // Individual points
1870    map.addLayer({
1871        id: `${sourceId}-unclustered`,
1872        type: 'circle',
1873        source: sourceId,
1874        filter: ['!', ['has', 'point_count']],
1875        paint: {
1876            'circle-color': '#11b4da',
1877            'circle-radius': 6,
1878            'circle-stroke-width': 1,
1879            'circle-stroke-color': '#ffffff'
1880        }
1881    });
1882
1883    // Click cluster to zoom in
1884    map.on('click', `${sourceId}-clusters`, async (e) => {
1885        const features = map.queryRenderedFeatures(e.point, { layers: [`${sourceId}-clusters`] });
1886        if (!features.length) return;
1887        const clusterId = features[0].properties.cluster_id;
1888        const zoom = await map.getSource(sourceId).getClusterExpansionZoom(clusterId);
1889        map.flyTo({ center: features[0].geometry.coordinates, zoom });
1890    });
1891
1892    // Click individual point
1893    map.on('click', `${sourceId}-unclustered`, (e) => {
1894        if (!e.features?.length) return;
1895        const feature = e.features[0];
1896        dotnetRef.invokeMethodAsync('OnFeatureClicked', JSON.stringify({
1897            id: feature.id,
1898            properties: feature.properties,
1899            coordinates: feature.geometry.coordinates
1900        }));
1901    });
1902
1903    // Cursor styling
1904    map.on('mouseenter', `${sourceId}-clusters`, () => { map.getCanvas().style.cursor = 'pointer'; });
1905    map.on('mouseleave', `${sourceId}-clusters`, () => { map.getCanvas().style.cursor = ''; });
1906    map.on('mouseenter', `${sourceId}-unclustered`, () => { map.getCanvas().style.cursor = 'pointer'; });
1907    map.on('mouseleave', `${sourceId}-unclustered`, () => { map.getCanvas().style.cursor = ''; });
1908}
1909
1910// ===== MOUSE COORDINATES =====
1911
1912export function enableMouseCoordinates(mapId, controlId) {
1913    const { map } = instances.get(mapId);
1914    const container = document.getElementById(controlId);
1915    if (!container) return;
1916
1917    const decEl = container.querySelector('[data-coord-value="dec"]');
1918    const dmsEl = container.querySelector('[data-coord-value="dms"]');
1919    const mgrsEl = container.querySelector('[data-coord-value="mgrs"]');
1920    const itmEl = container.querySelector('[data-coord-value="itm"]');
1921    const decRow = container.querySelector('[data-coord="dec"]');
1922    const dmsRow = container.querySelector('[data-coord="dms"]');
1923    const mgrsRow = container.querySelector('[data-coord="mgrs"]');
1924    const itmRow = container.querySelector('[data-coord="itm"]');
1925    const placeholder = container.querySelector('[data-coord="placeholder"]');
1926
1927    let shown = false;
1928
1929    map.on('mousemove', (e) => {
1930        const lat = e.lngLat.lat;
1931        const lng = e.lngLat.lng;
1932
1933        if (!shown) {
1934            decRow.style.display = '';
1935            dmsRow.style.display = '';
1936            mgrsRow.style.display = '';
1937            itmRow.style.display = '';
1938            placeholder.style.display = 'none';
1939            shown = true;
1940        }
1941
1942        decEl.textContent = lat.toFixed(6) + ', ' + lng.toFixed(6);
1943        dmsEl.textContent = _formatDMS(lat, lng);
1944        mgrsEl.textContent = _latLngToMGRS(lat, lng);
1945        itmEl.textContent = _latLngToITM(lat, lng);
1946    });
1947}
1948
1949// ===== FEATURE STATS CONTROL =====
1950
1951export function initStatsControl(mapId, controlId) {
1952    const instance = instances.get(mapId);
1953    if (!instance) return;
1954    const { map } = instance;
1955    const container = document.getElementById(controlId);
1956    if (!container) return;
1957
1958    const totalEl = container.querySelector('[data-stat="total"]');
1959    const visibleEl = container.querySelector('[data-stat="visible"]');
1960    const latestEl = container.querySelector('[data-stat="latest"]');
1961
1962    function update() {
1963        const allFeatures = instance.allFeatures;
1964        if (!allFeatures || allFeatures.length === 0) {
1965            totalEl.textContent = '0';
1966            visibleEl.textContent = '0';
1967            latestEl.textContent = '—';
1968            return;
1969        }
1970
1971        totalEl.textContent = allFeatures.length.toLocaleString();
1972
1973        // Count visible features by querying rendered features on placemark layers.
1974        // Filter to layers that actually exist in the current style — after a setStyle
1975        // (basemap switch) the cached IDs survive but the layers are wiped until
1976        // _restoreOverlays runs on 'idle'.
1977        const cachedIds = instance.placemarkLayerIds;
1978        const layerIds = cachedIds ? cachedIds.filter(id => map.getLayer(id)) : null;
1979        if (layerIds && layerIds.length) {
1980            const rendered = map.queryRenderedFeatures({ layers: layerIds });
1981            // Deduplicate by feature id (tiles can repeat features)
1982            const uniqueIds = new Set();
1983            for (const f of rendered) {
1984                uniqueIds.add(f.properties.id);
1985            }
1986            visibleEl.textContent = uniqueIds.size.toLocaleString();
1987        } else {
1988            visibleEl.textContent = '0';
1989        }
1990
1991        // Find latest date
1992        if (!instance._latestDate) {
1993            let maxDate = '';
1994            for (const f of allFeatures) {
1995                const d = f.properties.date;
1996                if (d && d > maxDate) maxDate = d;
1997            }
1998            instance._latestDate = maxDate;
1999        }
2000        if (instance._latestDate) {
2001            const d = new Date(instance._latestDate);
2002            latestEl.textContent = d.toLocaleDateString('en-GB', {
2003                day: '2-digit', month: 'short', year: 'numeric'
2004            });
2005        } else {
2006            latestEl.textContent = '—';
2007        }
2008    }
2009
2010    // Store the update function so it can be called from filter changes
2011    instance._statsUpdate = update;
2012
2013    // Update on viewport change
2014    map.on('moveend', update);
2015    // Update when source data finishes loading
2016    map.on('sourcedata', (e) => {
2017        if (e.sourceId === 'placemarks-all' && e.isSourceLoaded) update();
2018    });
2019
2020    // Initial update
2021    update();
2022}
2023
2024function _showCopyToast(container, text, mouseEvent) {
2025    const toast = document.createElement('div');
2026    toast.textContent = text;
2027    Object.assign(toast.style, {
2028        position: 'absolute',
2029        left: mouseEvent.offsetX + 'px',
2030        top: mouseEvent.offsetY + 'px',
2031        transform: 'translate(-50%, -120%)',
2032        background: 'rgba(0,0,0,0.8)',
2033        color: '#fff',
2034        padding: '4px 10px',
2035        borderRadius: '4px',
2036        fontSize: '12px',
2037        fontFamily: 'monospace',
2038        whiteSpace: 'nowrap',
2039        pointerEvents: 'none',
2040        zIndex: '9999',
2041        opacity: '1',
2042        transition: 'opacity 0.4s ease, transform 0.4s ease'
2043    });
2044    container.appendChild(toast);
2045    setTimeout(() => {
2046        toast.style.opacity = '0';
2047        toast.style.transform = 'translate(-50%, -180%)';
2048    }, 800);
2049    setTimeout(() => toast.remove(), 1300);
2050}
2051
2052function _formatDMS(lat, lng) {
2053    const fmt = (val, pos, neg) => {
2054        const dir = val >= 0 ? pos : neg;
2055        val = Math.abs(val);
2056        const d = Math.floor(val);
2057        const mFull = (val - d) * 60;
2058        const m = Math.floor(mFull);
2059        const s = ((mFull - m) * 60).toFixed(1);
2060        return d + '\u00B0' + m + '\'' + s + '"' + dir;
2061    };
2062    return fmt(lat, 'N', 'S') + ' ' + fmt(lng, 'E', 'W');
2063}
2064
2065function _latLngToMGRS(lat, lng) {
2066    if (lat < -80 || lat > 84) return 'Outside MGRS';
2067
2068    const a = 6378137;
2069    const f = 1 / 298.257223563;
2070    const e = Math.sqrt(2 * f - f * f);
2071    const e2 = e * e;
2072    const ep2 = e2 / (1 - e2);
2073    const k0 = 0.9996;
2074
2075    // Map events report longitudes past ±180 over repeated world copies
2076    if (lng < -180 || lng >= 180) lng = ((lng + 180) % 360 + 360) % 360 - 180;
2077
2078    let zone = Math.floor((lng + 180) / 6) + 1;
2079    // Norway (32V) and Svalbard (31X/33X/35X/37X) zone exceptions
2080    if (lat >= 56 && lat < 64 && lng >= 3 && lng < 12) zone = 32;
2081    if (lat >= 72 && lng >= 0 && lng < 42) zone = lng < 9 ? 31 : lng < 21 ? 33 : lng < 33 ? 35 : 37;
2082    const lonOrigin = (zone - 1) * 6 - 180 + 3;
2083    const latRad = lat * Math.PI / 180;
2084    const lngRad = lng * Math.PI / 180;
2085    const lonOriginRad = lonOrigin * Math.PI / 180;
2086
2087    const N = a / Math.sqrt(1 - e2 * Math.sin(latRad) * Math.sin(latRad));
2088    const T = Math.tan(latRad) * Math.tan(latRad);
2089    const C = ep2 * Math.cos(latRad) * Math.cos(latRad);
2090    const A = Math.cos(latRad) * (lngRad - lonOriginRad);
2091
2092    const e4 = e2 * e2;
2093    const e6 = e4 * e2;
2094    const M = a * ((1 - e2 / 4 - 3 * e4 / 64 - 5 * e6 / 256) * latRad
2095        - (3 * e2 / 8 + 3 * e4 / 32 + 45 * e6 / 1024) * Math.sin(2 * latRad)
2096        + (15 * e4 / 256 + 45 * e6 / 1024) * Math.sin(4 * latRad)
2097        - (35 * e6 / 3072) * Math.sin(6 * latRad));
2098
2099    let easting = k0 * N * (A + (1 - T + C) * A * A * A / 6
2100        + (5 - 18 * T + T * T + 72 * C - 58 * ep2) * A * A * A * A * A / 120) + 500000;
2101
2102    let northing = k0 * (M + N * Math.tan(latRad) * (A * A / 2
2103        + (5 - T + 9 * C + 4 * C * C) * A * A * A * A / 24
2104        + (61 - 58 * T + T * T + 600 * C - 330 * ep2) * A * A * A * A * A * A / 720));
2105
2106    if (lat < 0) northing += 10000000;
2107
2108    const bandLetters = 'CDEFGHJKLMNPQRSTUVWX';
2109    const bandIdx = Math.min(Math.floor((lat + 80) / 8), 19);
2110    const band = bandLetters[bandIdx];
2111
2112    const setParm = _mgrsSetParams(zone);
2113    const col = Math.floor(easting / 100000);
2114    const row = Math.floor(northing / 100000) % 20;
2115
2116    const colLetters = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
2117    const rowLetters = 'ABCDEFGHJKLMNPQRSTUV';
2118    const colLetter = colLetters[(col - 1 + setParm.colOffset) % 24];
2119    const rowLetter = rowLetters[(row + setParm.rowOffset) % 20];
2120
2121    // MGRS truncates to the 1 m cell (rounding could print "100000" in the wrong square)
2122    const e5 = (Math.floor(easting) % 100000).toString().padStart(5, '0');
2123    const n5 = (Math.floor(northing) % 100000).toString().padStart(5, '0');
2124
2125    return zone.toString() + band + ' ' + colLetter + rowLetter + ' ' + e5 + ' ' + n5;
2126}
2127
2128// 100 km square letters (AA scheme): columns start at A, J, S for zone % 3 = 1, 2, 0;
2129// rows start at A in odd zones and F in even zones
2130function _mgrsSetParams(zone) {
2131    return {
2132        colOffset: [16, 0, 8][zone % 3],
2133        rowOffset: zone % 2 === 0 ? 5 : 0
2134    };
2135}
2136
2137// ===== ISRAELI TRANSVERSE MERCATOR (EPSG:2039) =====
2138
2139function _latLngToITM(lat, lng) {
2140    // GRS80 ellipsoid
2141    const a = 6378137;
2142    const f = 1 / 298.257222101;
2143    const e2 = 2 * f - f * f;
2144    const ep2 = e2 / (1 - e2);
2145
2146    // ITM projection parameters
2147    const k0 = 1.0000067;
2148    const latOrigin = 31.7343936111111 * Math.PI / 180;
2149    const lonOrigin = 35.2045169444444 * Math.PI / 180;
2150    const falseEasting = 219529.584;
2151    const falseNorthing = 626907.390;
2152
2153    // ITM is on the Israel 1993 datum: shift the WGS84 point onto it before projecting
2154    const isr93 = _wgs84ToIsrael1993(lat * Math.PI / 180, lng * Math.PI / 180);
2155    const latRad = isr93.lat;
2156    const lngRad = isr93.lng;
2157
2158    const sinLat = Math.sin(latRad);
2159    const cosLat = Math.cos(latRad);
2160    const tanLat = Math.tan(latRad);
2161
2162    const N = a / Math.sqrt(1 - e2 * sinLat * sinLat);
2163    const T = tanLat * tanLat;
2164    const C = ep2 * cosLat * cosLat;
2165    const A = cosLat * (lngRad - lonOrigin);
2166
2167    // Meridian distance from the latitude of origin (where the false northing applies)
2168    const M = _meridionalArc(a, e2, latRad) - _meridionalArc(a, e2, latOrigin);
2169
2170    const easting = falseEasting + k0 * N * (A + (1 - T + C) * A * A * A / 6
2171        + (5 - 18 * T + T * T + 72 * C - 58 * ep2) * A * A * A * A * A / 120);
2172
2173    const northing = falseNorthing + k0 * (M + N * tanLat * (A * A / 2
2174        + (5 - T + 9 * C + 4 * C * C) * A * A * A * A / 24
2175        + (61 - 58 * T + T * T + 600 * C - 330 * ep2) * A * A * A * A * A * A / 720));
2176
2177    return Math.round(easting) + ', ' + Math.round(northing);
2178}
2179
2180// Meridian arc length (m) from the equator to latitude phi (radians)
2181function _meridionalArc(a, e2, phi) {
2182    const e4 = e2 * e2;
2183    const e6 = e4 * e2;
2184    return a * ((1 - e2 / 4 - 3 * e4 / 64 - 5 * e6 / 256) * phi
2185        - (3 * e2 / 8 + 3 * e4 / 32 + 45 * e6 / 1024) * Math.sin(2 * phi)
2186        + (15 * e4 / 256 + 45 * e6 / 1024) * Math.sin(4 * phi)
2187        - (35 * e6 / 3072) * Math.sin(6 * phi));
2188}
2189
2190// ITM grid coordinates are on the Israel 1993 datum (GRS80), not WGS84. PROJ's default
2191// WGS84 <-> EPSG:2039 operation, 'Israel 1993 to WGS 84 (2)' (0.5 m), is a 7-parameter
2192// Helmert on earth-centred XYZ; without it every ITM point is ~78 m off. Both directions
2193// take and return radians and treat the height as 0, as PROJ's 2D pipeline does.
2194// CoordinateParser.Israel1993ToWgs84 is the C# twin of the first.
2195function _israel1993ToWgs84(lat, lng) {
2196    const [x, y, z] = _geodeticToEcef(lat, lng, 6378137, 1 / 298.257222101);
2197    return _ecefToGeodetic(..._israel1993Helmert(x, y, z, true), 6378137, 1 / 298.257223563);
2198}
2199
2200function _wgs84ToIsrael1993(lat, lng) {
2201    const [x, y, z] = _geodeticToEcef(lat, lng, 6378137, 1 / 298.257223563);
2202    return _ecefToGeodetic(..._israel1993Helmert(x, y, z, false), 6378137, 1 / 298.257222101);
2203}
2204
2205// X_wgs84 = T + (1 + s) R X_isr93, with R the coordinate-frame rotation in the small-angle
2206// form PROJ builds; the reverse is R^T (X_wgs84 - T) / (1 + s), as PROJ computes it
2207function _israel1993Helmert(x, y, z, toWgs84) {
2208    const tx = 23.772, ty = 17.49, tz = 17.859;
2209    const arcSec = Math.PI / 648000;
2210    const rx = 0.3132 * arcSec, ry = 1.85274 * arcSec, rz = -1.67299 * arcSec;
2211    const scale = 1 - 5.4262e-6;
2212
2213    if (toWgs84) {
2214        return [
2215            tx + scale * (x + rz * y - ry * z),
2216            ty + scale * (-rz * x + y + rx * z),
2217            tz + scale * (ry * x - rx * y + z)
2218        ];
2219    }
2220    const dx = x - tx, dy = y - ty, dz = z - tz;
2221    return [
2222        (dx - rz * dy + ry * dz) / scale,
2223        (rz * dx + dy - rx * dz) / scale,
2224        (-ry * dx + rx * dy + dz) / scale
2225    ];
2226}
2227
2228// Geodetic latitude/longitude (radians) at height 0 to earth-centred XYZ (m)
2229function _geodeticToEcef(lat, lng, a, f) {
2230    const e2 = 2 * f - f * f;
2231    const sinLat = Math.sin(lat);
2232    const N = a / Math.sqrt(1 - e2 * sinLat * sinLat);
2233    return [
2234        N * Math.cos(lat) * Math.cos(lng),
2235        N * Math.cos(lat) * Math.sin(lng),
2236        N * (1 - e2) * sinLat
2237    ];
2238}
2239
2240// Earth-centred XYZ (m) to geodetic latitude/longitude (radians), dropping the height.
2241// Iterates tan(lat) = (Z + e2 N sin(lat)) / p, which gains over two digits a pass
2242function _ecefToGeodetic(x, y, z, a, f) {
2243    const e2 = 2 * f - f * f;
2244    const p = Math.sqrt(x * x + y * y);
2245    let lat = Math.atan2(z, p * (1 - e2));
2246    for (let i = 0; i < 10; i++) {
2247        const sinLat = Math.sin(lat);
2248        const N = a / Math.sqrt(1 - e2 * sinLat * sinLat);
2249        const next = Math.atan2(z + e2 * N * sinLat, p);
2250        const delta = Math.abs(next - lat);
2251        lat = next;
2252        if (delta < 1e-14) break;
2253    }
2254    return { lat, lng: Math.atan2(y, x) };
2255}
2256
2257// ===== PLACEMARK LOADING =====
2258
2259async function _loadImagesBatched(map, iconUrls, batchSize = 50) {
2260    // Filter out already-loaded images upfront
2261    const urls = [...iconUrls].filter(url => !map.hasImage(url));
2262    let loaded = 0, failed = 0;
2263    for (let i = 0; i < urls.length; i += batchSize) {
2264        const batch = urls.slice(i, i + batchSize);
2265        const results = await Promise.allSettled(
2266            batch.map(async (url) => {
2267                const resp = await fetch(url);
2268                if (!resp.ok) throw new Error(`${resp.status} ${url}`);
2269                const blob = await resp.blob();
2270                const bmp = await createImageBitmap(blob);
2271                // Guard against race conditions (another batch may have added it)
2272                if (!map.hasImage(url)) {
2273                    map.addImage(url, bmp);
2274                }
2275                loaded++;
2276            })
2277        );
2278        failed += results.filter(r => r.status === 'rejected').length;
2279    }
2280    return { loaded, failed };
2281}
2282
2283export async function loadPlacemarks(mapId, url, sourceId, displayName) {
2284    const instance = instances.get(mapId);
2285    const { map, dotnetRef } = instance;
2286
2287    const response = await fetch(url);
2288    if (!response.ok) throw new Error(`Failed to load placemarks: ${response.status}`);
2289
2290    const placemarks = await response.json();
2291
2292    // Collect unique icon URLs and build GeoJSON in one pass
2293    const iconUrls = new Set();
2294    const features = new Array(placemarks.length);
2295    for (let i = 0; i < placemarks.length; i++) {
2296        const p = placemarks[i];
2297        if (p.icon) iconUrls.add(p.icon);
2298        features[i] = {
2299            type: 'Feature',
2300            geometry: {
2301                type: 'Point',
2302                coordinates: [p.lo, p.la]
2303            },
2304            properties: {
2305                id: p.id,
2306                date: p.date,
2307                icon: p.icon || ''
2308            }
2309        };
2310    }
2311
2312    await _loadImagesBatched(map, iconUrls);
2313
2314    const geojson = { type: 'FeatureCollection', features };
2315
2316    map.addSource(sourceId, {
2317        type: 'geojson',
2318        data: geojson,
2319        buffer: 128,
2320        tolerance: 0.375
2321    });
2322
2323    const layerId = `${sourceId}-points`;
2324    map.addLayer({
2325        id: layerId,
2326        type: 'symbol',
2327        source: sourceId,
2328        layout: {
2329            'icon-image': ['get', 'icon'],
2330            'icon-size': 0.5,
2331            'icon-allow-overlap': true
2332        }
2333    });
2334
2335    if (displayName) {
2336        instance.layerDisplayNames[layerId] = displayName;
2337    }
2338    if (color) {
2339        instance.layerColors[layerId] = color;
2340    }
2341
2342    // Click handler
2343    map.on('click', `${sourceId}-points`, (e) => {
2344        if (!e.features?.length) return;
2345        const feature = e.features[0];
2346        dotnetRef.invokeMethodAsync('OnFeatureClicked', JSON.stringify({
2347            id: feature.id,
2348            properties: feature.properties,
2349            coordinates: feature.geometry.coordinates
2350        }));
2351    });
2352
2353    // Cursor styling
2354    map.on('mouseenter', `${sourceId}-points`, () => { map.getCanvas().style.cursor = 'pointer'; });
2355    map.on('mouseleave', `${sourceId}-points`, () => { map.getCanvas().style.cursor = ''; });
2356
2357    return placemarks.length;
2358}
2359
2360export async function loadPlacemarksFromData(mapId, placemarks, sourceId, displayName, color) {
2361    const instance = instances.get(mapId);
2362    const { map, dotnetRef } = instance;
2363
2364    const iconUrls = new Set();
2365    const features = new Array(placemarks.length);
2366    for (let i = 0; i < placemarks.length; i++) {
2367        const p = placemarks[i];
2368        if (p.icon) iconUrls.add(p.icon);
2369        const properties = {
2370            id: p.id,
2371            date: p.date,
2372            icon: p.icon || ''
2373        };
2374        // Per-feature color (e.g. faction color) takes precedence over the uniform
2375        // `color` argument below, so a single list of placemarks can render multi-colored
2376        // dots at low zoom while still sharing one source / layer.
2377        if (p.color) properties.color = p.color;
2378        // Materialized ancestor path(s) of the owning unit(s), e.g. "/1/17/204/". Lets a caller
2379        // dim/highlight a whole subtree with one paint expression instead of shipping thousands
2380        // of feature ids across the interop boundary on every selection.
2381        if (p.p) properties.p = p.p;
2382        features[i] = {
2383            type: 'Feature',
2384            geometry: {
2385                type: 'Point',
2386                coordinates: [p.lo, p.la]
2387            },
2388            properties
2389        };
2390    }
2391
2392    const { loaded, failed } = await _loadImagesBatched(map, iconUrls);
2393
2394    const geojson = { type: 'FeatureCollection', features };
2395    instance.originalSourceData[sourceId] = geojson;
2396
2397    // Called again with a different set of placemarks — e.g. the ORBAT card's mini-map when you
2398    // pick another unit. addSource would throw "already exists", so swap the data underneath the
2399    // layers that are already on the map and leave everything else standing.
2400    const existing = map.getSource(sourceId);
2401    if (existing) {
2402        existing.setData(geojson);
2403        return placemarks.length;
2404    }
2405
2406    map.addSource(sourceId, {
2407        type: 'geojson',
2408        data: geojson,
2409        buffer: 128,
2410        tolerance: 0.375
2411    });
2412
2413    const circlesId = `${sourceId}-circles`;
2414    const pointsId = `${sourceId}-points`;
2415    const { iconZoomThreshold } = instance;
2416
2417    // Circle layer for low zoom — lightweight colored dots. Prefer the per-feature
2418    // `color` property (e.g. faction color from the shared cache) and fall back to
2419    // the uniform `color` argument, then to the default blue.
2420    const circleColor = ['case', ['has', 'color'], ['get', 'color'], color || '#51bbd6'];
2421    map.addLayer({
2422        id: circlesId,
2423        type: 'circle',
2424        source: sourceId,
2425        maxzoom: iconZoomThreshold,
2426        paint: {
2427            'circle-radius': 4,
2428            'circle-color': circleColor,
2429            'circle-stroke-width': 1,
2430            'circle-stroke-color': '#ffffff'
2431        }
2432    });
2433
2434    // Symbol layer for high zoom — actual icons
2435    map.addLayer({
2436        id: pointsId,
2437        type: 'symbol',
2438        source: sourceId,
2439        minzoom: iconZoomThreshold,
2440        layout: {
2441            'icon-image': ['get', 'icon'],
2442            'icon-size': 0.5,
2443            'icon-allow-overlap': true
2444        }
2445    });
2446
2447    // Track placemark layer IDs for efficient date filtering
2448    if (!instance.placemarkLayerIds) instance.placemarkLayerIds = [];
2449    instance.placemarkLayerIds.push(circlesId, pointsId);
2450
2451    // Register display names and colors for both layers so layer control toggles them together
2452    if (displayName) {
2453        instance.layerDisplayNames[circlesId] = displayName;
2454        instance.layerDisplayNames[pointsId] = displayName;
2455    }
2456    if (color) {
2457        instance.layerColors[circlesId] = color;
2458        instance.layerColors[pointsId] = color;
2459    }
2460
2461    // Click handler for both layers (guard against duplicate fires when layers overlap)
2462    const onClick = (e) => {
2463        if (!e.features?.length) return;
2464        if (e.originalEvent._placemarkHandled) return;
2465        if (_mentionUnderCursor(instance, e)) return;
2466        e.originalEvent._placemarkHandled = true;
2467        const feature = e.features[0];
2468        dotnetRef.invokeMethodAsync('OnFeatureClicked', JSON.stringify({
2469            id: feature.id,
2470            properties: feature.properties,
2471            coordinates: feature.geometry.coordinates
2472        }));
2473    };
2474    map.on('click', circlesId, onClick);
2475    map.on('click', pointsId, onClick);
2476
2477    // Cursor styling
2478    map.on('mouseenter', circlesId, () => { map.getCanvas().style.cursor = 'pointer'; });
2479    map.on('mouseleave', circlesId, () => { map.getCanvas().style.cursor = ''; });
2480    map.on('mouseenter', pointsId, () => { map.getCanvas().style.cursor = 'pointer'; });
2481    map.on('mouseleave', pointsId, () => { map.getCanvas().style.cursor = ''; });
2482
2483    return placemarks.length;
2484}
2485
2486export function loadAllFactions(mapId, factionLayers) {
2487    const instance = instances.get(mapId);
2488    const { map, dotnetRef } = instance;
2489
2490    const allIconUrls = new Set();
2491    const { iconZoomThreshold } = instance;
2492
2493    // Merge all factions into a single source so date-based sort-key works across factions
2494    const allFeatures = [];
2495    const factionMeta = new Map(); // factionId → { color, displayName, visible }
2496
2497    for (const layer of factionLayers) {
2498        const factionId = layer.factionId;
2499        const color = layer.color || '#51bbd6';
2500        const displayName = layer.name;
2501
2502        factionMeta.set(factionId, { color, displayName, visible: true });
2503
2504        for (const iconGroup of layer.icons) {
2505            const iconUrl = iconGroup.icon || '';
2506            if (iconUrl) allIconUrls.add(iconUrl);
2507
2508            for (const p of iconGroup.placemarks) {
2509                const dateSort = p.ds || (p.date ? Math.floor(new Date(p.date).getTime() / 86400000) : 0);
2510                const props = {
2511                    id: p.id,
2512                    date: p.date,
2513                    icon: iconUrl,
2514                    dateSort,
2515                    factionId,
2516                    color
2517                };
2518                if (p.isApproved === false) props.unapproved = 1;
2519                allFeatures.push({
2520                    type: 'Feature',
2521                    geometry: {
2522                        type: 'Point',
2523                        coordinates: [p.lo, p.la]
2524                    },
2525                    properties: props
2526                });
2527            }
2528        }
2529    }
2530
2531    const sourceId = 'placemarks-all';
2532    const geojson = { type: 'FeatureCollection', features: allFeatures };
2533
2534    instance.allFeatures = allFeatures;
2535    instance.factionMeta = factionMeta;
2536    // activeIdFilter is deliberately left alone: a query filter that landed while this
2537    // conflict's data was still loading applies to it (see filterSourceByIds).
2538    instance.factionFilter = null;    // MapLibre filter for hidden factions
2539    instance.dateFilter = null;       // MapLibre filter for date range
2540    instance.staticFilter = _readPersistedStaticFilter();
2541    instance.originalSourceData[sourceId] = geojson;
2542
2543    map.addSource(sourceId, {
2544        type: 'geojson',
2545        data: { type: 'FeatureCollection', features: _filteredPlacemarkFeatures(instance) },
2546        buffer: 128,
2547        tolerance: 0.375
2548    });
2549
2550    const circlesId = `${sourceId}-circles`;
2551    const pointsId = `${sourceId}-points`;
2552
2553    const colorExpr = _factionCircleColor(factionMeta);
2554
2555    // Unapproved placemarks render at reduced opacity
2556    const opacityExpr = ['case', ['==', ['get', 'unapproved'], 1], 0.4, 1];
2557
2558    // Omit the `filter` property entirely when no filter applies. MapLibre v5's
2559    // style validator iterates every property on the layer object — setting it
2560    // to null or undefined still fails validation with "array expected".
2561    const filterProp = instance.staticFilter ? { filter: instance.staticFilter } : {};
2562
2563    map.addLayer({
2564        id: circlesId,
2565        type: 'circle',
2566        source: sourceId,
2567        ...filterProp,
2568        maxzoom: iconZoomThreshold,
2569        layout: {
2570            'circle-sort-key': ['get', 'dateSort']
2571        },
2572        paint: {
2573            'circle-radius': 4,
2574            'circle-color': colorExpr,
2575            'circle-stroke-width': 1,
2576            'circle-stroke-color': '#ffffff',
2577            'circle-opacity': opacityExpr,
2578            'circle-stroke-opacity': opacityExpr
2579        }
2580    });
2581
2582    map.addLayer({
2583        id: pointsId,
2584        type: 'symbol',
2585        source: sourceId,
2586        ...filterProp,
2587        minzoom: iconZoomThreshold,
2588        layout: {
2589            'icon-image': ['get', 'icon'],
2590            'icon-size': 0.5,
2591            'icon-allow-overlap': true,
2592            'symbol-sort-key': ['get', 'dateSort']
2593        },
2594        paint: {
2595            'icon-opacity': opacityExpr
2596        }
2597    });
2598
2599    instance.placemarkLayerIds = [circlesId, pointsId];
2600    _raiseMentionsLayers(map);
2601
2602    // Click handler for both layers (guard against duplicate fires when layers overlap)
2603    const onClick = (e) => {
2604        if (!e.features?.length) return;
2605        if (e.originalEvent._placemarkHandled) return;
2606        if (_mentionUnderCursor(instance, e)) return;
2607        e.originalEvent._placemarkHandled = true;
2608        const feature = e.features[0];
2609        dotnetRef.invokeMethodAsync('OnFeatureClicked', JSON.stringify({
2610            id: feature.id,
2611            properties: feature.properties,
2612            coordinates: feature.geometry.coordinates
2613        }));
2614    };
2615    map.on('click', circlesId, onClick);
2616    map.on('click', pointsId, onClick);
2617
2618    map.on('mouseenter', circlesId, () => { map.getCanvas().style.cursor = 'pointer'; });
2619    map.on('mouseleave', circlesId, () => { map.getCanvas().style.cursor = ''; });
2620    map.on('mouseenter', pointsId, () => { map.getCanvas().style.cursor = 'pointer'; });
2621    map.on('mouseleave', pointsId, () => { map.getCanvas().style.cursor = ''; });
2622
2623    // Phase 2: Load icons in background (fire-and-forget)
2624    _loadImagesBatched(map, allIconUrls).catch(err =>
2625        console.warn('[MapLibre] Background icon loading error:', err)
2626    );
2627}
2628
2629export async function loadAllFactionsFromUrl(mapId, url) {
2630    const response = await fetch(url, { credentials: 'same-origin' });
2631    const data = await response.json();
2632    return _loadAllFactionsFromData(mapId, data);
2633}
2634
2635export function loadAllFactionsFromJson(mapId, json) {
2636    const data = typeof json === 'string' ? JSON.parse(json) : json;
2637    return _loadAllFactionsFromData(mapId, data);
2638}
2639
2640const _utf8Decoder = new TextDecoder('utf-8');
2641
2642export function loadAllFactionsFromBytes(mapId, bytes) {
2643    // bytes arrives as a Uint8Array (Blazor marshals byte[] directly into WASM heap memory,
2644    // skipping JSON-string encoding). Decoding + parsing here is the only JSON work in JS.
2645    const text = _utf8Decoder.decode(bytes);
2646    const data = JSON.parse(text);
2647    return _loadAllFactionsFromData(mapId, data);
2648}
2649
2650// Tiny RPC for the Blazor side: look up a single placemark by id from the parsed
2651// feature collection. Used by the map's deep-link flow ('/{conflict}/{placemarkId}')
2652// so C# never has to walk the full GeoJSON itself.
2653export function getPlacemarkById(mapId, id) {
2654    const instance = instances.get(mapId);
2655    if (!instance || !instance.allFeatures) return null;
2656    for (const f of instance.allFeatures) {
2657        if (f.properties.id === id) {
2658            return {
2659                lon: f.geometry.coordinates[0],
2660                lat: f.geometry.coordinates[1],
2661                date: f.properties.date ?? null
2662            };
2663        }
2664    }
2665    return null;
2666}
2667
2668function _loadAllFactionsFromData(mapId, data) {
2669    const instance = instances.get(mapId);
2670    const { map, dotnetRef } = instance;
2671
2672    const { factionMeta: factionMetaArr, geojson } = data;
2673
2674    // Idempotent: a second load (scope switch, retry after a failed load) replaces the
2675    // previous dataset instead of throwing "source already exists" from addSource, which
2676    // would leave the page believing the old scope is still loaded.
2677    const sourceId = 'placemarks-all';
2678    for (const layerId of [`${sourceId}-points`, `${sourceId}-circles`]) {
2679        if (map.getLayer(layerId)) map.removeLayer(layerId);
2680    }
2681    if (map.getSource(sourceId)) map.removeSource(sourceId);
2682    // The stats overlay caches "Latest" per dataset.
2683    instance._latestDate = null;
2684
2685    const allIconUrls = new Set();
2686    const { iconZoomThreshold } = instance;
2687
2688    // Build faction metadata map. The global GeoJSON also carries the faction's conflict
2689    // (faction ids are globally unique, so the rest of the pipeline is unchanged).
2690    const factionMeta = new Map();
2691    for (const f of factionMetaArr) {
2692        factionMeta.set(f.id, {
2693            color: f.color,
2694            displayName: f.name,
2695            visible: true,
2696            conflict: f.conflict ?? null,
2697            conflictName: f.conflictName ?? null,
2698            conflictUrl: f.conflictUrl ?? null,
2699            conflictCode: f.conflictCode ?? null
2700        });
2701    }
2702
2703    // Collect icon URLs from features
2704    for (const f of geojson.features) {
2705        if (f.properties.icon) allIconUrls.add(f.properties.icon);
2706    }
2707
2708    instance.allFeatures = geojson.features;
2709    instance.factionMeta = factionMeta;
2710    // activeIdFilter is deliberately left alone: a query filter that landed while this
2711    // conflict's data was still loading applies to it (see filterSourceByIds).
2712    instance.factionFilter = null;
2713    instance.dateFilter = null;
2714    instance.staticFilter = _readPersistedStaticFilter();
2715    instance.originalSourceData[sourceId] = geojson;
2716
2717    map.addSource(sourceId, {
2718        type: 'geojson',
2719        data: { type: 'FeatureCollection', features: _filteredPlacemarkFeatures(instance) },
2720        buffer: 128,
2721        tolerance: 0.375
2722    });
2723
2724    const circlesId = `${sourceId}-circles`;
2725    const pointsId = `${sourceId}-points`;
2726
2727    const colorExpr = _factionCircleColor(factionMeta);
2728
2729    const opacityExpr = ['case', ['==', ['get', 'unapproved'], 1], 0.4, 1];
2730
2731    // See note above: omit `filter` entirely when no filter applies.
2732    const filterProp = instance.staticFilter ? { filter: instance.staticFilter } : {};
2733
2734    map.addLayer({
2735        id: circlesId,
2736        type: 'circle',
2737        source: sourceId,
2738        ...filterProp,
2739        maxzoom: iconZoomThreshold,
2740        layout: {
2741            'circle-sort-key': ['get', 'dateSort']
2742        },
2743        paint: {
2744            'circle-radius': 4,
2745            'circle-color': colorExpr,
2746            'circle-stroke-width': 1,
2747            'circle-stroke-color': '#ffffff',
2748            'circle-opacity': opacityExpr,
2749            'circle-stroke-opacity': opacityExpr
2750        }
2751    });
2752
2753    map.addLayer({
2754        id: pointsId,
2755        type: 'symbol',
2756        source: sourceId,
2757        ...filterProp,
2758        minzoom: iconZoomThreshold,
2759        layout: {
2760            'icon-image': ['get', 'icon'],
2761            'icon-size': 0.5,
2762            'icon-allow-overlap': true,
2763            'symbol-sort-key': ['get', 'dateSort']
2764        },
2765        paint: {
2766            'icon-opacity': opacityExpr
2767        }
2768    });
2769
2770    instance.placemarkLayerIds = [circlesId, pointsId];
2771    _raiseMentionsLayers(map);
2772
2773    const onClick = (e) => {
2774        if (!e.features?.length) return;
2775        if (e.originalEvent._placemarkHandled) return;
2776        if (_mentionUnderCursor(instance, e)) return;
2777        e.originalEvent._placemarkHandled = true;
2778        const feature = e.features[0];
2779        dotnetRef.invokeMethodAsync('OnFeatureClicked', JSON.stringify({
2780            id: feature.id,
2781            properties: feature.properties,
2782            coordinates: feature.geometry.coordinates
2783        }));
2784    };
2785    map.on('click', circlesId, onClick);
2786    map.on('click', pointsId, onClick);
2787
2788    map.on('mouseenter', circlesId, () => { map.getCanvas().style.cursor = 'pointer'; });
2789    map.on('mouseleave', circlesId, () => { map.getCanvas().style.cursor = ''; });
2790    map.on('mouseenter', pointsId, () => { map.getCanvas().style.cursor = 'pointer'; });
2791    map.on('mouseleave', pointsId, () => { map.getCanvas().style.cursor = ''; });
2792
2793    _loadImagesBatched(map, allIconUrls).catch(err =>
2794        console.warn('[MapLibre] Background icon loading error:', err)
2795    );
2796
2797    // Nudge the stats overlay. For large GeoJSON sources MapLibre processes the
2798    // features on a worker thread and the 'sourcedata' event with isSourceLoaded=true
2799    // doesn't always reach our handler (timing, dedup, etc.), so the overlay would
2800    // otherwise stay on its placeholder until the user pans the map.
2801    if (instance._statsUpdate) instance._statsUpdate();
2802}
2803
2804// ===== SOURCE FILTERING =====
2805
2806// Placemark features that pass the query-builder filter (Filters panel, bookmark or share-url
2807// restore). This is the data the merged 'placemarks-all' source carries; faction, timeline and
2808// static-object visibility are MapLibre layer filters applied on top by _applyComposedFilter.
2809// The id set lives on the instance rather than only inside the source so that every source
2810// rebuild — a base-style swap (_restoreOverlays) in particular — re-applies it instead of
2811// silently resurrecting the full dataset while the Filters badge still says a filter is active.
2812function _filteredPlacemarkFeatures(instance) {
2813    const { allFeatures, activeIdFilter } = instance;
2814    if (!allFeatures) return [];
2815    if (!activeIdFilter) return allFeatures;
2816    return allFeatures.filter(f => activeIdFilter.has(f.properties.id));
2817}
2818
2819// Diagnostic used by the UI tests: the tracked filter result vs. what the merged source really
2820// holds, plus whether the raster base is in place (false while a vector style is active).
2821export async function getPlacemarkSourceState(mapId) {
2822    const instance = instances.get(mapId);
2823    if (!instance) return null;
2824    const { map } = instance;
2825    const source = map.getSource('placemarks-all');
2826    let sourceCount = -1;
2827    if (source) {
2828        const data = await source.getData();
2829        sourceCount = data?.features?.length ?? -1;
2830    }
2831    return {
2832        all: instance.allFeatures ? instance.allFeatures.length : -1,
2833        tracked: instance.allFeatures ? _filteredPlacemarkFeatures(instance).length : -1,
2834        source: sourceCount,
2835        filterActive: !!instance.activeIdFilter,
2836        hasRasterBase: !!map.getSource('base-tiles')
2837    };
2838}
2839
2840export function filterSourceByIds(mapId, sourceId, ids) {
2841    const instance = instances.get(mapId);
2842    const { map } = instance;
2843
2844    // The merged placemarks source: remember the id set, then rebuild from it. The state is
2845    // recorded even while the source is momentarily absent (mid base-style swap, or between a
2846    // conflict's source removal and its reload): whichever path next (re)creates the source
2847    // builds it from _filteredPlacemarkFeatures, so it picks the set up. Only removeSource and
2848    // clearSourceFilter drop it.
2849    if (sourceId === 'placemarks-all') {
2850        instance.activeIdFilter = new Set(ids);
2851        _rebuildMergedSourceData(instance);
2852        return;
2853    }
2854
2855    // Other GeoJSON sources (e.g. the ORBAT mini-map) keep a one-shot filter over their
2856    // original data.
2857    const original = instance.originalSourceData[sourceId];
2858    if (!original) return;
2859    const idSet = new Set(ids);
2860    const filtered = {
2861        type: 'FeatureCollection',
2862        features: original.features.filter(f => idSet.has(f.properties.id))
2863    };
2864    const source = map.getSource(sourceId);
2865    if (source) source.setData(filtered);
2866}
2867
2868export function clearSourceFilter(mapId, sourceId) {
2869    const instance = instances.get(mapId);
2870    const { map } = instance;
2871
2872    if (sourceId === 'placemarks-all') {
2873        instance.activeIdFilter = null;
2874        _rebuildMergedSourceData(instance);
2875        return;
2876    }
2877
2878    const original = instance.originalSourceData[sourceId];
2879    if (!original) return;
2880    const source = map.getSource(sourceId);
2881    if (source) source.setData(original);
2882}
2883
2884// ===== TIMELINE SLIDERS (pure JS — no C# round-trips) =====
2885
2886// Per-conflict sorted, unique dateSort values of the frontline features. A single-conflict load
2887// keeps one index under the key '' (its features carry no `conflict`); the global union keys it by
2888// each feature's `conflict`, so the date-closest line is picked per conflict.
2889function _buildFrontlineDateIndex(instance) {
2890    const byConflict = new Map();
2891    for (const f of instance.frontlineFeatures ?? []) {
2892        const dateSort = f.properties?.dateSort;
2893        if (typeof dateSort !== 'number') continue;
2894        const key = instance.frontlineMultiConflict ? (f.properties.conflict ?? '') : '';
2895        let dates = byConflict.get(key);
2896        if (!dates) byConflict.set(key, dates = new Set());
2897        dates.add(dateSort);
2898    }
2899    instance.frontlineDatesByConflict = new Map(
2900        Array.from(byConflict, ([key, dates]) => [key, Array.from(dates).sort((a, b) => a - b)]));
2901}
2902
2903// Find the largest dateSort value <= target (closest frontline on or before that date). A target
2904// before the first line clamps to the first line, unless `strict`, which returns null instead.
2905function _findClosestFrontlineDate(sortedDates, targetDateSort, strict = false) {
2906    if (!sortedDates || sortedDates.length === 0) return null;
2907    let lo = 0, hi = sortedDates.length - 1, result = -1;
2908    while (lo <= hi) {
2909        const mid = (lo + hi) >> 1;
2910        if (sortedDates[mid] <= targetDateSort) { result = mid; lo = mid + 1; }
2911        else hi = mid - 1;
2912    }
2913    if (result >= 0) return sortedDates[result];
2914    return strict ? null : sortedDates[0];
2915}
2916
2917// Build a MapLibre filter that only shows frontlines matching the closest date(s). The picks go in
2918// instance.frontlineClosest (conflict key → { start, end }) for the colour expression.
2919function _buildFrontlineFilter(instance, startDate, endDate) {
2920    const index = instance.frontlineDatesByConflict;
2921    instance.frontlineClosest = new Map();
2922    if (!index || index.size === 0) {
2923        instance.frontlineDateFilter = null;
2924        return;
2925    }
2926    // Use the UTC calendar day (date part only) to match the UTC-based dateSort on
2927    // the frontline features. A local-time parse shifts the selection a day east of UTC.
2928    const dayUtc = (s) => Math.floor(Date.parse(s.slice(0, 10) + 'T00:00:00Z') / 86400000);
2929    const startSort = dayUtc(startDate);
2930    const endSort = dayUtc(endDate);
2931
2932    if (!instance.frontlineMultiConflict) {
2933        const sorted = index.get('') ?? [];
2934        const closestStart = _findClosestFrontlineDate(sorted, startSort);
2935        const closestEnd = _findClosestFrontlineDate(sorted, endSort);
2936        instance.frontlineClosest.set('', { start: closestStart, end: closestEnd });
2937        const matchDates = new Set();
2938        if (closestStart != null) matchDates.add(closestStart);
2939        if (closestEnd != null) matchDates.add(closestEnd);
2940        instance.frontlineDateFilter = ['any',
2941            ['==', ['get', 'date'], null],
2942            ['in', ['get', 'dateSort'], ['literal', Array.from(matchDates)]]
2943        ];
2944        return;
2945    }
2946
2947    // Global map: one branch per conflict. A conflict whose first line comes after the window's
2948    // end gets no branch and no frontlineClosest entry (it shows nothing) instead of the clamp.
2949    const branches = [];
2950    for (const [conflict, sorted] of index) {
2951        const end = _findClosestFrontlineDate(sorted, endSort, true);
2952        if (end == null) continue;
2953        const start = _findClosestFrontlineDate(sorted, startSort, true);
2954        const distinctStart = start != null && start !== end ? start : null;
2955        instance.frontlineClosest.set(conflict, { start: distinctStart, end });
2956        branches.push(['all',
2957            ['==', ['get', 'conflict'], conflict],
2958            ['in', ['get', 'dateSort'], ['literal', distinctStart != null ? [distinctStart, end] : [end]]]
2959        ]);
2960    }
2961    // Never null here: a null falls back to the range dateFilter and would draw every daily
2962    // snapshot in the window. With no branch it still hides every dated feature.
2963    instance.frontlineDateFilter = ['any', ['==', ['get', 'date'], null], ...branches];
2964}
2965
2966// Frontline colors: blue for "not earlier than" boundary, green for "not later than", red default.
2967// When the two closest dates collapse to one (range narrower than the gap between recorded
2968// frontlines), green wins because it is checked first in the case expression. Every conflict on the
2969// global map keeps this scheme: per-conflict colours would collide with these three.
2970const FRONTLINE_COLOR_DEFAULT = '#e53935';
2971const FRONTLINE_COLOR_START = '#1e88e5';
2972const FRONTLINE_COLOR_END = '#43a047';
2973
2974function _buildFrontlineColorExpression(instance) {
2975    const closest = instance.frontlineClosest;
2976    if (!closest || closest.size === 0) return FRONTLINE_COLOR_DEFAULT;
2977
2978    const cases = ['case'];
2979    if (!instance.frontlineMultiConflict) {
2980        const { start: startSort, end: endSort } = closest.get('') ?? {};
2981        if (startSort == null && endSort == null) return FRONTLINE_COLOR_DEFAULT;
2982        if (endSort != null) {
2983            cases.push(['==', ['get', 'dateSort'], endSort], FRONTLINE_COLOR_END);
2984        }
2985        if (startSort != null && startSort !== endSort) {
2986            cases.push(['==', ['get', 'dateSort'], startSort], FRONTLINE_COLOR_START);
2987        }
2988    } else {
2989        for (const [conflict, { start, end }] of closest) {
2990            cases.push(['all', ['==', ['get', 'conflict'], conflict], ['==', ['get', 'dateSort'], end]],
2991                FRONTLINE_COLOR_END);
2992            if (start != null) {
2993                cases.push(['all', ['==', ['get', 'conflict'], conflict], ['==', ['get', 'dateSort'], start]],
2994                    FRONTLINE_COLOR_START);
2995            }
2996        }
2997    }
2998    cases.push(FRONTLINE_COLOR_DEFAULT);
2999    return cases;
3000}
3001
3002function _applyFrontlineColors(instance) {
3003    const { map } = instance;
3004    if (!map.getLayer('frontlines-line')) return;
3005    map.setPaintProperty('frontlines-line', 'line-color', _buildFrontlineColorExpression(instance));
3006}
3007
3008// Refresh the stats panel (Total/Visible/Latest) after the map finishes applying a
3009// filter or source-data change. Visible comes from queryRenderedFeatures, which only
3010// reflects the last painted frame — a fixed setTimeout raced the re-render of large
3011// sources (56k+ features) and read a stale/zero count. Wait for the next 'idle'
3012// instead, coalescing rapid calls (e.g. playback steps) into one pending recompute.
3013function _scheduleStatsUpdate(instance) {
3014    if (!instance._statsUpdate || !instance.map) return;
3015    if (instance._statsIdlePending) return;
3016    instance._statsIdlePending = true;
3017    instance.map.once('idle', () => {
3018        instance._statsIdlePending = false;
3019        instance._statsUpdate();
3020    });
3021}
3022
3023function applyDateFilter(instance, startDate, endDate) {
3024    instance.dateFilter = ['any',
3025        ['==', ['get', 'date'], null],
3026        ['all',
3027            ['>=', ['get', 'date'], startDate],
3028            ['<=', ['get', 'date'], endDate]
3029        ]
3030    ];
3031    instance.lastDateFilterStart = startDate;
3032    instance.lastDateFilterEnd = endDate;
3033    _buildFrontlineFilter(instance, startDate, endDate);
3034    _applyFrontlineColors(instance);
3035    _applyComposedFilter(instance);
3036    _applyMentionsDateFilter(instance);
3037}
3038
3039function clearDateFilterInternal(instance) {
3040    instance.dateFilter = null;
3041    instance.frontlineDateFilter = null;
3042    instance.lastDateFilterStart = null;
3043    instance.lastDateFilterEnd = null;
3044    instance.frontlineClosest = new Map();
3045    _applyFrontlineColors(instance);
3046    _applyComposedFilter(instance);
3047    _applyMentionsDateFilter(instance);
3048}
3049
3050// Rebuilds the faction visibility filter from factionMeta and applies composed filter
3051function _rebuildFactionFilter(instance) {
3052    const { factionMeta } = instance;
3053    if (!factionMeta) return;
3054
3055    const visibleFactions = [];
3056    for (const [fid, meta] of factionMeta) {
3057        if (meta.visible) visibleFactions.push(fid);
3058    }
3059
3060    if (visibleFactions.length < factionMeta.size) {
3061        instance.factionFilter = ['in', ['get', 'factionId'], ['literal', visibleFactions]];
3062    } else {
3063        instance.factionFilter = null;
3064    }
3065    _applyComposedFilter(instance);
3066}
3067
3068// Pushes the current query-builder result (see _filteredPlacemarkFeatures) into the merged
3069// placemarks source. No-op while the source is absent — _restoreOverlays re-creates it from the
3070// same helper.
3071function _rebuildMergedSourceData(instance) {
3072    const { map, allFeatures } = instance;
3073    if (!allFeatures) return;
3074
3075    const geojson = { type: 'FeatureCollection', features: _filteredPlacemarkFeatures(instance) };
3076    const source = map.getSource('placemarks-all');
3077    if (source) source.setData(geojson);
3078
3079    // Refresh stats once the new source data actually renders.
3080    _scheduleStatsUpdate(instance);
3081}
3082
3083// Composes faction filter + date filter + static filter and applies to all placemark layers
3084function _applyComposedFilter(instance) {
3085    const { map, placemarkLayerIds, frontlineLayerIds, factionFilter, dateFilter, staticFilter } = instance;
3086
3087    // Apply composed filter to placemark layers (skip layers wiped by a pending setStyle).
3088    // MapLibre v5's setFilter rejects null (validation expects an array); pass undefined
3089    // to clear the filter instead.
3090    if (placemarkLayerIds) {
3091        const parts = [factionFilter, dateFilter, staticFilter].filter(Boolean);
3092        let filter;
3093        if (parts.length > 1) {
3094            filter = ['all', ...parts];
3095        } else if (parts.length === 1) {
3096            filter = parts[0];
3097        }
3098
3099        for (let i = 0; i < placemarkLayerIds.length; i++) {
3100            if (map.getLayer(placemarkLayerIds[i])) {
3101                map.setFilter(placemarkLayerIds[i], filter);
3102            }
3103        }
3104    }
3105
3106    // Apply frontline-specific filter (closest dates only, not full range)
3107    if (frontlineLayerIds) {
3108        const flFilter = instance.frontlineDateFilter || dateFilter || undefined;
3109        for (let i = 0; i < frontlineLayerIds.length; i++) {
3110            if (map.getLayer(frontlineLayerIds[i])) {
3111                map.setFilter(frontlineLayerIds[i], flFilter);
3112            }
3113        }
3114    }
3115
3116    // Refresh stats once the filter actually renders (a fixed timeout raced the
3117    // re-render of large sources and read a stale Visible count).
3118    _scheduleStatsUpdate(instance);
3119}
3120
3121// Active timeline state per map (for cleanup)
3122const _timelineState = new Map();
3123
3124export function initTimelineSliders(mapId,
3125    startSlider, endSlider, fillEl, playheadEl, startLabel, endLabel, playLabel,
3126    playBtn, stopBtn,
3127    startDateInput, endDateInput, mobilePlayBtn, mobileStopBtn,
3128    isoStarts, isoEnds, isoDates, labels,
3129    prevBtn, nextBtn, modeBtn,
3130    mobilePrevBtn, mobileNextBtn, mobileModeBtn, initialStartIdx, initialEndIdx,
3131    histCanvas, presetsEl, mobilePresetsEl, emptyHintEl) {
3132
3133    const instance = instances.get(mapId);
3134    if (!instance) return;
3135
3136    // Clean up previous timeline if re-initialized
3137    const prev = _timelineState.get(mapId);
3138    if (prev) prev.destroy();
3139
3140    // Flag the map container so the bottom-corner controls (coordinate readout,
3141    // stats) lift above the timeline. Removed in destroy() so maps without a
3142    // timeline keep those controls near the bottom.
3143    const mapContainerEl = instance.map?.getContainer?.()?.closest('.maplibre-container');
3144    if (mapContainerEl) mapContainerEl.classList.add('has-timeline');
3145
3146    const maxIdx = labels.length - 1;
3147    // Percent-of-track helper, guarded so a single-day conflict (maxIdx === 0)
3148    // doesn't divide by zero and emit "NaN%" into the fill/playhead styles.
3149    const pctOf = (idx) => (maxIdx > 0 ? idx / maxIdx * 100 : 0);
3150
3151    // Build date-string → index lookup for mobile inputs
3152    const dateToIdx = {};
3153    for (let i = 0; i <= maxIdx; i++) dateToIdx[isoDates[i]] = i;
3154
3155    // State
3156    let startIdx = Math.max(0, Math.min(maxIdx, initialStartIdx || 0));
3157    // A shared ?ts=&te= window passes an explicit end index; default to the latest day.
3158    let endIdx = Number.isFinite(initialEndIdx) ? Math.max(0, Math.min(maxIdx, initialEndIdx)) : maxIdx;
3159    if (startIdx > endIdx) startIdx = endIdx;
3160    let playIdx = 0;
3161    let playing = false;
3162    let intervalId = null;
3163    let cumulative = true; // true = show <= play-date, false = show only play-date
3164
3165    // Set initial labels
3166    startLabel.value = isoDates[startIdx];
3167    endLabel.value = isoDates[endIdx];
3168    startSlider.value = startIdx;
3169    endSlider.value = endIdx;
3170    startSlider.setAttribute('aria-valuetext', isoDates[startIdx]);
3171    endSlider.setAttribute('aria-valuetext', isoDates[endIdx]);
3172    startDateInput.value = isoDates[startIdx];
3173    endDateInput.value = isoDates[endIdx];
3174    playLabel.textContent = '';
3175
3176    fillEl.style.left = pctOf(startIdx).toFixed(2) + '%';
3177    fillEl.style.right = (100 - pctOf(endIdx)).toFixed(2) + '%';
3178    playheadEl.style.display = 'none';
3179
3180    // Reset chip highlight on (re-)init. The default initial range is the custom
3181    // 2-week window, which matches no preset; without this, switching conflicts
3182    // re-inits the timeline but leaves the previous conflict's active chip (e.g.
3183    // "30d") highlighted while the range has actually reset to the default.
3184    clearActivePreset();
3185
3186    // --- Filter helpers ---
3187    let rafPending = false;
3188    let pendingStart = isoStarts[startIdx];
3189    let pendingEnd = isoEnds[endIdx];
3190
3191    function scheduleFilter() {
3192        if (rafPending) return;
3193        rafPending = true;
3194        requestAnimationFrame(() => {
3195            rafPending = false;
3196            applyDateFilter(instance, pendingStart, pendingEnd);
3197        });
3198    }
3199
3200    function applyRange() {
3201        pendingStart = isoStarts[startIdx];
3202        pendingEnd = isoEnds[endIdx];
3203        scheduleFilter();
3204    }
3205
3206    function updateFill() {
3207        fillEl.style.left = pctOf(startIdx).toFixed(2) + '%';
3208        fillEl.style.right = (100 - pctOf(endIdx)).toFixed(2) + '%';
3209        startSlider.setAttribute('aria-valuetext', isoDates[startIdx]);
3210        endSlider.setAttribute('aria-valuetext', isoDates[endIdx]);
3211        clearActivePreset();
3212        scheduleHistogram();
3213        updateEmptyHint();
3214        // Paused mid-step (playhead shown): keep it inside the edited range.
3215        if (!playing && playheadEl.style.display !== 'none') {
3216            const clamped = Math.max(startIdx, Math.min(endIdx, playIdx));
3217            if (clamped !== playIdx) { playIdx = clamped; updatePlayhead(); }
3218        }
3219    }
3220
3221    // --- Density histogram ---
3222    // Per-day placemark counts, bucketed from the features already in JS
3223    // (properties.dateSort = epoch-day int). No C#/DB round-trip. Undated
3224    // placemarks fall back to dateSort 0 and are dropped so the conflict-start
3225    // day doesn't get a fake spike.
3226    let histCounts = null;
3227    let histMax = 0;
3228    let histPrefix = null;
3229    // Parse as UTC (append 'Z') so this base day matches the server's UTC-based
3230    // dateSort. Without it, a local-time parse shifts every bar +1 day and drops
3231    // the last day's bar for users east of UTC (most of the audience).
3232    const histDsBase = Math.floor(new Date(isoStarts[0] + 'Z').getTime() / 86400000);
3233
3234    function computeHistogram() {
3235        if (!histCanvas || !instance.allFeatures || !instance.allFeatures.length) return false;
3236        const counts = new Array(maxIdx + 1).fill(0);
3237        let max = 0;
3238        for (const f of instance.allFeatures) {
3239            const ds = f.properties.dateSort;
3240            if (!ds || ds <= 0) continue;
3241            const i = ds - histDsBase;
3242            if (i >= 0 && i <= maxIdx) counts[i]++;
3243        }
3244        for (let i = 0; i <= maxIdx; i++) if (counts[i] > max) max = counts[i];
3245        histCounts = counts;
3246        histMax = max;
3247        // Prefix sums for O(1) "is the selected range empty?" checks.
3248        histPrefix = new Array(maxIdx + 2);
3249        histPrefix[0] = 0;
3250        for (let i = 0; i <= maxIdx; i++) histPrefix[i + 1] = histPrefix[i] + counts[i];
3251        return max > 0;
3252    }
3253
3254    // Show a one-tap "Show all" hint only when dated data exists somewhere but the
3255    // selected range contains none of it — so a user who lands on an empty window
3256    // has an obvious way out instead of concluding the map is broken.
3257    function updateEmptyHint() {
3258        if (!emptyHintEl) return;
3259        const inRange = histPrefix ? (histPrefix[endIdx + 1] - histPrefix[startIdx]) : 1;
3260        emptyHintEl.style.display = (histMax > 0 && inRange === 0) ? '' : 'none';
3261    }
3262
3263    // Paint the histogram now if the feature data is already loaded; the retry
3264    // loop below picks it up (and re-applies the filter) once the layer-build
3265    // populates allFeatures on a slower load.
3266    computeHistogram();
3267    updateEmptyHint();
3268    const dataReadyAtInit = !!(instance.allFeatures && instance.allFeatures.length);
3269
3270    let histRafPending = false;
3271    function scheduleHistogram() {
3272        if (!histCanvas || !histCounts || histMax === 0) return;
3273        if (histRafPending) return;
3274        histRafPending = true;
3275        requestAnimationFrame(() => { histRafPending = false; drawHistogram(); });
3276    }
3277
3278    function drawHistogram() {
3279        if (!histCanvas) return;
3280        const cssW = histCanvas.clientWidth;
3281        const cssH = histCanvas.clientHeight;
3282        if (cssW === 0 || cssH === 0) return;
3283        const ctx = histCanvas.getContext('2d');
3284        if (!ctx) return;
3285        const dpr = window.devicePixelRatio || 1;
3286        histCanvas.width = Math.round(cssW * dpr);
3287        histCanvas.height = Math.round(cssH * dpr);
3288        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
3289        ctx.clearRect(0, 0, cssW, cssH);
3290        // No data yet (e.g. mid conflict-switch before the new features load):
3291        // leave the canvas cleared so the previous conflict's bars don't linger.
3292        if (!histCounts || histMax === 0) return;
3293
3294        const primary = getComputedStyle(histCanvas)
3295            .getPropertyValue('--mud-palette-primary').trim() || '#7b5dc9';
3296        ctx.fillStyle = primary;
3297
3298        // During playback the bright region tracks the playhead (so the rail doesn't
3299        // contradict it); otherwise it spans the whole selected range.
3300        const playhead = playheadEl.style.display !== 'none';
3301        const loIn = playhead ? (cumulative ? startIdx : playIdx) : startIdx;
3302        const hiIn = playhead ? playIdx : endIdx;
3303
3304        const n = maxIdx + 1;
3305        const denom = Math.sqrt(histMax);
3306        if (n <= cssW) {
3307            // One bar (or wider) per day.
3308            const bw = cssW / n;
3309            for (let i = 0; i < n; i++) {
3310                const c = histCounts[i];
3311                if (c === 0) continue;
3312                const h = Math.sqrt(c) / denom * cssH;
3313                ctx.globalAlpha = (i >= loIn && i <= hiIn) ? 0.9 : 0.28;
3314                ctx.fillRect(i * bw, cssH - h, Math.max(1, bw - 0.5), h);
3315            }
3316        } else {
3317            // More days than pixels: reduce each pixel column to its max count.
3318            for (let x = 0; x < cssW; x++) {
3319                const lo = Math.floor(x / cssW * n);
3320                const hi = Math.floor((x + 1) / cssW * n);
3321                let c = 0;
3322                for (let i = lo; i < hi && i < n; i++) if (histCounts[i] > c) c = histCounts[i];
3323                if (c === 0) continue;
3324                const h = Math.sqrt(c) / denom * cssH;
3325                ctx.globalAlpha = (hi > loIn && lo <= hiIn) ? 0.9 : 0.28;
3326                ctx.fillRect(x, cssH - h, 1, h);
3327            }
3328        }
3329        ctx.globalAlpha = 1;
3330    }
3331
3332    // --- Preset chips (1d / 2d / 3d / 7d / 30d / 90d / All) ---
3333    // Selection state lives in this closure (startIdx/endIdx), so the chips are
3334    // wired here and reuse the exact updateFill()/applyRange() path the sliders
3335    // and date inputs already use.
3336    function applyPreset(days) {
3337        if (playing) return;
3338        if (days === 'all') {
3339            startIdx = 0;
3340        } else {
3341            const span = parseInt(days, 10);
3342            if (!Number.isFinite(span)) return;
3343            startIdx = Math.max(0, maxIdx - (span - 1));
3344        }
3345        endIdx = maxIdx;
3346        startSlider.value = startIdx;
3347        endSlider.value = endIdx;
3348        startLabel.value = isoDates[startIdx];
3349        endLabel.value = isoDates[endIdx];
3350        startDateInput.value = isoDates[startIdx];
3351        endDateInput.value = isoDates[endIdx];
3352        updateFill();          // clears active preset + redraws histogram
3353        applyRange();
3354        setActivePreset(days); // re-mark the chosen chip
3355    }
3356
3357    function setActivePreset(days) {
3358        for (const el of [presetsEl, mobilePresetsEl]) {
3359            if (!el) continue;
3360            el.querySelectorAll('.timeline-chip').forEach(b => {
3361                const on = b.dataset.days === days;
3362                b.classList.toggle('is-active', on);
3363                b.setAttribute('aria-pressed', on ? 'true' : 'false');
3364            });
3365        }
3366    }
3367
3368    function clearActivePreset() {
3369        for (const el of [presetsEl, mobilePresetsEl]) {
3370            if (!el) continue;
3371            el.querySelectorAll('.timeline-chip').forEach(b => {
3372                b.classList.remove('is-active');
3373                b.setAttribute('aria-pressed', 'false');
3374            });
3375        }
3376    }
3377
3378    function setChipsDisabled(disabled) {
3379        for (const el of [presetsEl, mobilePresetsEl]) {
3380            if (!el) continue;
3381            el.querySelectorAll('.timeline-chip').forEach(b => { b.disabled = disabled; });
3382        }
3383    }
3384
3385    function onPresetClick(e) {
3386        const btn = e.target.closest('.timeline-chip');
3387        if (btn) applyPreset(btn.dataset.days);
3388    }
3389
3390    function updatePlayhead() {
3391        const pct = pctOf(playIdx).toFixed(2) + '%';
3392        playheadEl.style.left = pct;
3393        playheadEl.style.display = 'block';
3394        playLabel.textContent = labels[playIdx];
3395    }
3396
3397    function hidePlayhead() {
3398        playheadEl.style.display = 'none';
3399        playLabel.textContent = '';
3400    }
3401
3402    // --- Play/stop icon toggling ---
3403    function setPlayIcon(btn, isPlaying) {
3404        const playPath = btn.querySelector('.icon-play');
3405        const pausePath = btn.querySelector('.icon-pause');
3406        if (playPath) playPath.style.display = isPlaying ? 'none' : '';
3407        if (pausePath) pausePath.style.display = isPlaying ? '' : 'none';
3408        const label = isPlaying ? 'Pause playback' : 'Play day-by-day';
3409        btn.title = label;
3410        btn.setAttribute('aria-label', label);
3411    }
3412
3413    function showStopBtns(show) {
3414        stopBtn.style.display = show ? '' : 'none';
3415        mobileStopBtn.style.display = show ? '' : 'none';
3416        modeBtn.style.display = show ? '' : 'none';
3417        mobileModeBtn.style.display = show ? '' : 'none';
3418    }
3419
3420    function showStepBtns(show) {
3421        prevBtn.style.display = show ? '' : 'none';
3422        nextBtn.style.display = show ? '' : 'none';
3423        mobilePrevBtn.style.display = show ? '' : 'none';
3424        mobileNextBtn.style.display = show ? '' : 'none';
3425    }
3426
3427    function applyPlayFilter() {
3428        if (cumulative) {
3429            pendingStart = isoStarts[startIdx];
3430        } else {
3431            pendingStart = isoStarts[playIdx];
3432        }
3433        pendingEnd = isoEnds[playIdx];
3434        scheduleFilter();
3435    }
3436
3437    function setModeLabel(label) {
3438        // ≤/= is cryptic on its own; give the button a self-explanatory name/tooltip
3439        // that reflects the current mode.
3440        const desc = cumulative
3441            ? 'Showing all placemarks up to the play date — click for single day only'
3442            : 'Showing only the play date — click for cumulative (all up to date)';
3443        for (const b of [modeBtn, mobileModeBtn]) {
3444            b.querySelector('.mode-label').textContent = label;
3445            b.title = desc;
3446            b.setAttribute('aria-label', desc);
3447        }
3448    }
3449
3450    function toggleMode() {
3451        cumulative = !cumulative;
3452        setModeLabel(cumulative ? '≤' : '=');
3453        applyPlayFilter();
3454    }
3455
3456    function stepPrev() {
3457        if (playing) return;
3458        if (playIdx <= startIdx) return;
3459        playIdx--;
3460        showPlayFrame();
3461    }
3462
3463    function stepNext() {
3464        if (playing) return;
3465        if (playIdx >= endIdx) return;
3466        playIdx++;
3467        showPlayFrame();
3468    }
3469
3470    // --- Playback ---
3471    function startPlayback() {
3472        if (playing) { pause(); return; }
3473        playing = true;
3474        // playIdx is the day the playhead shows. Fresh play starts at startIdx; a
3475        // resume keeps the shown day — clamped into the (possibly just-edited) range
3476        // so resuming after dragging a thumb can't invert start>end and blank the map.
3477        if (playheadEl.style.display === 'none') {
3478            playIdx = startIdx;
3479        }
3480        playIdx = Math.max(startIdx, Math.min(endIdx, playIdx));
3481        setPlayIcon(playBtn, true);
3482        setPlayIcon(mobilePlayBtn, true);
3483        showStopBtns(true);
3484        showStepBtns(false);
3485        startSlider.disabled = true;
3486        endSlider.disabled = true;
3487        startLabel.disabled = true;
3488        endLabel.disabled = true;
3489        startDateInput.disabled = true;
3490        endDateInput.disabled = true;
3491        setChipsDisabled(true);
3492        showPlayFrame();
3493        intervalId = setInterval(tick, 150);
3494    }
3495
3496    // Show the day playIdx points at (filter + playhead). Keeps playIdx == the
3497    // displayed day so Prev/Next/mode-toggle never desync by one.
3498    function showPlayFrame() {
3499        applyPlayFilter();
3500        updatePlayhead();
3501        scheduleHistogram(); // keep the bright rail region tracking the playhead
3502    }
3503
3504    function tick() {
3505        if (playIdx >= endIdx) { stopPlayback(); return; }
3506        playIdx++;
3507        showPlayFrame();
3508    }
3509
3510    function pause() {
3511        playing = false;
3512        if (intervalId) { clearInterval(intervalId); intervalId = null; }
3513        setPlayIcon(playBtn, false);
3514        setPlayIcon(mobilePlayBtn, false);
3515        showStepBtns(true);
3516        startSlider.disabled = false;
3517        endSlider.disabled = false;
3518        startLabel.disabled = false;
3519        endLabel.disabled = false;
3520        startDateInput.disabled = false;
3521        endDateInput.disabled = false;
3522        setChipsDisabled(false);
3523    }
3524
3525    function stopPlayback() {
3526        pause();
3527        hidePlayhead();
3528        showStopBtns(false);
3529        showStepBtns(false);
3530        applyRange();
3531    }
3532
3533    // --- Track click: move closest range marker ---
3534    // Listen on the whole rail (canvas + slider band), not just the 4px track line,
3535    // so a tap anywhere on the density rail — including a histogram spike, since the
3536    // canvas is pointer-events:none — moves the nearest marker. The rail shares the
3537    // track's horizontal extent, so the pct geometry is unchanged.
3538    const track = fillEl.parentElement; // .range-track
3539    const clickSurface = (histCanvas && histCanvas.parentElement) || track;
3540    clickSurface.addEventListener('click', onTrackClick);
3541
3542    function onTrackClick(e) {
3543        if (playing) return;
3544        const rect = clickSurface.getBoundingClientRect();
3545        const pct = (e.clientX - rect.left) / rect.width;
3546        const clickIdx = Math.max(0, Math.min(maxIdx, Math.round(pct * maxIdx)));
3547        const distToStart = Math.abs(clickIdx - startIdx);
3548        const distToEnd = Math.abs(clickIdx - endIdx);
3549
3550        if (distToStart <= distToEnd) {
3551            const val = Math.min(clickIdx, endIdx);
3552            startIdx = val;
3553            startSlider.value = val;
3554            startLabel.value = isoDates[val];
3555            startDateInput.value = isoDates[val];
3556        } else {
3557            const val = Math.max(clickIdx, startIdx);
3558            endIdx = val;
3559            endSlider.value = val;
3560            endLabel.value = isoDates[val];
3561            endDateInput.value = isoDates[val];
3562        }
3563        updateFill();
3564        applyRange();
3565    }
3566
3567    // --- Desktop slider events ---
3568    startSlider.addEventListener('input', onStartSlider);
3569    endSlider.addEventListener('input', onEndSlider);
3570
3571    function onStartSlider() {
3572        let val = parseInt(startSlider.value, 10);
3573        if (val > endIdx) { val = endIdx; startSlider.value = val; }
3574        startIdx = val;
3575        startLabel.value = isoDates[val];
3576        startDateInput.value = isoDates[val];
3577        updateFill();
3578        applyRange();
3579    }
3580
3581    function onEndSlider() {
3582        let val = parseInt(endSlider.value, 10);
3583        if (val < startIdx) { val = startIdx; endSlider.value = val; }
3584        endIdx = val;
3585        endLabel.value = isoDates[val];
3586        endDateInput.value = isoDates[val];
3587        updateFill();
3588        applyRange();
3589    }
3590
3591    // --- Mobile date input events ---
3592    startDateInput.addEventListener('change', onStartDate);
3593    endDateInput.addEventListener('change', onEndDate);
3594
3595    function onStartDate() {
3596        const idx = dateToIdx[startDateInput.value];
3597        if (idx === undefined) { startDateInput.value = isoDates[startIdx]; return; }
3598        const val = Math.min(idx, endIdx);
3599        startIdx = val;
3600        startDateInput.value = isoDates[val];
3601        // Sync desktop
3602        startSlider.value = val;
3603        startLabel.value = isoDates[val];
3604        updateFill();
3605        applyRange();
3606    }
3607
3608    function onEndDate() {
3609        const idx = dateToIdx[endDateInput.value];
3610        if (idx === undefined) { endDateInput.value = isoDates[endIdx]; return; }
3611        const val = Math.max(idx, startIdx);
3612        endIdx = val;
3613        endDateInput.value = isoDates[val];
3614        // Sync desktop
3615        endSlider.value = val;
3616        endLabel.value = isoDates[val];
3617        updateFill();
3618        applyRange();
3619    }
3620
3621    // --- Desktop date input events ---
3622    function onStartLabelChange() {
3623        const idx = dateToIdx[startLabel.value];
3624        if (idx === undefined) { startLabel.value = isoDates[startIdx]; return; }
3625        const val = Math.min(idx, endIdx);
3626        startIdx = val;
3627        startLabel.value = isoDates[val];
3628        // Sync slider + mobile
3629        startSlider.value = val;
3630        startDateInput.value = isoDates[val];
3631        updateFill();
3632        applyRange();
3633    }
3634
3635    function onEndLabelChange() {
3636        const idx = dateToIdx[endLabel.value];
3637        if (idx === undefined) { endLabel.value = isoDates[endIdx]; return; }
3638        const val = Math.max(idx, startIdx);
3639        endIdx = val;
3640        endLabel.value = isoDates[val];
3641        // Sync slider + mobile
3642        endSlider.value = val;
3643        endDateInput.value = isoDates[val];
3644        updateFill();
3645        applyRange();
3646    }
3647
3648    startLabel.addEventListener('change', onStartLabelChange);
3649    endLabel.addEventListener('change', onEndLabelChange);
3650
3651    // --- Button events ---
3652    playBtn.addEventListener('click', startPlayback);
3653    stopBtn.addEventListener('click', stopPlayback);
3654    mobilePlayBtn.addEventListener('click', startPlayback);
3655    mobileStopBtn.addEventListener('click', stopPlayback);
3656    prevBtn.addEventListener('click', stepPrev);
3657    nextBtn.addEventListener('click', stepNext);
3658    mobilePrevBtn.addEventListener('click', stepPrev);
3659    mobileNextBtn.addEventListener('click', stepNext);
3660    modeBtn.addEventListener('click', toggleMode);
3661    mobileModeBtn.addEventListener('click', toggleMode);
3662
3663    // --- Preset chip events (one delegated listener per container) ---
3664    if (presetsEl) presetsEl.addEventListener('click', onPresetClick);
3665    if (mobilePresetsEl) mobilePresetsEl.addEventListener('click', onPresetClick);
3666
3667    // "Show all" inside the empty-range hint
3668    const showAllBtn = emptyHintEl ? emptyHintEl.querySelector('.timeline-showall-btn') : null;
3669    function onShowAll(e) { e.stopPropagation(); applyPreset('all'); }
3670    if (showAllBtn) showAllBtn.addEventListener('click', onShowAll);
3671
3672    // Initial state: apply range filter
3673    applyDateFilter(instance, pendingStart, pendingEnd);
3674
3675    // Density histogram: keep it crisp across resizes (DPR-aware) and paint now.
3676    let histObserver = null;
3677    if (histCanvas && typeof ResizeObserver !== 'undefined') {
3678        histObserver = new ResizeObserver(() => drawHistogram());
3679        histObserver.observe(histCanvas);
3680    }
3681    drawHistogram();
3682
3683    // A window resize — including moving between monitors of a different DPR, which
3684    // the ResizeObserver doesn't catch — should re-rasterize the canvas crisply.
3685    function onWinResize() { scheduleHistogram(); }
3686    window.addEventListener('resize', onWinResize);
3687
3688    // instance.allFeatures is filled by the map's layer-build path, which can run
3689    // AFTER this timeline init on a fresh load. When it does, the build also resets
3690    // instance.dateFilter (and never re-applies it), so the map shows the wrong set
3691    // until the user interacts. Poll until the features arrive, then paint the
3692    // histogram AND re-apply the current range filter so first load is correct.
3693    let histRetryTimer = null;
3694    if (!dataReadyAtInit) {
3695        let tries = 0;
3696        const retry = () => {
3697            histRetryTimer = null;
3698            if (instance.allFeatures && instance.allFeatures.length) {
3699                computeHistogram();
3700                drawHistogram();
3701                if (!playing) applyRange(); // don't clobber an in-progress playback
3702                updateEmptyHint();
3703                return;
3704            }
3705            if (++tries < 40) histRetryTimer = setTimeout(retry, 250);
3706        };
3707        histRetryTimer = setTimeout(retry, 250);
3708    }
3709
3710    // Store state for cleanup
3711    _timelineState.set(mapId, {
3712        destroy() {
3713            if (intervalId) clearInterval(intervalId);
3714            clickSurface.removeEventListener('click', onTrackClick);
3715            startSlider.removeEventListener('input', onStartSlider);
3716            endSlider.removeEventListener('input', onEndSlider);
3717            startDateInput.removeEventListener('change', onStartDate);
3718            endDateInput.removeEventListener('change', onEndDate);
3719            startLabel.removeEventListener('change', onStartLabelChange);
3720            endLabel.removeEventListener('change', onEndLabelChange);
3721            playBtn.removeEventListener('click', startPlayback);
3722            stopBtn.removeEventListener('click', stopPlayback);
3723            mobilePlayBtn.removeEventListener('click', startPlayback);
3724            mobileStopBtn.removeEventListener('click', stopPlayback);
3725            prevBtn.removeEventListener('click', stepPrev);
3726            nextBtn.removeEventListener('click', stepNext);
3727            mobilePrevBtn.removeEventListener('click', stepPrev);
3728            mobileNextBtn.removeEventListener('click', stepNext);
3729            modeBtn.removeEventListener('click', toggleMode);
3730            mobileModeBtn.removeEventListener('click', toggleMode);
3731            if (presetsEl) presetsEl.removeEventListener('click', onPresetClick);
3732            if (mobilePresetsEl) mobilePresetsEl.removeEventListener('click', onPresetClick);
3733            if (showAllBtn) showAllBtn.removeEventListener('click', onShowAll);
3734            if (histObserver) histObserver.disconnect();
3735            window.removeEventListener('resize', onWinResize);
3736            if (histRetryTimer) clearTimeout(histRetryTimer);
3737            if (mapContainerEl) mapContainerEl.classList.remove('has-timeline');
3738            _timelineState.delete(mapId);
3739        }
3740    });
3741}
3742
3743// Tear down the timeline for a map — used when switching to a ShowTimeline=false
3744// conflict, where no new init would otherwise run prev.destroy(). Removes its
3745// listeners and the .has-timeline class so the bottom-corner controls drop back down.
3746export function teardownTimeline(mapId) {
3747    const prev = _timelineState.get(mapId);
3748    if (prev) prev.destroy();
3749}
3750
3751export function filterByDateRange(mapId, startDate, endDate) {
3752    const instance = instances.get(mapId);
3753    if (!instance) return;
3754    applyDateFilter(instance, startDate, endDate);
3755}
3756
3757export function clearDateFilter(mapId) {
3758    const instance = instances.get(mapId);
3759    if (!instance) return;
3760    clearDateFilterInternal(instance);
3761}
3762
3763export function setStaticObjectsVisible(mapId, visible) {
3764    const instance = instances.get(mapId);
3765    if (!instance) return;
3766    // When hidden, filter out features with no date (static objects)
3767    // Use undefined (not null) when no filter applies — MapLibre v5's style
3768    // validation rejects a null filter on both addLayer and setFilter.
3769    instance.staticFilter = visible ? undefined : ['has', 'date'];
3770    _applyComposedFilter(instance);
3771    localStorage.setItem('gc-static-objects', visible ? '1' : '0');
3772}
3773
3774export function getStaticObjectsVisible() {
3775    return localStorage.getItem('gc-static-objects') === '1';
3776}
3777
3778// Reads the persisted user preference for static objects visibility and returns
3779// the corresponding MapLibre filter expression. Called from the placemark-load
3780// paths so a fresh load respects whatever the user toggled in a prior session.
3781function _readPersistedStaticFilter() {
3782    // Return undefined (not null) when static objects are visible — addLayer's
3783    // style validation rejects a null filter property.
3784    return localStorage.getItem('gc-static-objects') === '1' ? undefined : ['has', 'date'];
3785}
3786
3787// Build the data-driven circle-color paint from faction metadata. A MapLibre
3788// `match` needs at least one case, so a conflict with no factions would produce
3789// an invalid `['match', ['get','factionId'], '#51bbd6']` (only 2 args) and make
3790// addLayer throw. Fall back to the plain default color in that case.
3791function _factionCircleColor(factionMeta) {
3792    if (!factionMeta || factionMeta.size === 0) return '#51bbd6';
3793    const expr = ['match', ['get', 'factionId']];
3794    for (const [fid, meta] of factionMeta) expr.push(fid, meta.color);
3795    expr.push('#51bbd6');
3796    return expr;
3797}
3798
3799// ===== POPUP =====
3800
3801export function showPopup(mapId, placemarkId, lng, lat, html) {
3802    const instance = instances.get(mapId);
3803    const { map } = instance;
3804    if (!instance._popups) instance._popups = new Map();
3805    // Remove existing popup for this placemark (toggle or refresh)
3806    if (instance._popups.has(placemarkId)) {
3807        instance._popups.get(placemarkId).remove();
3808        instance._popups.delete(placemarkId);
3809        return;
3810    }
3811    const popup = new maplibregl.Popup({ closeOnClick: false, maxWidth: '300px' })
3812        .setLngLat([lng, lat])
3813        .setHTML(html)
3814        .addTo(map);
3815    popup.on('close', () => instance._popups.delete(placemarkId));
3816    instance._popups.set(placemarkId, popup);
3817
3818    // Initialize copy buttons and coordinate format dropdowns in this popup
3819    _initPopupCopyButtons(popup.getElement());
3820    _initPopupCoordFormat(popup.getElement());
3821    _initPopupSpecialReveal(popup.getElement());
3822}
3823
3824// ===== POPUP COPY BUTTONS =====
3825
3826// Rows in the mention popup's placemark list copy that placemark's id, so the
3827// admin can paste it into the datatable search and find the entry.
3828function _initMentionPopupCopy(popupEl) {
3829    popupEl.querySelectorAll('.mention-popup-placemark').forEach(btn => {
3830        btn.addEventListener('click', (e) => {
3831            e.stopPropagation();
3832            const id = btn.dataset.copyId;
3833            if (!id) return;
3834            _copyText(id);
3835            // Position the toast relative to the popup element. A keyboard-activated
3836            // click (Enter/Space — the popup focuses its first button on open) carries
3837            // no pointer position, so anchor on the row itself in that case.
3838            const r = popupEl.getBoundingClientRect();
3839            const b = btn.getBoundingClientRect();
3840            const pointer = e.detail > 0 && (e.clientX || e.clientY);
3841            const x = pointer ? e.clientX : b.left + b.width / 2;
3842            const y = pointer ? e.clientY : b.top;
3843            _showCopyToast(popupEl, `Copied ${id}`, { offsetX: x - r.left, offsetY: y - r.top });
3844        });
3845    });
3846}
3847
3848// navigator.clipboard needs a secure context; fall back to the legacy command
3849// (plain-http dev hosts, some embedded browsers).
3850function _copyText(text) {
3851    if (navigator.clipboard?.writeText) {
3852        navigator.clipboard.writeText(text).catch(() => _copyTextLegacy(text));
3853    } else {
3854        _copyTextLegacy(text);
3855    }
3856}
3857
3858function _copyTextLegacy(text) {
3859    const ta = document.createElement('textarea');
3860    ta.value = text;
3861    ta.setAttribute('readonly', '');
3862    ta.style.position = 'fixed';
3863    ta.style.opacity = '0';
3864    document.body.appendChild(ta);
3865    ta.select();
3866    try { document.execCommand('copy'); } catch { /* nothing else to try */ }
3867    ta.remove();
3868}
3869
3870function _initPopupCopyButtons(popupEl) {
3871    popupEl.querySelectorAll('.placemark-popup-copy-btn').forEach(btn => {
3872        btn.addEventListener('click', (e) => {
3873            const text = btn.dataset.copy;
3874            if (text) {
3875                navigator.clipboard.writeText(text);
3876                _showCopyToast(popupEl, `Copied: ${text}`, e);
3877            }
3878        });
3879    });
3880}
3881
3882// ===== POPUP SPECIAL / INTERNAL REMARKS REVEAL =====
3883// The popup HTML embeds Special remarks behind a click-to-reveal button so the
3884// data doesn't appear in screenshots taken before the operator uncovers it.
3885// Server only sends SpecialRemarks to callers in the Special role, so by the time
3886// this runs the visibility decision has already been authorized — this is purely
3887// a screenshot-hygiene UI affordance.
3888function _initPopupSpecialReveal(popupEl) {
3889    popupEl.querySelectorAll('.placemark-popup-reveal-special-btn').forEach(btn => {
3890        btn.addEventListener('click', () => {
3891            const targetId = btn.dataset.revealTarget;
3892            if (!targetId) return;
3893            const content = popupEl.querySelector(`#${CSS.escape(targetId)}`);
3894            if (content) content.style.display = '';
3895            btn.remove();
3896        });
3897    });
3898}
3899
3900// ===== POPUP COORDINATE FORMAT =====
3901
3902function _initPopupCoordFormat(popupEl) {
3903    const saved = localStorage.getItem('gc-coord-format') || 'dec';
3904    const selects = popupEl.querySelectorAll('.placemark-popup-coord-format');
3905    selects.forEach(sel => {
3906        sel.value = saved;
3907        const valueEl = sel.closest('.placemark-popup-coordinates').querySelector('.placemark-popup-coord-value');
3908        if (valueEl) _updateCoordDisplay(valueEl, saved);
3909
3910        sel.addEventListener('change', () => {
3911            const format = sel.value;
3912            localStorage.setItem('gc-coord-format', format);
3913            // Update all open popups
3914            document.querySelectorAll('.placemark-popup-coord-format').forEach(s => {
3915                s.value = format;
3916            });
3917            document.querySelectorAll('.placemark-popup-coord-value').forEach(v => {
3918                _updateCoordDisplay(v, format);
3919            });
3920        });
3921    });
3922
3923    const valueEls = popupEl.querySelectorAll('.placemark-popup-coord-value');
3924    valueEls.forEach(valueEl => {
3925        valueEl.addEventListener('click', (e) => {
3926            const text = valueEl.textContent;
3927
3928            navigator.clipboard.writeText(text).catch(() => {});
3929
3930            _showCopyToast(popupEl, 'Copied: ' + text, e);
3931        });
3932    });
3933}
3934
3935function _updateCoordDisplay(valueEl, format) {
3936    const lat = parseFloat(valueEl.dataset.lat);
3937    const lng = parseFloat(valueEl.dataset.lng);
3938    switch (format) {
3939        case 'dms':
3940            valueEl.textContent = _formatDMS(lat, lng);
3941            break;
3942        case 'mgrs':
3943            valueEl.textContent = _latLngToMGRS(lat, lng);
3944            break;
3945        case 'itm':
3946            valueEl.textContent = _latLngToITM(lat, lng);
3947            break;
3948        default:
3949            valueEl.textContent = lat.toFixed(6) + ', ' + lng.toFixed(6);
3950            break;
3951    }
3952}
3953
3954// ===== SEARCH RESULT MARKER & AREA =====
3955
3956export async function showSearchResult(mapId, lng, lat, bbox, featureId) {
3957    const instance = instances.get(mapId);
3958    const { map } = instance;
3959
3960    clearSearchResult(mapId);
3961
3962    // Add marker
3963    const marker = new maplibregl.Marker({ color: '#e53935' })
3964        .setLngLat([lng, lat])
3965        .addTo(map);
3966    instance._searchMarker = marker;
3967
3968    // Try to fetch full geometry from feature ID
3969    let geojson = null;
3970    if (featureId) {
3971        try {
3972            const resp = await fetch(`/api/tiles/feature/${encodeURIComponent(featureId)}.json`);
3973            if (resp.ok) {
3974                const data = await resp.json();
3975                const feature = data.features?.[0];
3976                if (feature?.geometry && feature.geometry.type !== 'Point') {
3977                    geojson = { type: 'Feature', geometry: feature.geometry };
3978                }
3979            }
3980        } catch { /* fall through to bbox */ }
3981    }
3982
3983    // Fall back to bounding box rectangle
3984    if (!geojson && bbox && bbox.length === 4) {
3985        const [south, north, west, east] = bbox;
3986        geojson = {
3987            type: 'Feature',
3988            geometry: {
3989                type: 'Polygon',
3990                coordinates: [[
3991                    [west, south], [east, south],
3992                    [east, north], [west, north],
3993                    [west, south]
3994                ]]
3995            }
3996        };
3997    }
3998
3999    if (geojson) {
4000        map.addSource('search-area', { type: 'geojson', data: geojson });
4001        map.addLayer({
4002            id: 'search-area-fill',
4003            type: 'fill',
4004            source: 'search-area',
4005            paint: {
4006                'fill-color': '#e53935',
4007                'fill-opacity': 0.12
4008            }
4009        });
4010        map.addLayer({
4011            id: 'search-area-outline',
4012            type: 'line',
4013            source: 'search-area',
4014            paint: {
4015                'line-color': '#e53935',
4016                'line-width': 2
4017            }
4018        });
4019        instance._searchArea = true;
4020    }
4021}
4022
4023export function clearSearchResult(mapId) {
4024    const instance = instances.get(mapId);
4025    if (!instance) return;
4026    const { map } = instance;
4027
4028    if (instance._searchMarker) {
4029        instance._searchMarker.remove();
4030        instance._searchMarker = null;
4031    }
4032
4033    if (instance._searchArea) {
4034        if (map.getLayer('search-area-fill')) map.removeLayer('search-area-fill');
4035        if (map.getLayer('search-area-outline')) map.removeLayer('search-area-outline');
4036        if (map.getSource('search-area')) map.removeSource('search-area');
4037        instance._searchArea = false;
4038    }
4039}
4040
4041// ===== FRONTLINES =====
4042
4043// Fetches a frontline FeatureCollection and replaces whatever frontline is on the map with it.
4044// `multiConflict` (the global union) picks the date-closest line per feature `conflict`.
4045// Resolves to a boolean on every path: false when the map is gone, the fetch failed, a newer load
4046// or removeFrontlines superseded this one, or adding the layers threw — the caller may retry.
4047export async function loadFrontlines(mapId, url, multiConflict = false) {
4048    const instance = instances.get(mapId);
4049    if (!instance?.map) return false;
4050    const { map } = instance;
4051
4052    // A newer load or a removeFrontlines (scope change) bumps the token and drops this one.
4053    const token = instance._frontlineLoadToken = (instance._frontlineLoadToken ?? 0) + 1;
4054    const stale = () => instance._frontlineLoadToken !== token || !instances.has(mapId);
4055
4056    let geojson;
4057    try {
4058        const response = await fetch(url, { credentials: 'same-origin' });
4059        if (!response.ok) {
4060            console.warn(`[MapLibre] Frontline fetch failed: ${response.status}`);
4061            return false;
4062        }
4063        geojson = await response.json();
4064    } catch (err) {
4065        console.warn('[MapLibre] Frontline fetch failed', err);
4066        return false;
4067    }
4068    if (stale()) return false;
4069
4070    // Mid base-style swap addSource throws "Style is not done loading". Wait for the swap's
4071    // idle: its _restoreOverlays was registered first, so it runs before this continuation and
4072    // the replace below wins (and keeps the 'Frontline' display name the restore would reset).
4073    // Only a pending swap waits: tiles still loading do not block addSource, and waiting on them
4074    // would delay the per-conflict page's placemark load behind the map-init callbacks.
4075    if (instance._styleSwapPending) {
4076        await new Promise(resolve => map.once('idle', resolve));
4077        if (stale()) return false;
4078    }
4079
4080    const sourceId = 'frontlines';
4081    const layerId = 'frontlines-line';
4082    const hitLayerId = 'frontlines-hit';
4083
4084    try {
4085        // Replace semantics: a restored or previous frontline (another scope's) goes first.
4086        _removeFrontlineLayers(instance);
4087
4088        instance.frontlineMultiConflict = multiConflict;
4089        instance.frontlineFeatures = geojson.features ?? [];
4090        instance.frontlineConflictNames = multiConflict
4091            ? new Map((geojson.conflicts ?? []).map(c => [c.conflict, c.conflictName || c.conflict]))
4092            : null;
4093        instance.frontlineVisible = true;
4094        _buildFrontlineDateIndex(instance);
4095
4096        instance.originalSourceData[sourceId] = geojson;
4097
4098        map.addSource(sourceId, {
4099            type: 'geojson',
4100            data: geojson
4101        });
4102
4103        // Invisible wider line for easier click targeting
4104        map.addLayer({
4105            id: hitLayerId,
4106            type: 'line',
4107            source: sourceId,
4108            paint: {
4109                'line-color': 'transparent',
4110                'line-width': 14
4111            }
4112        });
4113
4114        map.addLayer({
4115            id: layerId,
4116            type: 'line',
4117            source: sourceId,
4118            layout: {
4119                'line-cap': 'round',
4120                'line-join': 'round'
4121            },
4122            paint: {
4123                'line-color': '#e53935',
4124                'line-width': 3,
4125                'line-opacity': 0.7
4126            }
4127        });
4128
4129        instance.frontlineLayerIds = [layerId, hitLayerId];
4130        // Overlay display names are listed in GlobalLayerNames.Overlays (server), which allow-lists
4131        // a shared global bookmark's HiddenLayers; keep the two in step.
4132        instance.layerDisplayNames[layerId] = 'Frontline';
4133        instance.layerColors[layerId] = '#e53935';
4134        _raiseMentionsLayers(map); // no-op unless the mentions overlay is already loaded
4135
4136        // Apply frontline-specific filter if timeline is active
4137        if (instance.lastDateFilterStart && instance.lastDateFilterEnd) {
4138            _buildFrontlineFilter(instance, instance.lastDateFilterStart, instance.lastDateFilterEnd);
4139            const flFilter = instance.frontlineDateFilter || instance.dateFilter;
4140            if (flFilter) {
4141                map.setFilter(layerId, flFilter);
4142                map.setFilter(hitLayerId, flFilter);
4143            }
4144        } else if (instance.dateFilter) {
4145            map.setFilter(layerId, instance.dateFilter);
4146            map.setFilter(hitLayerId, instance.dateFilter);
4147        }
4148
4149        _applyFrontlineColors(instance);
4150        _registerFrontlineHandlers(instance, hitLayerId);
4151        return true;
4152    } catch (err) {
4153        console.warn('[MapLibre] Frontline add failed', err);
4154        _removeFrontlineLayers(instance);
4155        _resetFrontlineState(instance);
4156        return false;
4157    }
4158}
4159
4160// Removes the frontline layers, their panel metadata and the source; each step is guarded, so it
4161// is safe on a half-added or already-removed frontline.
4162function _removeFrontlineLayers(instance) {
4163    const { map } = instance;
4164    for (const id of ['frontlines-line', 'frontlines-hit']) {
4165        if (map.getLayer(id)) map.removeLayer(id);
4166        delete instance.layerDisplayNames?.[id];
4167        delete instance.layerColors?.[id];
4168    }
4169    if (map.getSource('frontlines')) map.removeSource('frontlines');
4170}
4171
4172// Forgets the loaded frontline, so _restoreOverlays does not re-add it after a base-style swap.
4173function _resetFrontlineState(instance) {
4174    instance.frontlineFeatures = null;
4175    instance.frontlineLayerIds = null;
4176    instance.frontlineMultiConflict = false;
4177    instance.frontlineDatesByConflict = null;
4178    instance.frontlineClosest = new Map();
4179    instance.frontlineConflictNames = null;
4180    instance.frontlineDateFilter = null;
4181    delete instance.originalSourceData?.['frontlines'];
4182}
4183
4184// Diagnostic snapshot for the UI tests (called through the module, never through window.*).
4185export function getFrontlineState(mapId) {
4186    const instance = instances.get(mapId);
4187    if (!instance?.map) return null;
4188    const features = instance.frontlineFeatures;
4189    return {
4190        loaded: !!instance.map.getSource('frontlines'),
4191        multiConflict: !!instance.frontlineMultiConflict,
4192        features: features?.length ?? -1,
4193        conflicts: [...new Set((features ?? []).map(f => f.properties?.conflict).filter(Boolean))],
4194        closest: Object.fromEntries(instance.frontlineClosest ?? [])
4195    };
4196}
4197
4198export function setFrontlineVisible(mapId, visible) {
4199    const instance = instances.get(mapId);
4200    if (!instance) return;
4201    const { map } = instance;
4202
4203    // Tracked on the instance so _restoreOverlays re-adds the layers in the
4204    // same state after a base-style swap.
4205    instance.frontlineVisible = visible;
4206    for (const id of ['frontlines-line', 'frontlines-hit']) {
4207        if (map.getLayer(id)) map.setLayoutProperty(id, 'visibility', visible ? 'visible' : 'none');
4208    }
4209}
4210
4211export function removeFrontlines(mapId) {
4212    const instance = instances.get(mapId);
4213    if (!instance?.map) return;
4214
4215    // Drops a load still in flight (e.g. the previous scope's fetch during a scope switch).
4216    instance._frontlineLoadToken = (instance._frontlineLoadToken ?? 0) + 1;
4217    _removeFrontlineLayers(instance);
4218    _resetFrontlineState(instance);
4219}
4220
4221// ===== FIRMS HOTSPOTS =====
4222
4223// NOTE: the visible layers are deliberately NOT named with a '-circles'
4224// suffix — setIconZoomThreshold applies a zoom clamp to every layer matching
4225// that placemark naming convention, which would hide the hotspots.
4226const _firmsLayerIds = ['firms-clusters', 'firms-dots', 'firms-hit'];
4227
4228function _addFirmsLayers(instance) {
4229    const { map } = instance;
4230    const sourceId = 'firms';
4231
4232    // Guards the race where a toggle lands between a base-style swap and its
4233    // queued _restoreOverlays — addSource would throw "source already added"
4234    // and abort the rest of the restore.
4235    if (map.getSource(sourceId)) return;
4236
4237    const geojson = { type: 'FeatureCollection', features: instance.firmsFeatures };
4238    // Respect the last toggle state across base-style swaps: a hidden overlay
4239    // must not resurrect itself visible after switching to Dark/Fiord.
4240    const visibility = instance.firmsVisible === false ? 'none' : 'visible';
4241
4242    // The dataset is the full 24h global feed (~100k+ points), so it's
4243    // clustered at low zooms to keep rendering and the visual noise sane.
4244    map.addSource(sourceId, {
4245        type: 'geojson',
4246        data: geojson,
4247        cluster: true,
4248        clusterMaxZoom: 8,
4249        clusterRadius: 40
4250    });
4251
4252    map.addLayer({
4253        id: 'firms-clusters',
4254        type: 'circle',
4255        source: sourceId,
4256        filter: ['has', 'point_count'],
4257        layout: { visibility },
4258        paint: {
4259            'circle-color': '#ff7043',
4260            'circle-opacity': 0.75,
4261            'circle-stroke-width': 1,
4262            'circle-stroke-color': '#bf360c',
4263            'circle-radius': ['interpolate', ['linear'], ['get', 'point_count'],
4264                2, 5,
4265                50, 10,
4266                500, 16,
4267                5000, 24]
4268        }
4269    });
4270
4271    // Invisible wider circle for easier click targeting on single hotspots
4272    map.addLayer({
4273        id: 'firms-hit',
4274        type: 'circle',
4275        source: sourceId,
4276        filter: ['!', ['has', 'point_count']],
4277        layout: { visibility },
4278        paint: { 'circle-color': 'transparent', 'circle-radius': 10 }
4279    });
4280
4281    map.addLayer({
4282        id: 'firms-dots',
4283        type: 'circle',
4284        source: sourceId,
4285        filter: ['!', ['has', 'point_count']],
4286        layout: { visibility },
4287        paint: {
4288            'circle-radius': ['interpolate', ['linear'], ['zoom'], 4, 2, 8, 3.5, 12, 6],
4289            'circle-color': ['match', ['get', 'conf'],
4290                'high', '#ff3d00',
4291                'low', '#ffb300',
4292                /* nominal */ '#ff7043'],
4293            'circle-opacity': 0.85,
4294            'circle-stroke-width': 0.5,
4295            'circle-stroke-color': '#000000',
4296            'circle-stroke-opacity': 0.35
4297        }
4298    });
4299
4300    _raiseMentionsLayers(map); // no-op unless the mentions overlay is already loaded
4301
4302    // Delegated listeners live on the Map (not the style) and keep working
4303    // when a layer with the same id is re-added — register exactly once or
4304    // popups would stack one copy per base-style swap / conflict change.
4305    if (!instance._firmsHandlersRegistered) {
4306        _registerFirmsHandlers(instance, 'firms-hit');
4307        instance._firmsHandlersRegistered = true;
4308    }
4309}
4310
4311function _registerFirmsHandlers(instance, hitLayerId) {
4312    const { map } = instance;
4313    map.on('click', hitLayerId, (e) => {
4314        if (!e.features?.length) return;
4315        const props = e.features[0].properties;
4316        const acq = props.acq ? new Date(props.acq) : null;
4317        // Date AND time must both be rendered in UTC — a local-time date part
4318        // would roll late-evening detections into the next day for UTC+ users.
4319        const when = acq
4320            ? acq.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric', timeZone: 'UTC' }).toUpperCase()
4321              + ' ' + acq.toISOString().slice(11, 16) + ' UTC'
4322            : 'Unknown time';
4323        const conf = props.conf ? props.conf.charAt(0).toUpperCase() + props.conf.slice(1) : null;
4324        const rows = [
4325            `<div style="font-size:13px;font-weight:600;">\u{1F525} ${when}</div>`,
4326            props.sat ? `<div style="font-size:12px;color:#888;">VIIRS · ${props.sat} · ${props.dn === 'D' ? 'Day' : 'Night'}</div>` : '',
4327            conf ? `<div style="font-size:12px;color:#888;">Confidence: ${conf}</div>` : '',
4328            props.frp != null ? `<div style="font-size:12px;color:#888;">Fire power: ${props.frp} MW</div>` : ''
4329        ];
4330        // .maplibregl-popup-content has padding stripped globally (the
4331        // placemark popup brings its own container), so wrap in overlay-popup.
4332        new maplibregl.Popup({ closeOnClick: true, maxWidth: '220px' })
4333            .setLngLat(e.lngLat)
4334            .setHTML(`<div class="overlay-popup">${rows.join('')}</div>`)
4335            .addTo(map);
4336    });
4337    map.on('mouseenter', hitLayerId, () => { map.getCanvas().style.cursor = 'pointer'; });
4338    map.on('mouseleave', hitLayerId, () => { map.getCanvas().style.cursor = ''; });
4339
4340    // Clicking a cluster zooms to its expansion level
4341    map.on('click', 'firms-clusters', async (e) => {
4342        const feature = e.features?.[0];
4343        if (!feature) return;
4344        const source = map.getSource('firms');
4345        if (!source) return;
4346        const zoom = await source.getClusterExpansionZoom(feature.properties.cluster_id);
4347        map.easeTo({ center: feature.geometry.coordinates, zoom });
4348    });
4349    map.on('mouseenter', 'firms-clusters', () => { map.getCanvas().style.cursor = 'pointer'; });
4350    map.on('mouseleave', 'firms-clusters', () => { map.getCanvas().style.cursor = ''; });
4351}
4352
4353export async function loadFirms(mapId, url) {
4354    const instance = instances.get(mapId);
4355    if (!instance) return 0;
4356
4357    const response = await fetch(url, { credentials: 'same-origin' });
4358    if (!response.ok) {
4359        console.warn(`[MapLibre] FIRMS fetch failed: ${response.status}`);
4360        return 0;
4361    }
4362    const geojson = await response.json();
4363
4364    instance.firmsFeatures = geojson.features ?? [];
4365    instance.firmsVisible = true;
4366    _addFirmsLayers(instance);
4367    return instance.firmsFeatures.length;
4368}
4369
4370export function setFirmsVisible(mapId, visible) {
4371    const instance = instances.get(mapId);
4372    if (!instance) return;
4373    const { map } = instance;
4374
4375    // Tracked on the instance so _restoreOverlays re-adds the layers in the
4376    // same state after a base-style swap.
4377    instance.firmsVisible = visible;
4378    for (const id of _firmsLayerIds) {
4379        if (map.getLayer(id)) map.setLayoutProperty(id, 'visibility', visible ? 'visible' : 'none');
4380    }
4381}
4382
4383export function removeFirms(mapId) {
4384    const instance = instances.get(mapId);
4385    if (!instance) return;
4386    const { map } = instance;
4387
4388    for (const id of _firmsLayerIds) {
4389        if (map.getLayer(id)) map.removeLayer(id);
4390    }
4391    if (map.getSource('firms')) map.removeSource('firms');
4392    instance.firmsFeatures = null;
4393}
4394
4395// ===== MENTIONS (unprocessed @geoconfirmed mentions with coordinates) =====
4396//
4397// Admin-only triage overlay: every mention that still has no Discord/Doubt/Wrong/Map
4398// flag and carries coordinates, served by /api/mention/geojson. Drawn in the app's
4399// primary purple and deliberately translucent so a regular placemark underneath
4400// stays visible — the whole point is spotting mentions that are already mapped.
4401// Same lifecycle rules as FIRMS: layer ids must not end in '-circles'/'-points'
4402// (setIconZoomThreshold would clamp them), and the source is re-added from the
4403// cached features after a base-style swap.
4404//
4405// The timeline window applies to mentions too (by the mention's own date), but
4406// unlike placemarks it is applied to the DATA, not the layer: the source is
4407// clustered, and cluster counts come from whatever is in the source regardless
4408// of layer filters, so a layer filter would leave clusters counting mentions the
4409// window had excluded. instance.mentionsFeatures always holds the full set;
4410// the source only ever gets _visibleMentionFeatures(instance).
4411
4412const _mentionsLayerIds = ['mentions-clusters', 'mentions-cluster-count', 'mentions-hit', 'mentions-dots'];
4413const MENTIONS_COLOR = '#7b5dc9';
4414const MENTIONS_STROKE = '#5a3da0';
4415
4416function _addMentionsLayers(instance) {
4417    const { map } = instance;
4418    const sourceId = 'mentions';
4419
4420    // Guards the toggle-between-setStyle-and-_restoreOverlays race (see _addFirmsLayers).
4421    // The source can already be there because a load landed in that window, in which
4422    // case the placemarks were re-added after it — so put the mentions back on top.
4423    if (map.getSource(sourceId)) {
4424        _raiseMentionsLayers(map);
4425        return;
4426    }
4427
4428    const geojson = { type: 'FeatureCollection', features: _visibleMentionFeatures(instance) };
4429    const visibility = instance.mentionsVisible === false ? 'none' : 'visible';
4430
4431    // Far fewer points than FIRMS, so clusters break apart earlier (zoom 11) —
4432    // at street zooms every mention should stand on its own.
4433    map.addSource(sourceId, {
4434        type: 'geojson',
4435        data: geojson,
4436        cluster: true,
4437        clusterMaxZoom: 11,
4438        clusterRadius: 40
4439    });
4440
4441    map.addLayer({
4442        id: 'mentions-clusters',
4443        type: 'circle',
4444        source: sourceId,
4445        filter: ['has', 'point_count'],
4446        layout: { visibility },
4447        paint: {
4448            'circle-color': MENTIONS_COLOR,
4449            'circle-opacity': 0.55,
4450            'circle-stroke-width': 1.5,
4451            'circle-stroke-color': MENTIONS_STROKE,
4452            'circle-stroke-opacity': 0.9,
4453            'circle-radius': ['step', ['get', 'point_count'],
4454                12, 10, 16, 50, 22, 200, 28]
4455        }
4456    });
4457
4458    map.addLayer({
4459        id: 'mentions-cluster-count',
4460        type: 'symbol',
4461        source: sourceId,
4462        filter: ['has', 'point_count'],
4463        layout: {
4464            visibility,
4465            'text-field': '{point_count_abbreviated}',
4466            'text-size': 11,
4467            'text-font': ['Open Sans Regular'],
4468            'text-allow-overlap': true
4469        },
4470        paint: {
4471            'text-color': '#ffffff',
4472            'text-halo-color': MENTIONS_STROKE,
4473            'text-halo-width': 1
4474        }
4475    });
4476
4477    // Invisible wider circle for easier click targeting on single mentions
4478    map.addLayer({
4479        id: 'mentions-hit',
4480        type: 'circle',
4481        source: sourceId,
4482        filter: ['!', ['has', 'point_count']],
4483        layout: { visibility },
4484        paint: { 'circle-color': 'transparent', 'circle-radius': 14 }
4485    });
4486
4487    map.addLayer({
4488        id: 'mentions-dots',
4489        type: 'circle',
4490        source: sourceId,
4491        filter: ['!', ['has', 'point_count']],
4492        layout: { visibility },
4493        paint: {
4494            'circle-radius': ['interpolate', ['linear'], ['zoom'], 4, 5, 10, 8, 14, 11],
4495            'circle-color': MENTIONS_COLOR,
4496            // Translucent fill: a placemark icon/circle underneath must stay legible.
4497            'circle-opacity': 0.45,
4498            'circle-stroke-width': 1.5,
4499            'circle-stroke-color': MENTIONS_STROKE,
4500            'circle-stroke-opacity': 0.85
4501        }
4502    });
4503
4504    _raiseMentionsLayers(map);
4505
4506    // Register exactly once — delegated listeners survive layer re-adds (see FIRMS).
4507    if (!instance._mentionsHandlersRegistered) {
4508        _registerMentionsHandlers(instance, 'mentions-hit');
4509        instance._mentionsHandlersRegistered = true;
4510    }
4511}
4512
4513// Same test the placemark layers apply through their MapLibre filter
4514// (['any', date == null, ['all', date >= start, date <= end]]): a plain string
4515// compare of the ISO 'date' property against the timeline's start/end strings
4516// ("yyyy-MM-ddT00:00:00" / "yyyy-MM-ddT23:59:59.9999999"). Mention dates carry a
4517// trailing 'Z' that placemark dates don't, so only the first 19 characters take
4518// part — otherwise a mention at exactly 23:59:59 would sort after the end bound.
4519function _mentionInDateWindow(dateProp, start, end) {
4520    if (!start || !end) return true;
4521    if (!dateProp) return true;
4522    const key = String(dateProp).slice(0, 19);
4523    return key >= start && key <= end;
4524}
4525
4526function _visibleMentionFeatures(instance) {
4527    const all = instance.mentionsFeatures ?? [];
4528    const start = instance.lastDateFilterStart, end = instance.lastDateFilterEnd;
4529    if (!start || !end) return all;
4530    return all.filter(f => _mentionInDateWindow(f.properties?.date, start, end));
4531}
4532
4533// Re-feeds the clustered source when the timeline window changes. No-op until
4534// the overlay has loaded; the load paths apply the window themselves.
4535function _applyMentionsDateFilter(instance) {
4536    if (!instance.mentionsFeatures) return;
4537    const source = instance.map.getSource('mentions');
4538    if (!source) return;
4539    source.setData({ type: 'FeatureCollection', features: _visibleMentionFeatures(instance) });
4540}
4541
4542// Keeps the overlay above everything it is meant to be compared against. A conflict
4543// switch removes and re-adds the placemark layers (appended on top of the stack), and a
4544// base-style swap can re-add them after a load that landed mid-swap — either way the
4545// translucent mention dots would end up hidden under the placemark icons while
4546// _mentionUnderCursor (which ignores occlusion) still let them claim the click. So this
4547// runs wherever the order can change: after the mention layers are added, after a
4548// refresh, whenever they are made visible, and from the two 'placemarks-all' loaders —
4549// never only on the success path of a fetch. No-op while the layers don't exist. Only
4550// the user's own drawings and measurements stay above the mentions.
4551function _raiseMentionsLayers(map) {
4552    const ids = (map.getStyle()?.layers ?? []).map(l => l.id);
4553    // Everything the mentions must sit above. Draw/measure layers are only a valid
4554    // anchor when they are themselves above all of these — after a conflict switch the
4555    // placemark layers get re-appended on top of older drawings, and anchoring below
4556    // such a drawing would pin the mentions under the placemarks.
4557    let topData = -1;
4558    ids.forEach((id, i) => {
4559        if (id.startsWith('placemarks-') || id.startsWith('frontlines-') || id.startsWith('firms-')) topData = i;
4560    });
4561    const beforeId = ids.find((id, i) => i > topData && (id.startsWith('draw-') || id.startsWith('measure-')));
4562    for (const id of _mentionsLayerIds) {
4563        if (map.getLayer(id)) map.moveLayer(id, beforeId);
4564    }
4565}
4566
4567// Mention markers sit on top of placemarks and are translucent on purpose, so a
4568// click on one must open the mention popup rather than the placemark's. MapLibre
4569// runs per-layer click handlers in registration order and the placemark handlers
4570// are usually registered first, so they call this and yield when a visible mention
4571// marker (dot or cluster) is under the cursor.
4572function _mentionUnderCursor(instance, e) {
4573    const { map } = instance;
4574    if (instance.mentionsVisible === false) return false;
4575    const layers = ['mentions-hit', 'mentions-clusters'].filter(id => map.getLayer(id));
4576    if (layers.length === 0) return false;
4577    return map.queryRenderedFeatures(e.point, { layers }).length > 0;
4578}
4579
4580function _escapeHtml(value) {
4581    return String(value ?? '')
4582        .replace(/&/g, '&amp;')
4583        .replace(/</g, '&lt;')
4584        .replace(/>/g, '&gt;')
4585        .replace(/"/g, '&quot;')
4586        .replace(/'/g, '&#39;');
4587}
4588
4589// Placemarks within a few pixels of the mention — the reason this overlay exists is
4590// to tell "already mapped" mentions from new ones, so the popup lists them. Taken
4591// from the conflict's full feature set (instance.allFeatures), NOT the rendered
4592// layers: the map applies a default timeline window and faction toggles as layer
4593// filters, and an older placemark hidden by them still means the mention is already
4594// mapped. Each item says whether the current filters hide it.
4595function _placemarksNear(instance, point) {
4596    const { map } = instance;
4597    const all = instance.allFeatures;
4598    if (!all || all.length === 0) return [];
4599
4600    const pad = 12;
4601    // A screen box around the mention's projected position. The map can be rotated and pitched, so the
4602    // lng/lat bounding box is taken over all four corners (over-inclusive but never
4603    // degenerate) to cheaply pre-filter the ~100k features with plain arithmetic, and
4604    // each survivor is then confirmed with an exact screen-space check.
4605    const corners = [
4606        map.unproject([point.x - pad, point.y - pad]),
4607        map.unproject([point.x + pad, point.y - pad]),
4608        map.unproject([point.x - pad, point.y + pad]),
4609        map.unproject([point.x + pad, point.y + pad])
4610    ];
4611    const minLng = Math.min(...corners.map(c => c.lng)), maxLng = Math.max(...corners.map(c => c.lng));
4612    const minLat = Math.min(...corners.map(c => c.lat)), maxLat = Math.max(...corners.map(c => c.lat));
4613
4614    const byId = new Map();
4615    for (const f of all) {
4616        const c = f.geometry?.coordinates;
4617        if (!c) continue;
4618        if (c[0] < minLng || c[0] > maxLng || c[1] < minLat || c[1] > maxLat) continue;
4619        const px = map.project(c);
4620        if (Math.abs(px.x - point.x) <= pad && Math.abs(px.y - point.y) <= pad) {
4621            const id = f.properties?.id ?? f.id;
4622            if (id != null && !byId.has(id)) {
4623                byId.set(id, {
4624                    id: String(id),
4625                    icon: f.properties?.icon ?? null,
4626                    color: f.properties?.color ?? null,
4627                    date: f.properties?.date ?? null,
4628                    unapproved: f.properties?.unapproved === 1,
4629                    hidden: true
4630                });
4631            }
4632        }
4633    }
4634    if (byId.size === 0) return [];
4635
4636    // Mark the ones the current filters actually render in the same box.
4637    const layers = (instance.placemarkLayerIds ?? []).filter(id => map.getLayer(id));
4638    if (layers.length > 0) {
4639        const rendered = map.queryRenderedFeatures(
4640            [[point.x - pad, point.y - pad], [point.x + pad, point.y + pad]], { layers });
4641        for (const f of rendered) {
4642            const item = byId.get(String(f.properties?.id ?? f.id));
4643            if (item) item.hidden = false;
4644        }
4645    }
4646
4647    // Newest first; undated last.
4648    return [...byId.values()].sort((a, b) => (b.date ?? '').localeCompare(a.date ?? ''));
4649}
4650
4651// "16 SEP 2026" — the DTG convention the datatable uses for placemark names
4652// (DateFormats.DateOnly, upper-cased; the table stores dates at midnight). Rows
4653// from before the date-only convention still carry a time, shown as the older
4654// "16 1030 SEP 2026" form so the label matches what the table shows for them.
4655// Formatted from the naive "yyyy-MM-ddTHH:mm:ss" string the placemark GeoJSON
4656// carries, without going through Date, so no timezone shift creeps in.
4657const _DTG_MONTHS = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'];
4658function _formatDtg(dateStr) {
4659    const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(String(dateStr ?? ''));
4660    if (!m) return null;
4661    const month = _DTG_MONTHS[Number(m[2]) - 1];
4662    if (!month) return null;
4663    const hm = m[4] + m[5];
4664    return hm === '0000' ? `${m[3]} ${month} ${m[1]}` : `${m[3]} ${hm} ${month} ${m[1]}`;
4665}
4666
4667// Where the mention was posted, from its link — the same classification the
4668// Mentions page uses for its source chips (twitter.com/x.com, bsky.app, t.me).
4669function _mentionSource(link) {
4670    if (typeof link !== 'string') return null;
4671    if (/^https?:\/\/(?:www\.|mobile\.)?(?:twitter|x)\.com\//i.test(link)) return 'x';
4672    if (/^https?:\/\/(?:www\.)?bsky\.app\//i.test(link)) return 'bluesky';
4673    if (/^https?:\/\/(?:www\.)?t\.me\//i.test(link)) return 'telegram';
4674    return null;
4675}
4676
4677// Brand glyphs (Simple Icons, CC0). X follows the text colour so it stays visible
4678// on the dark themes; the other two use their brand colours.
4679const MENTION_SOURCE_GLYPHS = {
4680    x: {
4681        label: 'X', color: 'currentColor',
4682        path: 'M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z'
4683    },
4684    bluesky: {
4685        label: 'Bluesky', color: '#0085ff',
4686        path: 'M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.206-.659-.298-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8Z'
4687    },
4688    telegram: {
4689        label: 'Telegram', color: '#26a5e4',
4690        path: 'M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z'
4691    }
4692};
4693
4694function _mentionSourceHtml(source) {
4695    const g = MENTION_SOURCE_GLYPHS[source];
4696    if (!g) return '';
4697    return `<span class="mention-popup-source" title="${g.label}">`
4698        + `<svg viewBox="0 0 24 24" width="14" height="14" fill="${g.color}" aria-hidden="true"><path d="${g.path}"/></svg>`
4699        + `<span style="font-size:11px;color:#888;">${g.label}</span></span>`;
4700}
4701
4702// Path segments on the three hosts that are not user handles.
4703const _MENTION_RESERVED_SEGMENTS = new Set(['i', 'home', 'search', 'intent', 'hashtag', 'explore', 'share', 'c', 's', 'joinchat', 'addstickers', 'profile']);
4704
4705// The author's handle. The stored Username is unreliable (thousands of rows hold
4706// the literal "status", parsed from the wrong segment of an x.com/<user>/status/<id>
4707// link), so the link is the source of truth; Username and Name are fallbacks.
4708function _mentionHandle(props) {
4709    const link = typeof props.link === 'string' ? props.link : '';
4710    let m;
4711    if ((m = /^https?:\/\/(?:www\.|mobile\.)?(?:twitter|x)\.com\/([A-Za-z0-9_]{1,15})(?:[/?#]|$)/i.exec(link))
4712        && !_MENTION_RESERVED_SEGMENTS.has(m[1].toLowerCase())) return '@' + m[1];
4713    // The Aviary poller falls back to a did:plc:… profile link for overlong handles
4714    // while keeping the real handle in Username, so a DID is "no handle in the link".
4715    if ((m = /^https?:\/\/(?:www\.)?bsky\.app\/profile\/([^/?#]+)/i.exec(link))
4716        && !/^did:/i.test(m[1])) return '@' + m[1];
4717    if ((m = /^https?:\/\/(?:www\.)?t\.me\/(?:s\/)?([A-Za-z0-9_]{3,})(?:[/?#]|$)/i.exec(link))
4718        && !_MENTION_RESERVED_SEGMENTS.has(m[1].toLowerCase())) return '@' + m[1];
4719    if (props.username && props.username.toLowerCase() !== 'status') return '@' + props.username;
4720    if (props.name) return props.name;
4721    return 'Unknown';
4722}
4723
4724function _registerMentionsHandlers(instance, hitLayerId) {
4725    const { map } = instance;
4726    map.on('click', hitLayerId, (e) => {
4727        if (!e.features?.length) return;
4728        // Claim the click so any placemark handler registered after us stays quiet.
4729        e.originalEvent._placemarkHandled = true;
4730
4731        const props = e.features[0].properties;
4732        const date = props.date ? new Date(props.date) : null;
4733        const when = date && !isNaN(date)
4734            ? date.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric', timeZone: 'UTC' }).toUpperCase()
4735              + ' ' + date.toISOString().slice(11, 16) + ' UTC'
4736            : null;
4737        const handle = _mentionHandle(props);
4738        // Telegram rows carry the channel title in Name; X rows just repeat the handle.
4739        const name = props.name && props.name.replace(/^@/, '').toLowerCase() !== handle.replace(/^@/, '').toLowerCase()
4740            ? props.name : null;
4741        const fullText = props.text || '';
4742        const text = fullText.length > 320 ? fullText.slice(0, 320).trimEnd() + '\u2026' : fullText;
4743        const link = typeof props.link === 'string' && /^https?:\/\//i.test(props.link) ? props.link : null;
4744
4745        // Anchor on the mention's own position: the hit circle is 14px wide, so the
4746        // click can land well off the dot's centre, and the popup is about the mention.
4747        const anchor = map.project(e.features[0].geometry.coordinates);
4748        const placemarks = _placemarksNear(instance, anchor);
4749        const maxRows = 10;
4750        const rows = placemarks.slice(0, maxRows).map(pm => {
4751            const notes = [];
4752            if (pm.hidden) notes.push('hidden by filters');
4753            if (pm.unapproved) notes.push('unapproved');
4754            return `<button type="button" class="mention-popup-placemark${pm.hidden ? ' is-hidden' : ''}" data-copy-id="${_escapeHtml(pm.id)}" title="Copy placemark ID ${_escapeHtml(pm.id)}">`
4755                // Faction-coloured dot under the icon: shows the faction immediately (and
4756                // still if the icon image is slow or missing); the rendered PNG is the same
4757                // coloured circle with the glyph on top, so it covers the dot exactly.
4758                + `<span class="mention-popup-placemark-icon"${pm.color && /^#[0-9a-f]{3,8}$/i.test(pm.color) ? ` style="background:${pm.color}"` : ''}>`
4759                + (pm.icon ? `<img src="${_escapeHtml(pm.icon)}" alt="" loading="lazy">` : '')
4760                + `</span>`
4761                + `<span>${_escapeHtml(_formatDtg(pm.date) ?? 'No DTG')}</span>`
4762                + (notes.length ? `<span class="mention-popup-placemark-note">${_escapeHtml(notes.join(', '))}</span>` : '')
4763                + `</button>`;
4764        });
4765        if (placemarks.length > maxRows) {
4766            rows.push(`<div style="font-size:11px;color:#888;padding:2px 6px;">+${placemarks.length - maxRows} more</div>`);
4767        }
4768
4769        const html = [
4770            `<div style="font-size:13px;font-weight:600;display:flex;align-items:center;gap:6px;flex-wrap:wrap;">${_mentionSourceHtml(_mentionSource(props.link))}<span>${_escapeHtml(handle)}</span></div>`,
4771            name ? `<div style="font-size:12px;color:#888;">${_escapeHtml(name)}</div>` : '',
4772            when ? `<div style="font-size:12px;color:#888;">${when}${props.conflict ? ' \u00B7 ' + _escapeHtml(props.conflict) : ''}</div>` : '',
4773            text ? `<div style="font-size:12px;margin-top:6px;white-space:pre-wrap;overflow-wrap:anywhere;">${_escapeHtml(text)}</div>` : '',
4774            props.remark ? `<div style="font-size:12px;color:#888;margin-top:4px;"><strong>Remark:</strong> ${_escapeHtml(props.remark)}</div>` : '',
4775            placemarks.length > 0
4776                ? `<div style="font-size:12px;color:var(--mud-palette-warning);margin-top:8px;">\u26A0 ${placemarks.length} placemark${placemarks.length === 1 ? '' : 's'} at this spot \u00B7 click to copy ID</div>`
4777                  + `<div class="mention-popup-placemarks">${rows.join('')}</div>`
4778                : `<div style="font-size:12px;color:#888;margin-top:8px;">No placemarks at this spot</div>`,
4779            link ? `<div style="font-size:12px;margin-top:8px;"><a href="${_escapeHtml(link)}" target="_blank" rel="noopener noreferrer">Open post \u2197</a></div>` : ''
4780        ];
4781        // Anchor on the marker itself, not on wherever inside the 14px hit circle
4782        // the click landed, and lift the tip clear of the dot (radius 5-11px by zoom).
4783        // Rendered-feature geometry is always the canonical world copy; shift it
4784        // toward the clicked lng so a dot on a wrapped copy (world zoom on a wide
4785        // viewport) gets its popup on the copy that was actually clicked. The
4786        // placemark lookup above stays canonical: allFeatures coordinates are too.
4787        const popupLngLat = e.features[0].geometry.coordinates.slice();
4788        while (Math.abs(e.lngLat.lng - popupLngLat[0]) > 180) {
4789            popupLngLat[0] += e.lngLat.lng > popupLngLat[0] ? 360 : -360;
4790        }
4791        const popup = new maplibregl.Popup({ closeOnClick: true, maxWidth: '320px', offset: 12 })
4792            .setLngLat(popupLngLat)
4793            .setHTML(`<div class="overlay-popup mention-popup">${html.join('')}</div>`)
4794            .addTo(map);
4795        _initMentionPopupCopy(popup.getElement());
4796    });
4797    map.on('mouseenter', hitLayerId, () => { map.getCanvas().style.cursor = 'pointer'; });
4798    map.on('mouseleave', hitLayerId, () => { map.getCanvas().style.cursor = ''; });
4799
4800    // Clicking a cluster zooms to its expansion level
4801    map.on('click', 'mentions-clusters', async (e) => {
4802        const feature = e.features?.[0];
4803        if (!feature) return;
4804        e.originalEvent._placemarkHandled = true;
4805        const source = map.getSource('mentions');
4806        if (!source) return;
4807        const zoom = await source.getClusterExpansionZoom(feature.properties.cluster_id);
4808        map.easeTo({ center: feature.geometry.coordinates, zoom });
4809    });
4810    map.on('mouseenter', 'mentions-clusters', () => { map.getCanvas().style.cursor = 'pointer'; });
4811    map.on('mouseleave', 'mentions-clusters', () => { map.getCanvas().style.cursor = ''; });
4812}
4813
4814// (Re)loads the overlay. Unlike FIRMS this is meant to be called again on every
4815// toggle-on / conflict load: the admin processes mentions on the Mentions page and
4816// comes back to the map expecting the dots to be gone, so an existing source is
4817// refreshed in place rather than kept.
4818export async function loadMentions(mapId, url) {
4819    const instance = instances.get(mapId);
4820    if (!instance) return 0;
4821
4822    // Record the intent up front rather than after the fetch: a toggle-off that
4823    // lands while the request is in flight flips this back via setMentionsVisible,
4824    // and the code below honours whatever the flag says once the data arrives.
4825    instance.mentionsVisible = true;
4826
4827    let geojson;
4828    try {
4829        const response = await fetch(url, { credentials: 'same-origin' });
4830        if (!response.ok) {
4831            console.warn(`[MapLibre] Mentions fetch failed: ${response.status}`);
4832            return 0;
4833        }
4834        geojson = await response.json();
4835    } catch (err) {
4836        console.warn('[MapLibre] Mentions fetch error:', err);
4837        return 0;
4838    }
4839
4840    // The map may have been disposed while the fetch was in flight.
4841    if (instances.get(mapId) !== instance) return 0;
4842
4843    instance.mentionsFeatures = geojson.features ?? [];
4844
4845    const source = instance.map.getSource('mentions');
4846    if (source) {
4847        source.setData({ type: 'FeatureCollection', features: _visibleMentionFeatures(instance) });
4848        setMentionsVisible(mapId, instance.mentionsVisible !== false);
4849        _raiseMentionsLayers(instance.map);
4850    } else {
4851        _addMentionsLayers(instance); // honours instance.mentionsVisible and the date window
4852    }
4853    return instance.mentionsFeatures.length;
4854}
4855
4856export function setMentionsVisible(mapId, visible) {
4857    const instance = instances.get(mapId);
4858    if (!instance) return;
4859    const { map } = instance;
4860
4861    // Tracked on the instance so _restoreOverlays re-adds the layers in the
4862    // same state after a base-style swap. Safe to call before the layers exist:
4863    // it then only records the intent, which an in-flight loadMentions honours.
4864    instance.mentionsVisible = visible;
4865    for (const id of _mentionsLayerIds) {
4866        if (map.getLayer(id)) map.setLayoutProperty(id, 'visibility', visible ? 'visible' : 'none');
4867    }
4868    // Showing existing layers must also put them back on top: they may have been
4869    // hidden while a conflict switch re-added the placemarks above them.
4870    if (visible) _raiseMentionsLayers(map);
4871}
4872
4873export function removeMentions(mapId) {
4874    const instance = instances.get(mapId);
4875    if (!instance) return;
4876    const { map } = instance;
4877
4878    for (const id of _mentionsLayerIds) {
4879        if (map.getLayer(id)) map.removeLayer(id);
4880    }
4881    if (map.getSource('mentions')) map.removeSource('mentions');
4882    instance.mentionsFeatures = null;
4883}
4884
4885// ===== COPERNICUS / SENTINEL-2 IMAGERY OVERLAY =====
4886//
4887// A single raster layer fed by the server tile proxy
4888// (/api/tiles/copernicus/{date}/{z}/{x}/{y}), which injects the CDSE Sentinel Hub
4889// instance id and caches tiles in MinIO. The layer is inserted *below* every
4890// data layer (buildings, placemarks, frontlines, FIRMS, drawings) so the imagery
4891// acts as a basemap while markers stay on top.
4892
4893// First data/overlay layer in the current style — the imagery is inserted before
4894// it so it sits just above the basemap. Works for both raster and vector bases.
4895function _copernicusInsertBeforeId(map) {
4896    const managed = new Set([
4897        '3d-buildings', 'frontlines-hit', 'frontlines-line',
4898        'firms-clusters', 'firms-hit', 'firms-dots',
4899        'mentions-clusters', 'mentions-cluster-count', 'mentions-hit', 'mentions-dots',
4900        'draw-fills-layer', 'draw-lines-layer', 'draw-points-layer', 'draw-labels-layer',
4901        'draw-preview-line', 'draw-preview-fill',
4902        'measure-lines-layer', 'measure-points-layer', 'measure-labels-layer', 'measure-fill-layer'
4903    ]);
4904    const layers = map.getStyle()?.layers ?? [];
4905    for (const l of layers) {
4906        if (managed.has(l.id) || l.id.startsWith('placemarks-')) return l.id;
4907    }
4908    return undefined; // nothing above the base yet — add on top
4909}
4910
4911// --- Copernicus tile-loading state (drives the spinner next to the date arrows) ---
4912
4913function _setCopernicusLoading(instance, loading) {
4914    if (loading === instance._copernicusLoading) return;
4915    instance._copernicusLoading = loading;
4916    try { instance.dotnetRef.invokeMethodAsync('OnCopernicusLoading', loading); } catch { /* disposed */ }
4917}
4918
4919// Clear the loading flag once the source has finished loading its in-view tiles.
4920function _refreshCopernicusLoading(instance) {
4921    if (!instance._copernicusLoading) return;
4922    const { map } = instance;
4923    if (!map.getSource('copernicus') || map.isSourceLoaded('copernicus')) {
4924        _setCopernicusLoading(instance, false);
4925    }
4926}
4927
4928function _registerCopernicusLoadingHandlers(instance) {
4929    if (instance._copernicusLoadingReg) return;
4930    instance._copernicusLoadingReg = true;
4931    const { map } = instance;
4932    // 'data' fires per copernicus tile; 'idle' is the catch-all when everything settles.
4933    map.on('data', (e) => { if (e.sourceId === 'copernicus') _refreshCopernicusLoading(instance); });
4934    map.on('idle', () => _refreshCopernicusLoading(instance));
4935}
4936
4937function _addCopernicusLayer(instance) {
4938    const { map } = instance;
4939    // Guards the base-style-swap race (same reasoning as _addFirmsLayers):
4940    // a toggle landing between setStyle and its queued _restoreOverlays would
4941    // otherwise call addSource twice and throw "source already added".
4942    if (map.getSource('copernicus')) return;
4943
4944    map.addSource('copernicus', {
4945        type: 'raster',
4946        tiles: [instance.copernicusUrl],
4947        tileSize: 256,
4948        // Bound the tile-zoom range we ever request from CDSE:
4949        // - minzoom 7: below this each tile is coarser than Sentinel Hub's 1500 m/px
4950        //   limit for the S2L2A collection (zoom 6 ≈ 1548 m/px), which comes back as an
4951        //   error-text tile — so the layer is simply hidden when zoomed way out instead.
4952        // - maxzoom 17: above this MapLibre OVERZOOMS (reuses zoom-17 tiles scaled up)
4953        //   instead of fetching native 18-21 tiles, so panning/date-scrubbing while
4954        //   zoomed in reuses cached tiles rather than burning fresh CDSE requests.
4955        //   (Sentinel-2 is ~10 m/px ≈ zoom 14 native, so little real detail past ~15.)
4956        minzoom: 7,
4957        maxzoom: 17,
4958        attribution: '© Copernicus Sentinel data — Data Space Ecosystem'
4959    });
4960
4961    // Add the layer on a LATER frame, not in the same tick as addSource. Adding a
4962    // raster source and a layer that references it synchronously races MapLibre's
4963    // source-cache registration: the source never computes its tile coverage and
4964    // loads nothing until the next camera move — so the imagery stays blank right
4965    // after the toggle until the user pans/zooms. Deferring the layer add by a
4966    // couple of animation frames lets the source cache register first, so tiles
4967    // load immediately. (Verified against MapLibre GL v5 in-browser.)
4968    const addLayer = () => {
4969        if (!map.getSource('copernicus') || map.getLayer('copernicus-layer')) return;
4970        const visibility = instance.copernicusVisible === false ? 'none' : 'visible';
4971        map.addLayer({
4972            id: 'copernicus-layer',
4973            type: 'raster',
4974            source: 'copernicus',
4975            layout: { visibility },
4976            paint: { 'raster-opacity': instance.copernicusOpacity ?? 1 }
4977        }, _copernicusInsertBeforeId(map));
4978        _ensureCopernicusAttribution(instance);
4979        _registerCopernicusLoadingHandlers(instance);
4980        if (instance.copernicusVisible !== false) _setCopernicusLoading(instance, true);
4981    };
4982    requestAnimationFrame(() => requestAnimationFrame(addLayer));
4983}
4984
4985export function loadCopernicus(mapId, tileUrl, opacity, visible) {
4986    const instance = instances.get(mapId);
4987    if (!instance) return;
4988    instance.copernicusUrl = tileUrl;
4989    instance.copernicusOpacity = opacity ?? 1;
4990    instance.copernicusVisible = visible !== false;
4991    _addCopernicusLayer(instance);
4992}
4993
4994// Swap the imagery date by repointing the raster source at a new proxy URL.
4995export function updateCopernicusDate(mapId, tileUrl) {
4996    const instance = instances.get(mapId);
4997    if (!instance) return;
4998    instance.copernicusUrl = tileUrl;
4999    const source = instance.map.getSource('copernicus');
5000    if (source && typeof source.setTiles === 'function') {
5001        source.setTiles([tileUrl]);
5002        if (instance.copernicusVisible !== false) _setCopernicusLoading(instance, true);
5003    } else {
5004        _addCopernicusLayer(instance);
5005    }
5006}
5007
5008export function setCopernicusVisible(mapId, visible) {
5009    const instance = instances.get(mapId);
5010    if (!instance) return;
5011    instance.copernicusVisible = visible;
5012    if (instance.map.getLayer('copernicus-layer')) {
5013        instance.map.setLayoutProperty('copernicus-layer', 'visibility', visible ? 'visible' : 'none');
5014    }
5015    if (visible) _ensureCopernicusAttribution(instance);
5016    else { _removeCopernicusAttribution(instance); _setCopernicusLoading(instance, false); }
5017}
5018
5019export function setCopernicusOpacity(mapId, opacity) {
5020    const instance = instances.get(mapId);
5021    if (!instance) return;
5022    instance.copernicusOpacity = opacity;
5023    if (instance.map.getLayer('copernicus-layer')) {
5024        instance.map.setPaintProperty('copernicus-layer', 'raster-opacity', opacity);
5025    }
5026}
5027
5028export function removeCopernicus(mapId) {
5029    const instance = instances.get(mapId);
5030    if (!instance) return;
5031    const { map } = instance;
5032    if (map.getLayer('copernicus-layer')) map.removeLayer('copernicus-layer');
5033    if (map.getSource('copernicus')) map.removeSource('copernicus');
5034    instance.copernicusUrl = null;
5035    _removeCopernicusAttribution(instance);
5036    _setCopernicusLoading(instance, false);
5037}
5038
5039// Legal attribution badge, shown only while the imagery is visible. The map runs
5040// with attributionControl:false, so it's added explicitly and torn down on hide.
5041function _ensureCopernicusAttribution(instance) {
5042    if (instance.copernicusVisible === false) { _removeCopernicusAttribution(instance); return; }
5043    if (instance._copernicusAttr) return;
5044    const el = document.createElement('div');
5045    el.className = 'copernicus-attribution';
5046    el.innerHTML = '© <a href="https://dataspace.copernicus.eu" target="_blank" rel="noopener">Copernicus</a> Sentinel data';
5047    el.setAttribute('style', [
5048        'position:absolute', 'bottom:0', 'right:0', 'z-index:1',
5049        'padding:1px 6px', 'font:11px/1.5 sans-serif',
5050        'background:rgba(255,255,255,0.75)', 'color:#333',
5051        'border-top-left-radius:4px'
5052    ].join(';'));
5053    instance.map.getContainer().appendChild(el);
5054    instance._copernicusAttr = el;
5055}
5056
5057function _removeCopernicusAttribution(instance) {
5058    if (instance._copernicusAttr) {
5059        instance._copernicusAttr.remove();
5060        instance._copernicusAttr = null;
5061    }
5062}
5063
5064// Whether the server has a Copernicus instance configured (+ earliest date).
5065// Returns null on failure so the caller simply hides the layer.
5066export async function copernicusConfig(url) {
5067    try {
5068        const r = await fetch(url, { credentials: 'same-origin' });
5069        if (!r.ok) return null;
5070        return await r.json(); // { enabled: bool, minDate: 'YYYY-MM-DD' }
5071    } catch {
5072        return null;
5073    }
5074}
5075
5076// ===== DATA EXPORT =====
5077
5078export function hasPlacemarkData(mapId) {
5079    const instance = instances.get(mapId);
5080    return !!(instance?.allFeatures?.length);
5081}
5082
5083export function getExportFeatureCount(mapId, useFullRange, onlyInPolygons) {
5084    const instance = instances.get(mapId);
5085    if (!instance) return 0;
5086    return _getFilteredFeatures(instance, useFullRange, onlyInPolygons).length;
5087}
5088
5089export function getDrawnPolygonCount(mapId) {
5090    const instance = instances.get(mapId);
5091    if (!instance) return 0;
5092    return _getDrawnPolygons(instance).length;
5093}
5094
5095export function getTimelineDates(mapId) {
5096    const instance = instances.get(mapId);
5097    if (!instance) return null;
5098    if (instance.lastDateFilterStart && instance.lastDateFilterEnd) {
5099        return [instance.lastDateFilterStart, instance.lastDateFilterEnd];
5100    }
5101    return null;
5102}
5103
5104// Drawn shapes that enclose an area (polygon and circle draw types) — not frontlines,
5105// which live in a separate source, and not lines/freehand/markers.
5106function _getDrawnPolygons(instance) {
5107    return (instance.drawFeatures || []).filter(f => f.geometry?.type === 'Polygon');
5108}
5109
5110// Features the Download panel counts and exports client-side: the query-builder result
5111// (never the raw dataset — the Filters panel narrows downloads exactly as it narrows the
5112// map), then the panel's own timeline and drawn-area switches.
5113function _getFilteredFeatures(instance, useFullRange, onlyInPolygons) {
5114    if (!instance.allFeatures) return [];
5115    let features = _filteredPlacemarkFeatures(instance);
5116
5117    if (!useFullRange) {
5118        const startDate = instance.lastDateFilterStart;
5119        const endDate = instance.lastDateFilterEnd;
5120        if (startDate && endDate) {
5121            features = features.filter(f => {
5122                const d = f.properties.date;
5123                if (!d) return true; // include static objects
5124                return d >= startDate && d <= endDate;
5125            });
5126        }
5127    }
5128
5129    if (onlyInPolygons) {
5130        const polygons = _getDrawnPolygons(instance);
5131        if (polygons.length > 0) {
5132            features = features.filter(f => {
5133                const coords = f.geometry?.coordinates;
5134                if (!coords || f.geometry.type !== 'Point') return false;
5135                return polygons.some(p => turf.booleanPointInPolygon(coords, p));
5136            });
5137        }
5138    }
5139
5140    return features;
5141}
5142
5143function _resolveUrl(path) {
5144    if (!path) return path;
5145    if (path.startsWith('http://') || path.startsWith('https://')) return path;
5146    return window.location.origin + (path.startsWith('/') ? '' : '/') + path;
5147}
5148
5149function _triggerDownload(fileName, content, mimeType) {
5150    const blob = new Blob([content], { type: mimeType });
5151    const url = URL.createObjectURL(blob);
5152    const a = document.createElement('a');
5153    a.href = url;
5154    a.download = fileName;
5155    document.body.appendChild(a);
5156    a.click();
5157    document.body.removeChild(a);
5158    URL.revokeObjectURL(url);
5159}
5160
5161export function getViewState(mapId) {
5162    const instance = instances.get(mapId);
5163    if (!instance) return null;
5164    const map = instance.map;
5165    const center = map.getCenter();
5166    return {
5167        centerLng: center.lng,
5168        centerLat: center.lat,
5169        zoom: map.getZoom(),
5170        bearing: map.getBearing(),
5171        pitch: map.getPitch()
5172    };
5173}
5174
5175export function setViewState(mapId, lng, lat, zoom, bearing, pitch) {
5176    const instance = instances.get(mapId);
5177    if (!instance) return;
5178    instance.map.jumpTo({
5179        center: [lng, lat],
5180        zoom: zoom,
5181        bearing: bearing || 0,
5182        pitch: pitch || 0
5183    });
5184}
5185
5186export function exportGeoJson(mapId, fileName, useFullRange, onlyInPolygons) {
5187    const instance = instances.get(mapId);
5188    if (!instance) return;
5189
5190    const features = _getFilteredFeatures(instance, useFullRange, onlyInPolygons).map(f => ({
5191        ...f,
5192        properties: {
5193            ...f.properties,
5194            icon: _resolveUrl(f.properties.icon)
5195        }
5196    }));
5197    const geojson = { type: 'FeatureCollection', features };
5198    const json = JSON.stringify(geojson);
5199    _triggerDownload(fileName, json, 'application/geo+json');
5200}
5201
5202// Downloads a server-built export (KMZ / CSV). Unfiltered exports use a plain
5203// GET so the server can serve its cached copy; filtered exports POST the
5204// timeline range, drawn polygons and the query-builder filter and download the
5205// response blob. `filterJson` is the QueryGroup as serialised by C# (its wire
5206// format carries .NET type names the server resolves), or null when no filter
5207// is applied on the map.
5208async function _exportFromServer(instance, url, fileName, useFullRange, onlyInPolygons, filterJson) {
5209    let start = null, end = null;
5210    if (!useFullRange && instance.lastDateFilterStart && instance.lastDateFilterEnd) {
5211        start = instance.lastDateFilterStart;
5212        end = instance.lastDateFilterEnd;
5213    }
5214
5215    const polygons = onlyInPolygons
5216        ? _getDrawnPolygons(instance).map(f => f.geometry.coordinates)
5217        : [];
5218
5219    const filter = filterJson ? JSON.parse(filterJson) : null;
5220
5221    if (!start && !end && polygons.length === 0 && !filter) {
5222        const a = document.createElement('a');
5223        a.href = url;
5224        a.download = '';
5225        document.body.appendChild(a);
5226        a.click();
5227        document.body.removeChild(a);
5228        return;
5229    }
5230
5231    const response = await fetch(url, {
5232        method: 'POST',
5233        headers: { 'Content-Type': 'application/json' },
5234        credentials: 'include',
5235        body: JSON.stringify({ start, end, polygons, filter })
5236    });
5237    if (!response.ok) throw new Error(`Export failed (HTTP ${response.status})`);
5238
5239    const blob = await response.blob();
5240    const blobUrl = URL.createObjectURL(blob);
5241    const a = document.createElement('a');
5242    a.href = blobUrl;
5243    a.download = fileName;
5244    document.body.appendChild(a);
5245    a.click();
5246    document.body.removeChild(a);
5247    URL.revokeObjectURL(blobUrl);
5248}
5249
5250export async function exportCsv(mapId, url, fileName, useFullRange, onlyInPolygons, filterJson) {
5251    const instance = instances.get(mapId);
5252    if (!instance) return;
5253    await _exportFromServer(instance, url, fileName, useFullRange, onlyInPolygons, filterJson);
5254}
5255
5256export async function exportKmz(mapId, url, fileName, useFullRange, onlyInPolygons, filterJson) {
5257    const instance = instances.get(mapId);
5258    if (!instance) return;
5259    await _exportFromServer(instance, url, fileName, useFullRange, onlyInPolygons, filterJson);
5260}
5261
5262export function addMarker(mapId, lng, lat, color) {
5263    const instance = instances.get(mapId);
5264    if (!instance) return;
5265    const marker = new maplibregl.Marker({ color: color || '#7b5dc9' })
5266        .setLngLat([lng, lat])
5267        .addTo(instance.map);
5268    if (!instance._markers) instance._markers = [];
5269    instance._markers.push(marker);
5270}

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.