} 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, '"');
}