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, '&') 4583 .replace(/</g, '<') 4584 .replace(/>/g, '>') 4585 .replace(/"/g, '"') 4586 .replace(/'/g, '''); 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.