/** * Aperture — MapLibre GL map tools * AOI click-to-place on the Define Area map + results map rendering. */ 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, }, ], }; /* ── Basemap Toggle Control ────────────────────────────── */ 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); // Re-add AOI layer + click handler after style change _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 ` `; } static _mapIcon() { return ` `; } } /* ── AOI Map ─────────────────────────────────────────────── */ let _aoiMap = null; let _onAoiChange = null; // callback(bbox | null) let _currentBbox = null; // [minLon, minLat, maxLon, maxLat] let _currentCenter = null; // [lng, lat] — last click/geocode center let _selectedKm2 = 500; // active preset size const AOI_SOURCE_ID = 'aoi-draw'; const AOI_FILL_LAYER = 'aoi-draw-fill'; const AOI_OUTLINE_LAYER = 'aoi-draw-outline'; /** * Compute a square bbox centered on [lng, lat] for targetKm2. */ 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); } /** * Initialise the AOI map inside containerId. * @param {string} containerId * @param {function} onAoiChange - called with [minLon,minLat,maxLon,maxLat] or null */ 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); }); } /** * Change the AOI preset size. If an AOI is already placed, resize it. * @param {number} km2 - target area in km² */ export function setAoiSize(km2) { _selectedKm2 = km2; if (_currentCenter) { _currentBbox = computeBbox(_currentCenter[0], _currentCenter[1], km2); _updateAoiLayer(_currentBbox); if (_onAoiChange) _onAoiChange(_currentBbox); } } /** * Search for a location via Nominatim, fly there, and auto-place the AOI. * @param {string} query */ 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); // Fly to fit the placed bbox _aoiMap.fitBounds( [[_currentBbox[0], _currentBbox[1]], [_currentBbox[2], _currentBbox[3]]], { padding: 60, duration: 800 } ); } /* ── Results Map ─────────────────────────────────────────── */ let _resultsMap = null; /** * Initialise the results map inside containerId. * The AOI rectangle is intentionally NOT drawn here — the indicator * overlay itself defines the area, and the dark rectangle was clashing * with the data. A scale bar is added instead so distances are legible. * * @param {string} containerId * @param {Array} bbox - [minLon, minLat, maxLon, maxLat] */ 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 { // Raster was not convertible to a grid (old pre-fix job, or the // backend couldn't read the raster file). Show the legend with a // plain-language explanation so clicking indicators doesn't just // blank the map with no feedback. _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 }; // Prefer backend-provided color stops; fall back to a two-colormap // heuristic for older payloads. 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; } // Fit the map to the grid extent so the overlay is always visible. const bounds = [ [Math.min(...lons), Math.min(...lats)], [Math.max(...lons), Math.max(...lats)], ]; _resultsMap.fitBounds(bounds, { padding: 40, duration: 400, maxZoom: 13 }); // Publish legend info so the overlay panel can update. _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) { // The AOI rectangle was removed from the results map. When an // indicator has no spatial data there is simply nothing to overlay, // which is the honest outcome for that case. _hideLegend(); } /* ── Legend control ──────────────────────────────────────── */ 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 = `
${_escHtml(productName)}
Map overlay not available for this indicator in this job. Re-run the analysis to see the pixel-level layer.
${hint ? `
${_escHtml(hint)}
` : ''} `; 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 = `
${_escHtml(productName)}
${_escHtml(lo)} ${_escHtml(hi)}
${hint ? `
${_escHtml(hint)}
` : ''} `; 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, '"'); }