| |
| |
| |
| |
|
|
| const POSITRON_STYLE = 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json'; |
|
|
| const SATELLITE_STYLE = { |
| version: 8, |
| sources: { |
| 'esri-satellite': { |
| type: 'raster', |
| tiles: [ |
| 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', |
| ], |
| tileSize: 256, |
| attribution: '© Esri', |
| }, |
| }, |
| layers: [ |
| { |
| id: 'esri-satellite-layer', |
| type: 'raster', |
| source: 'esri-satellite', |
| minzoom: 0, |
| maxzoom: 19, |
| }, |
| ], |
| }; |
|
|
| |
|
|
| let _currentStyle = 'positron'; |
|
|
| class BasemapToggle { |
| onAdd(map) { |
| this._map = map; |
| this._container = document.createElement('div'); |
| this._container.className = 'maplibregl-ctrl maplibregl-ctrl-group basemap-toggle'; |
|
|
| this._btn = document.createElement('button'); |
| this._btn.type = 'button'; |
| this._btn.title = 'Switch to Satellite'; |
| this._btn.setAttribute('aria-label', 'Switch to Satellite'); |
| this._btn.innerHTML = BasemapToggle._satelliteIcon(); |
| this._btn.addEventListener('click', () => this._toggle()); |
|
|
| this._container.appendChild(this._btn); |
| return this._container; |
| } |
|
|
| onRemove() { |
| this._container.remove(); |
| this._map = undefined; |
| } |
|
|
| _toggle() { |
| const isSatellite = _currentStyle === 'satellite'; |
| const newStyle = isSatellite ? POSITRON_STYLE : SATELLITE_STYLE; |
|
|
| _aoiMap.setStyle(newStyle); |
|
|
| |
| _aoiMap.once('style.load', () => { |
| _addAoiSource(); |
| if (_currentBbox) _updateAoiLayer(_currentBbox); |
| _aoiMap.getCanvas().style.cursor = 'crosshair'; |
| _aoiMap.on('click', (e) => { |
| _placeAoi(e.lngLat.lng, e.lngLat.lat); |
| }); |
| }); |
|
|
| _currentStyle = isSatellite ? 'positron' : 'satellite'; |
| const nextLabel = _currentStyle === 'positron' ? 'Switch to Satellite' : 'Switch to Map'; |
| this._btn.title = nextLabel; |
| this._btn.setAttribute('aria-label', nextLabel); |
| this._btn.innerHTML = _currentStyle === 'positron' |
| ? BasemapToggle._satelliteIcon() |
| : BasemapToggle._mapIcon(); |
| } |
|
|
| static _satelliteIcon() { |
| return `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> |
| <circle cx="12" cy="12" r="10"/> |
| <path d="M2 12h20"/> |
| <path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/> |
| </svg>`; |
| } |
|
|
| static _mapIcon() { |
| return `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> |
| <polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6"/> |
| <line x1="8" y1="2" x2="8" y2="18"/> |
| <line x1="16" y1="6" x2="16" y2="22"/> |
| </svg>`; |
| } |
| } |
|
|
| |
|
|
| let _aoiMap = null; |
| let _onAoiChange = null; |
| let _currentBbox = null; |
| let _currentCenter = null; |
| let _selectedKm2 = 500; |
|
|
| const AOI_SOURCE_ID = 'aoi-draw'; |
| const AOI_FILL_LAYER = 'aoi-draw-fill'; |
| const AOI_OUTLINE_LAYER = 'aoi-draw-outline'; |
|
|
| |
| |
| |
| function computeBbox(centerLng, centerLat, targetKm2) { |
| const sideKm = Math.sqrt(targetKm2); |
| const dLat = sideKm / 111.32; |
| const dLon = sideKm / (111.32 * Math.cos(centerLat * Math.PI / 180)); |
| return [ |
| centerLng - dLon / 2, |
| centerLat - dLat / 2, |
| centerLng + dLon / 2, |
| centerLat + dLat / 2, |
| ]; |
| } |
|
|
| function _bboxToGeoJSON(bbox) { |
| const [minLon, minLat, maxLon, maxLat] = bbox; |
| return { |
| type: 'FeatureCollection', |
| features: [{ |
| type: 'Feature', |
| geometry: { |
| type: 'Polygon', |
| coordinates: [[ |
| [minLon, minLat], |
| [maxLon, minLat], |
| [maxLon, maxLat], |
| [minLon, maxLat], |
| [minLon, minLat], |
| ]], |
| }, |
| properties: {}, |
| }], |
| }; |
| } |
|
|
| const _emptyGeoJSON = { type: 'FeatureCollection', features: [] }; |
|
|
| function _addAoiSource() { |
| if (_aoiMap.getSource(AOI_SOURCE_ID)) return; |
| _aoiMap.addSource(AOI_SOURCE_ID, { type: 'geojson', data: _emptyGeoJSON }); |
| _aoiMap.addLayer({ |
| id: AOI_FILL_LAYER, |
| type: 'fill', |
| source: AOI_SOURCE_ID, |
| paint: { 'fill-color': '#1A3A34', 'fill-opacity': 0.10 }, |
| }); |
| _aoiMap.addLayer({ |
| id: AOI_OUTLINE_LAYER, |
| type: 'line', |
| source: AOI_SOURCE_ID, |
| paint: { 'line-color': '#1A3A34', 'line-width': 2 }, |
| }); |
| } |
|
|
| function _updateAoiLayer(bbox) { |
| const source = _aoiMap.getSource(AOI_SOURCE_ID); |
| if (source) source.setData(_bboxToGeoJSON(bbox)); |
| } |
|
|
| function _placeAoi(lng, lat) { |
| _currentCenter = [lng, lat]; |
| _currentBbox = computeBbox(lng, lat, _selectedKm2); |
| _updateAoiLayer(_currentBbox); |
| if (_onAoiChange) _onAoiChange(_currentBbox); |
| } |
|
|
| |
| |
| |
| |
| |
| export function initAoiMap(containerId, onAoiChange) { |
| _onAoiChange = onAoiChange; |
| _currentStyle = 'positron'; |
|
|
| _aoiMap = new maplibregl.Map({ |
| container: containerId, |
| style: POSITRON_STYLE, |
| center: [37.0, 3.0], |
| zoom: 4, |
| }); |
|
|
| _aoiMap.addControl(new BasemapToggle(), 'top-right'); |
| _aoiMap.getCanvas().style.cursor = 'crosshair'; |
|
|
| _aoiMap.on('load', () => { |
| _addAoiSource(); |
| }); |
|
|
| _aoiMap.on('click', (e) => { |
| _placeAoi(e.lngLat.lng, e.lngLat.lat); |
| }); |
| } |
|
|
| |
| |
| |
| |
| export function setAoiSize(km2) { |
| _selectedKm2 = km2; |
| if (_currentCenter) { |
| _currentBbox = computeBbox(_currentCenter[0], _currentCenter[1], km2); |
| _updateAoiLayer(_currentBbox); |
| if (_onAoiChange) _onAoiChange(_currentBbox); |
| } |
| } |
|
|
| |
| |
| |
| |
| export async function geocode(query) { |
| const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&format=json&limit=1`; |
| const res = await fetch(url, { headers: { 'Accept-Language': 'en' } }); |
| const results = await res.json(); |
| if (!results.length) throw new Error('Location not found'); |
| const { lon, lat } = results[0]; |
| const lng = parseFloat(lon); |
| const latNum = parseFloat(lat); |
|
|
| _placeAoi(lng, latNum); |
|
|
| |
| _aoiMap.fitBounds( |
| [[_currentBbox[0], _currentBbox[1]], [_currentBbox[2], _currentBbox[3]]], |
| { padding: 60, duration: 800 } |
| ); |
| } |
|
|
| |
|
|
| let _resultsMap = null; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function initResultsMap(containerId, bbox) { |
| _resultsMap = new maplibregl.Map({ |
| container: containerId, |
| style: POSITRON_STYLE, |
| bounds: [[bbox[0], bbox[1]], [bbox[2], bbox[3]]], |
| fitBoundsOptions: { padding: 60 }, |
| }); |
|
|
| _resultsMap.addControl( |
| new maplibregl.ScaleControl({ maxWidth: 120, unit: 'metric' }), |
| 'bottom-left', |
| ); |
| } |
|
|
| const STATUS_COLORS = { green: '#3BAA7F', amber: '#CA5D0F', red: '#B83A2A' }; |
| const CONFIDENCE_COLORS = { high: '#B83A2A', nominal: '#CA5D0F', low: '#E8C547' }; |
|
|
| export function renderSpatialOverlay(spatialData) { |
| clearSpatialOverlay(); |
| if (!_resultsMap) return; |
|
|
| const mapType = spatialData.map_type; |
| const status = spatialData.status; |
|
|
| if (mapType === 'points' && spatialData.geojson) { |
| _renderPoints(spatialData.geojson); |
| } else if (mapType === 'choropleth' && spatialData.geojson) { |
| _renderChoropleth(spatialData.geojson, spatialData.colormap); |
| } else if (mapType === 'grid' && spatialData.data) { |
| _renderGrid(spatialData); |
| } else { |
| |
| |
| |
| |
| _showLegend({ |
| label: spatialData.label || '', |
| productId: spatialData.product_id, |
| stops: Array.isArray(spatialData.stops) && spatialData.stops.length |
| ? spatialData.stops |
| : null, |
| vmin: spatialData.vmin, |
| vmax: spatialData.vmax, |
| unavailable: true, |
| }); |
| } |
| } |
|
|
| export function clearSpatialOverlay() { |
| if (!_resultsMap) return; |
| for (const id of ['spatial-points', 'spatial-choropleth', 'spatial-grid']) { |
| if (_resultsMap.getLayer(id)) _resultsMap.removeLayer(id); |
| if (_resultsMap.getSource(id)) _resultsMap.removeSource(id); |
| } |
| _hideLegend(); |
| } |
|
|
| function _renderPoints(geojson) { |
| _resultsMap.addSource('spatial-points', { type: 'geojson', data: geojson }); |
| _resultsMap.addLayer({ |
| id: 'spatial-points', |
| type: 'circle', |
| source: 'spatial-points', |
| paint: { |
| 'circle-radius': 5, |
| 'circle-color': [ |
| 'match', ['get', 'confidence'], |
| 'high', CONFIDENCE_COLORS.high, |
| 'nominal', CONFIDENCE_COLORS.nominal, |
| 'low', CONFIDENCE_COLORS.low, |
| CONFIDENCE_COLORS.nominal, |
| ], |
| 'circle-stroke-width': 1, |
| 'circle-stroke-color': '#111', |
| 'circle-opacity': 0.85, |
| }, |
| }); |
| } |
|
|
| function _renderChoropleth(geojson, colormap) { |
| _resultsMap.addSource('spatial-choropleth', { type: 'geojson', data: geojson }); |
| const values = geojson.features.map(f => f.properties.value || 0); |
| const vmin = Math.min(...values); |
| const vmax = Math.max(...values); |
| const mid = (vmin + vmax) / 2; |
| _resultsMap.addLayer({ |
| id: 'spatial-choropleth', |
| type: 'fill', |
| source: 'spatial-choropleth', |
| paint: { |
| 'fill-color': [ |
| 'interpolate', ['linear'], ['get', 'value'], |
| vmin, colormap === 'Blues' ? '#deebf7' : '#f7fcb9', |
| mid, colormap === 'Blues' ? '#6baed6' : '#78c679', |
| vmax, colormap === 'Blues' ? '#08519c' : '#006837', |
| ], |
| 'fill-opacity': 0.55, |
| }, |
| }); |
| } |
|
|
| function _renderGrid(spatialData) { |
| const { data, lats, lons } = spatialData; |
| if (!Array.isArray(data) || !Array.isArray(lats) || !Array.isArray(lons)) return; |
|
|
| const features = []; |
| for (let r = 0; r < lats.length - 1; r++) { |
| const row = data[r]; |
| if (!row) continue; |
| for (let c = 0; c < lons.length - 1; c++) { |
| const val = row[c]; |
| if (val == null || !isFinite(val)) continue; |
| features.push({ |
| type: 'Feature', |
| geometry: { |
| type: 'Polygon', |
| coordinates: [[ |
| [lons[c], lats[r]], |
| [lons[c + 1], lats[r]], |
| [lons[c + 1], lats[r + 1]], |
| [lons[c], lats[r + 1]], |
| [lons[c], lats[r]], |
| ]], |
| }, |
| properties: { value: val }, |
| }); |
| } |
| } |
| if (!features.length) return; |
|
|
| const geojson = { type: 'FeatureCollection', features }; |
|
|
| |
| |
| const colorExpr = ['interpolate', ['linear'], ['get', 'value']]; |
| if (Array.isArray(spatialData.stops) && spatialData.stops.length >= 2) { |
| for (const stop of spatialData.stops) { |
| colorExpr.push(Number(stop.value), String(stop.color)); |
| } |
| } else { |
| const values = features.map(f => f.properties.value); |
| const vmin = Number.isFinite(spatialData.vmin) ? spatialData.vmin : Math.min(...values); |
| const vmax = Number.isFinite(spatialData.vmax) ? spatialData.vmax : Math.max(...values); |
| const mid = (vmin + vmax) / 2; |
| const isTemp = spatialData.colormap === 'coolwarm'; |
| colorExpr.push( |
| vmin, isTemp ? '#3b4cc0' : '#deebf7', |
| mid, isTemp ? '#f7f7f7' : '#6baed6', |
| vmax, isTemp ? '#b40426' : '#08519c', |
| ); |
| } |
|
|
| try { |
| _resultsMap.addSource('spatial-grid', { type: 'geojson', data: geojson }); |
| _resultsMap.addLayer({ |
| id: 'spatial-grid', |
| type: 'fill', |
| source: 'spatial-grid', |
| paint: { |
| 'fill-color': colorExpr, |
| 'fill-opacity': 0.55, |
| 'fill-outline-color': 'rgba(0,0,0,0)', |
| }, |
| }); |
| } catch (err) { |
| console.error('spatial-grid render failed', err); |
| return; |
| } |
|
|
| |
| const bounds = [ |
| [Math.min(...lons), Math.min(...lats)], |
| [Math.max(...lons), Math.max(...lats)], |
| ]; |
| _resultsMap.fitBounds(bounds, { padding: 40, duration: 400, maxZoom: 13 }); |
|
|
| |
| _showLegend({ |
| label: spatialData.label || '', |
| productId: spatialData.product_id, |
| stops: Array.isArray(spatialData.stops) && spatialData.stops.length |
| ? spatialData.stops |
| : null, |
| vmin: spatialData.vmin, |
| vmax: spatialData.vmax, |
| unit: spatialData.unit || '', |
| }); |
| } |
|
|
| function _renderStatusOverlay(_status) { |
| |
| |
| |
| _hideLegend(); |
| } |
|
|
| |
|
|
| let _legendEl = null; |
|
|
| function _ensureLegend() { |
| if (_legendEl) return _legendEl; |
| const container = document.getElementById('results-map'); |
| if (!container) return null; |
| _legendEl = document.createElement('div'); |
| _legendEl.className = 'map-legend'; |
| _legendEl.style.display = 'none'; |
| container.appendChild(_legendEl); |
| return _legendEl; |
| } |
|
|
| const COLOR_HINTS = { |
| ndvi: 'Darker green = healthier vegetation than the seasonal norm. Pink = less vegetation (stressed, cleared, or burnt).', |
| water: 'Darker blue = more water present than normal. Pale = less water than normal.', |
| sar: 'Blue = ground surface darker (wetter, smoother). Red = brighter (drier, rougher, new structures).', |
| buildup: 'Magenta = likely built-up. Green = vegetated / non-built-up. The classification uses a persistence filter.', |
| }; |
|
|
| function _showLegend({ label, productId, stops, vmin, vmax, unavailable }) { |
| const el = _ensureLegend(); |
| if (!el) return; |
|
|
| const hint = COLOR_HINTS[productId] || ''; |
| const productName = PRODUCT_NAMES[productId] || label || 'Indicator'; |
|
|
| if (unavailable) { |
| el.innerHTML = ` |
| <div class="map-legend-title">${_escHtml(productName)}</div> |
| <div class="map-legend-unavailable"> |
| Map overlay not available for this indicator in this job. |
| Re-run the analysis to see the pixel-level layer. |
| </div> |
| ${hint ? `<div class="map-legend-hint">${_escHtml(hint)}</div>` : ''} |
| `; |
| el.style.display = 'block'; |
| return; |
| } |
|
|
| let gradientCss = ''; |
| if (Array.isArray(stops) && stops.length >= 2) { |
| const parts = stops.map((s, i) => { |
| const pct = (i / (stops.length - 1)) * 100; |
| return `${s.color} ${pct.toFixed(1)}%`; |
| }); |
| gradientCss = `linear-gradient(to right, ${parts.join(', ')})`; |
| } else { |
| gradientCss = 'linear-gradient(to right, #e6e6e6, #6e6e6e)'; |
| } |
|
|
| const lo = Number.isFinite(vmin) ? _fmtNum(vmin) : ''; |
| const hi = Number.isFinite(vmax) ? _fmtNum(vmax) : ''; |
|
|
| el.innerHTML = ` |
| <div class="map-legend-title">${_escHtml(productName)}</div> |
| <div class="map-legend-bar" style="background: ${gradientCss}"></div> |
| <div class="map-legend-scale"> |
| <span>${_escHtml(lo)}</span> |
| <span>${_escHtml(hi)}</span> |
| </div> |
| ${hint ? `<div class="map-legend-hint">${_escHtml(hint)}</div>` : ''} |
| `; |
| el.style.display = 'block'; |
| } |
|
|
| const PRODUCT_NAMES = { |
| ndvi: 'Vegetation health', |
| water: 'Water bodies', |
| sar: 'Ground surface change', |
| buildup: 'Built-up areas', |
| }; |
|
|
| function _hideLegend() { |
| if (_legendEl) _legendEl.style.display = 'none'; |
| } |
|
|
| function _fmtNum(v) { |
| const n = Number(v); |
| if (!isFinite(n)) return ''; |
| if (Math.abs(n) >= 1000) return n.toFixed(0); |
| if (Math.abs(n) >= 10) return n.toFixed(1); |
| return n.toFixed(2); |
| } |
|
|
| function _escHtml(str) { |
| return String(str == null ? '' : str) |
| .replace(/&/g, '&') |
| .replace(/</g, '<') |
| .replace(/>/g, '>') |
| .replace(/"/g, '"'); |
| } |
|
|