Aperture / frontend /js /map.js
KSvend
fix: graceful fallback when indicator has no grid overlay
ef89b38
/**
* 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 `<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>`;
}
}
/* ── 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<number>} 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 = `
<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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}