fix: graceful fallback when indicator has no grid overlay
Browse filesThe overlay was disappearing completely when clicking between
indicators if any of them hit the raster→grid fallback path — the
frontend fell through to _renderStatusOverlay() which (after the AOI
rectangle was removed) did nothing visible, leaving a blank map with
no feedback.
Backend:
- raster_to_grid_dict() now handles all-NaN rasters by emitting an
empty grid so the legend still renders instead of returning None
- _save_spatial_json() logs the raster path + band + existence when
serializing, and writes a richer raster-unavailable payload (with
label, colormap, vmin, vmax) so the frontend can still show a
meaningful legend
Frontend:
- renderSpatialOverlay() no longer falls through to the old status
overlay — unknown/fallback payloads render the legend in its new
"overlay not available" mode with the product name + color hint
- new _activeProductId tracking drops stale spatial responses when
the user clicks through indicators rapidly
- addSource/addLayer calls are wrapped in try/catch with console
logging so silent MapLibre failures stop being invisible
CSS:
- .map-legend-unavailable block styled in warm ember tones matching
the MERLx "caution" palette
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- app/outputs/spatial_web.py +31 -11
- app/worker.py +22 -3
- frontend/css/merlx.css +10 -0
- frontend/js/map.js +55 -15
- frontend/js/results.js +14 -3
|
@@ -7,6 +7,7 @@ of just a tinted AOI rectangle.
|
|
| 7 |
"""
|
| 8 |
from __future__ import annotations
|
| 9 |
|
|
|
|
| 10 |
import math
|
| 11 |
from typing import Any
|
| 12 |
|
|
@@ -14,6 +15,8 @@ import numpy as np
|
|
| 14 |
|
| 15 |
from app.eo_products.base import SpatialData
|
| 16 |
|
|
|
|
|
|
|
| 17 |
# Target grid size for the web overlay. Small enough that MapLibre can
|
| 18 |
# render as GeoJSON polygons without lagging, large enough to still
|
| 19 |
# look like a heatmap rather than a mosaic.
|
|
@@ -41,6 +44,7 @@ def raster_to_grid_dict(
|
|
| 41 |
try:
|
| 42 |
import rasterio
|
| 43 |
except ImportError:
|
|
|
|
| 44 |
return None
|
| 45 |
|
| 46 |
try:
|
|
@@ -49,10 +53,12 @@ def raster_to_grid_dict(
|
|
| 49 |
data = src.read(band).astype(np.float32)
|
| 50 |
nodata = src.nodata
|
| 51 |
bounds = src.bounds
|
| 52 |
-
except Exception:
|
|
|
|
| 53 |
return None
|
| 54 |
|
| 55 |
if data.size == 0:
|
|
|
|
| 56 |
return None
|
| 57 |
|
| 58 |
if nodata is not None:
|
|
@@ -74,17 +80,31 @@ def raster_to_grid_dict(
|
|
| 74 |
lats = np.linspace(bounds.bottom, bounds.top, ny + 1)
|
| 75 |
|
| 76 |
# Determine value range, preferring product-supplied vmin/vmax.
|
| 77 |
-
vmin
|
| 78 |
-
vmax
|
| 79 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
valid = data[np.isfinite(data)]
|
| 81 |
-
if valid.size
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
|
| 89 |
stops = _colormap_stops(spatial.colormap or "RdYlGn", vmin, vmax, n=5)
|
| 90 |
|
|
|
|
| 7 |
"""
|
| 8 |
from __future__ import annotations
|
| 9 |
|
| 10 |
+
import logging
|
| 11 |
import math
|
| 12 |
from typing import Any
|
| 13 |
|
|
|
|
| 15 |
|
| 16 |
from app.eo_products.base import SpatialData
|
| 17 |
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
# Target grid size for the web overlay. Small enough that MapLibre can
|
| 21 |
# render as GeoJSON polygons without lagging, large enough to still
|
| 22 |
# look like a heatmap rather than a mosaic.
|
|
|
|
| 44 |
try:
|
| 45 |
import rasterio
|
| 46 |
except ImportError:
|
| 47 |
+
logger.warning("raster_to_grid_dict: rasterio not available")
|
| 48 |
return None
|
| 49 |
|
| 50 |
try:
|
|
|
|
| 53 |
data = src.read(band).astype(np.float32)
|
| 54 |
nodata = src.nodata
|
| 55 |
bounds = src.bounds
|
| 56 |
+
except Exception as exc:
|
| 57 |
+
logger.warning("raster_to_grid_dict: cannot open %s (%s)", raster_path, exc)
|
| 58 |
return None
|
| 59 |
|
| 60 |
if data.size == 0:
|
| 61 |
+
logger.warning("raster_to_grid_dict: empty data array for %s", raster_path)
|
| 62 |
return None
|
| 63 |
|
| 64 |
if nodata is not None:
|
|
|
|
| 80 |
lats = np.linspace(bounds.bottom, bounds.top, ny + 1)
|
| 81 |
|
| 82 |
# Determine value range, preferring product-supplied vmin/vmax.
|
| 83 |
+
vmin: float
|
| 84 |
+
vmax: float
|
| 85 |
+
if spatial.vmin is not None:
|
| 86 |
+
vmin = float(spatial.vmin)
|
| 87 |
+
else:
|
| 88 |
+
valid = data[np.isfinite(data)]
|
| 89 |
+
vmin = float(np.nanmin(valid)) if valid.size else float("nan")
|
| 90 |
+
if spatial.vmax is not None:
|
| 91 |
+
vmax = float(spatial.vmax)
|
| 92 |
+
else:
|
| 93 |
valid = data[np.isfinite(data)]
|
| 94 |
+
vmax = float(np.nanmax(valid)) if valid.size else float("nan")
|
| 95 |
+
|
| 96 |
+
if not math.isfinite(vmin) or not math.isfinite(vmax):
|
| 97 |
+
# All values were NaN — still render an empty grid rather than
|
| 98 |
+
# dropping the indicator entirely, so the frontend can at least
|
| 99 |
+
# show the legend and the user knows the indicator was processed.
|
| 100 |
+
logger.warning(
|
| 101 |
+
"raster_to_grid_dict: all values NaN for %s; rendering empty grid",
|
| 102 |
+
raster_path,
|
| 103 |
+
)
|
| 104 |
+
vmin, vmax = 0.0, 1.0
|
| 105 |
+
elif vmin == vmax:
|
| 106 |
+
vmin -= 0.5
|
| 107 |
+
vmax += 0.5
|
| 108 |
|
| 109 |
stops = _colormap_stops(spatial.colormap or "RdYlGn", vmin, vmax, n=5)
|
| 110 |
|
|
@@ -45,8 +45,15 @@ def _save_spatial_json(spatial, status_value: str, path: str, product_obj=None)
|
|
| 45 |
from app.outputs.spatial_web import raster_to_grid_dict
|
| 46 |
raster_path = getattr(product_obj, "_product_raster_path", None)
|
| 47 |
render_band = getattr(product_obj, "_render_band", 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
grid = None
|
| 49 |
-
if raster_path:
|
| 50 |
grid = raster_to_grid_dict(
|
| 51 |
raster_path,
|
| 52 |
band=render_band,
|
|
@@ -56,8 +63,20 @@ def _save_spatial_json(spatial, status_value: str, path: str, product_obj=None)
|
|
| 56 |
if grid is not None:
|
| 57 |
obj = grid
|
| 58 |
else:
|
| 59 |
-
# Fall back to a
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
elif spatial.map_type == "grid":
|
| 62 |
obj = {
|
| 63 |
"map_type": "grid",
|
|
|
|
| 45 |
from app.outputs.spatial_web import raster_to_grid_dict
|
| 46 |
raster_path = getattr(product_obj, "_product_raster_path", None)
|
| 47 |
render_band = getattr(product_obj, "_render_band", 1)
|
| 48 |
+
logger.info(
|
| 49 |
+
"Serializing raster spatial for %s: path=%s band=%s exists=%s",
|
| 50 |
+
getattr(product_obj, "id", "?"),
|
| 51 |
+
raster_path,
|
| 52 |
+
render_band,
|
| 53 |
+
os.path.exists(raster_path) if raster_path else False,
|
| 54 |
+
)
|
| 55 |
grid = None
|
| 56 |
+
if raster_path and os.path.exists(raster_path):
|
| 57 |
grid = raster_to_grid_dict(
|
| 58 |
raster_path,
|
| 59 |
band=render_band,
|
|
|
|
| 63 |
if grid is not None:
|
| 64 |
obj = grid
|
| 65 |
else:
|
| 66 |
+
# Fall back to a descriptive payload so the frontend can still
|
| 67 |
+
# show the legend + color hint instead of blanking the map.
|
| 68 |
+
logger.warning(
|
| 69 |
+
"No grid spatial for %s — falling back to no-overlay payload",
|
| 70 |
+
getattr(product_obj, "id", "?"),
|
| 71 |
+
)
|
| 72 |
+
obj = {
|
| 73 |
+
"map_type": "raster-unavailable",
|
| 74 |
+
"status": status_value,
|
| 75 |
+
"label": spatial.label,
|
| 76 |
+
"colormap": spatial.colormap,
|
| 77 |
+
"vmin": spatial.vmin,
|
| 78 |
+
"vmax": spatial.vmax,
|
| 79 |
+
}
|
| 80 |
elif spatial.map_type == "grid":
|
| 81 |
obj = {
|
| 82 |
"map_type": "grid",
|
|
@@ -1055,6 +1055,16 @@ h1, h2, h3, h4, h5, h6 {
|
|
| 1055 |
line-height: 1.45;
|
| 1056 |
}
|
| 1057 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1058 |
.results-panel {
|
| 1059 |
flex: 0 0 35%;
|
| 1060 |
background-color: var(--surface);
|
|
|
|
| 1055 |
line-height: 1.45;
|
| 1056 |
}
|
| 1057 |
|
| 1058 |
+
.map-legend-unavailable {
|
| 1059 |
+
font-size: var(--text-micro);
|
| 1060 |
+
color: var(--ember);
|
| 1061 |
+
line-height: 1.45;
|
| 1062 |
+
background: var(--ember-dim);
|
| 1063 |
+
border-radius: var(--radius-sm);
|
| 1064 |
+
padding: var(--space-3) var(--space-4);
|
| 1065 |
+
margin-bottom: var(--space-3);
|
| 1066 |
+
}
|
| 1067 |
+
|
| 1068 |
.results-panel {
|
| 1069 |
flex: 0 0 35%;
|
| 1070 |
background-color: var(--surface);
|
|
@@ -282,7 +282,20 @@ export function renderSpatialOverlay(spatialData) {
|
|
| 282 |
} else if (mapType === 'grid' && spatialData.data) {
|
| 283 |
_renderGrid(spatialData);
|
| 284 |
} else {
|
| 285 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 286 |
}
|
| 287 |
}
|
| 288 |
|
|
@@ -390,17 +403,22 @@ function _renderGrid(spatialData) {
|
|
| 390 |
);
|
| 391 |
}
|
| 392 |
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 404 |
|
| 405 |
// Fit the map to the grid extent so the overlay is always visible.
|
| 406 |
const bounds = [
|
|
@@ -451,10 +469,26 @@ const COLOR_HINTS = {
|
|
| 451 |
buildup: 'Magenta = likely built-up. Green = vegetated / non-built-up. The classification uses a persistence filter.',
|
| 452 |
};
|
| 453 |
|
| 454 |
-
function _showLegend({ label, productId, stops, vmin, vmax }) {
|
| 455 |
const el = _ensureLegend();
|
| 456 |
if (!el) return;
|
| 457 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 458 |
let gradientCss = '';
|
| 459 |
if (Array.isArray(stops) && stops.length >= 2) {
|
| 460 |
const parts = stops.map((s, i) => {
|
|
@@ -466,12 +500,11 @@ function _showLegend({ label, productId, stops, vmin, vmax }) {
|
|
| 466 |
gradientCss = 'linear-gradient(to right, #e6e6e6, #6e6e6e)';
|
| 467 |
}
|
| 468 |
|
| 469 |
-
const hint = COLOR_HINTS[productId] || '';
|
| 470 |
const lo = Number.isFinite(vmin) ? _fmtNum(vmin) : '';
|
| 471 |
const hi = Number.isFinite(vmax) ? _fmtNum(vmax) : '';
|
| 472 |
|
| 473 |
el.innerHTML = `
|
| 474 |
-
<div class="map-legend-title">${_escHtml(
|
| 475 |
<div class="map-legend-bar" style="background: ${gradientCss}"></div>
|
| 476 |
<div class="map-legend-scale">
|
| 477 |
<span>${_escHtml(lo)}</span>
|
|
@@ -482,6 +515,13 @@ function _showLegend({ label, productId, stops, vmin, vmax }) {
|
|
| 482 |
el.style.display = 'block';
|
| 483 |
}
|
| 484 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 485 |
function _hideLegend() {
|
| 486 |
if (_legendEl) _legendEl.style.display = 'none';
|
| 487 |
}
|
|
|
|
| 282 |
} else if (mapType === 'grid' && spatialData.data) {
|
| 283 |
_renderGrid(spatialData);
|
| 284 |
} else {
|
| 285 |
+
// Raster was not convertible to a grid (old pre-fix job, or the
|
| 286 |
+
// backend couldn't read the raster file). Show the legend with a
|
| 287 |
+
// plain-language explanation so clicking indicators doesn't just
|
| 288 |
+
// blank the map with no feedback.
|
| 289 |
+
_showLegend({
|
| 290 |
+
label: spatialData.label || '',
|
| 291 |
+
productId: spatialData.product_id,
|
| 292 |
+
stops: Array.isArray(spatialData.stops) && spatialData.stops.length
|
| 293 |
+
? spatialData.stops
|
| 294 |
+
: null,
|
| 295 |
+
vmin: spatialData.vmin,
|
| 296 |
+
vmax: spatialData.vmax,
|
| 297 |
+
unavailable: true,
|
| 298 |
+
});
|
| 299 |
}
|
| 300 |
}
|
| 301 |
|
|
|
|
| 403 |
);
|
| 404 |
}
|
| 405 |
|
| 406 |
+
try {
|
| 407 |
+
_resultsMap.addSource('spatial-grid', { type: 'geojson', data: geojson });
|
| 408 |
+
_resultsMap.addLayer({
|
| 409 |
+
id: 'spatial-grid',
|
| 410 |
+
type: 'fill',
|
| 411 |
+
source: 'spatial-grid',
|
| 412 |
+
paint: {
|
| 413 |
+
'fill-color': colorExpr,
|
| 414 |
+
'fill-opacity': 0.55,
|
| 415 |
+
'fill-outline-color': 'rgba(0,0,0,0)',
|
| 416 |
+
},
|
| 417 |
+
});
|
| 418 |
+
} catch (err) {
|
| 419 |
+
console.error('spatial-grid render failed', err);
|
| 420 |
+
return;
|
| 421 |
+
}
|
| 422 |
|
| 423 |
// Fit the map to the grid extent so the overlay is always visible.
|
| 424 |
const bounds = [
|
|
|
|
| 469 |
buildup: 'Magenta = likely built-up. Green = vegetated / non-built-up. The classification uses a persistence filter.',
|
| 470 |
};
|
| 471 |
|
| 472 |
+
function _showLegend({ label, productId, stops, vmin, vmax, unavailable }) {
|
| 473 |
const el = _ensureLegend();
|
| 474 |
if (!el) return;
|
| 475 |
|
| 476 |
+
const hint = COLOR_HINTS[productId] || '';
|
| 477 |
+
const productName = PRODUCT_NAMES[productId] || label || 'Indicator';
|
| 478 |
+
|
| 479 |
+
if (unavailable) {
|
| 480 |
+
el.innerHTML = `
|
| 481 |
+
<div class="map-legend-title">${_escHtml(productName)}</div>
|
| 482 |
+
<div class="map-legend-unavailable">
|
| 483 |
+
Map overlay not available for this indicator in this job.
|
| 484 |
+
Re-run the analysis to see the pixel-level layer.
|
| 485 |
+
</div>
|
| 486 |
+
${hint ? `<div class="map-legend-hint">${_escHtml(hint)}</div>` : ''}
|
| 487 |
+
`;
|
| 488 |
+
el.style.display = 'block';
|
| 489 |
+
return;
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
let gradientCss = '';
|
| 493 |
if (Array.isArray(stops) && stops.length >= 2) {
|
| 494 |
const parts = stops.map((s, i) => {
|
|
|
|
| 500 |
gradientCss = 'linear-gradient(to right, #e6e6e6, #6e6e6e)';
|
| 501 |
}
|
| 502 |
|
|
|
|
| 503 |
const lo = Number.isFinite(vmin) ? _fmtNum(vmin) : '';
|
| 504 |
const hi = Number.isFinite(vmax) ? _fmtNum(vmax) : '';
|
| 505 |
|
| 506 |
el.innerHTML = `
|
| 507 |
+
<div class="map-legend-title">${_escHtml(productName)}</div>
|
| 508 |
<div class="map-legend-bar" style="background: ${gradientCss}"></div>
|
| 509 |
<div class="map-legend-scale">
|
| 510 |
<span>${_escHtml(lo)}</span>
|
|
|
|
| 515 |
el.style.display = 'block';
|
| 516 |
}
|
| 517 |
|
| 518 |
+
const PRODUCT_NAMES = {
|
| 519 |
+
ndvi: 'Vegetation health',
|
| 520 |
+
water: 'Water bodies',
|
| 521 |
+
sar: 'Ground surface change',
|
| 522 |
+
buildup: 'Built-up areas',
|
| 523 |
+
};
|
| 524 |
+
|
| 525 |
function _hideLegend() {
|
| 526 |
if (_legendEl) _legendEl.style.display = 'none';
|
| 527 |
}
|
|
@@ -515,21 +515,32 @@ function _esc(str) {
|
|
| 515 |
.replace(/"/g, '"');
|
| 516 |
}
|
| 517 |
|
|
|
|
|
|
|
| 518 |
function _showMapForProduct(productId) {
|
| 519 |
if (!_currentJobId || !_currentBbox) return;
|
|
|
|
| 520 |
const url = spatialUrl(_currentJobId, productId);
|
| 521 |
fetch(url, { headers: authHeaders() })
|
| 522 |
.then(res => {
|
| 523 |
if (res.ok) return res.json();
|
| 524 |
-
clearSpatialOverlay();
|
| 525 |
return null;
|
| 526 |
})
|
| 527 |
.then(data => {
|
|
|
|
|
|
|
|
|
|
| 528 |
if (data) {
|
| 529 |
-
// Tag the payload so the map legend knows which color hint to show.
|
| 530 |
data.product_id = productId;
|
| 531 |
renderSpatialOverlay(data);
|
|
|
|
|
|
|
| 532 |
}
|
| 533 |
})
|
| 534 |
-
.catch(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 535 |
}
|
|
|
|
| 515 |
.replace(/"/g, '"');
|
| 516 |
}
|
| 517 |
|
| 518 |
+
let _activeProductId = null;
|
| 519 |
+
|
| 520 |
function _showMapForProduct(productId) {
|
| 521 |
if (!_currentJobId || !_currentBbox) return;
|
| 522 |
+
_activeProductId = productId;
|
| 523 |
const url = spatialUrl(_currentJobId, productId);
|
| 524 |
fetch(url, { headers: authHeaders() })
|
| 525 |
.then(res => {
|
| 526 |
if (res.ok) return res.json();
|
|
|
|
| 527 |
return null;
|
| 528 |
})
|
| 529 |
.then(data => {
|
| 530 |
+
// Drop any stale response that arrived after the user clicked
|
| 531 |
+
// through — keeps the visible overlay in sync with the active card.
|
| 532 |
+
if (productId !== _activeProductId) return;
|
| 533 |
if (data) {
|
|
|
|
| 534 |
data.product_id = productId;
|
| 535 |
renderSpatialOverlay(data);
|
| 536 |
+
} else {
|
| 537 |
+
renderSpatialOverlay({ map_type: 'raster-unavailable', product_id: productId });
|
| 538 |
}
|
| 539 |
})
|
| 540 |
+
.catch(err => {
|
| 541 |
+
console.error('spatial fetch failed', err);
|
| 542 |
+
if (productId === _activeProductId) {
|
| 543 |
+
renderSpatialOverlay({ map_type: 'raster-unavailable', product_id: productId });
|
| 544 |
+
}
|
| 545 |
+
});
|
| 546 |
}
|