KSvend Claude Opus 4.6 (1M context) commited on
Commit
ef89b38
·
1 Parent(s): 1ea9510

fix: graceful fallback when indicator has no grid overlay

Browse files

The 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 CHANGED
@@ -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 = spatial.vmin if spatial.vmin is not None else float(np.nanmin(data))
78
- vmax = spatial.vmax if spatial.vmax is not None else float(np.nanmax(data))
79
- if not math.isfinite(vmin) or not math.isfinite(vmax) or vmin == vmax:
 
 
 
 
 
 
 
80
  valid = data[np.isfinite(data)]
81
- if valid.size == 0:
82
- return None
83
- vmin = float(np.nanmin(valid))
84
- vmax = float(np.nanmax(valid))
85
- if vmin == vmax:
86
- vmin -= 0.5
87
- vmax += 0.5
 
 
 
 
 
 
 
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
 
app/worker.py CHANGED
@@ -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 status-only overlay so the frontend still renders.
60
- obj = {"map_type": "status", "status": status_value, "label": spatial.label}
 
 
 
 
 
 
 
 
 
 
 
 
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",
frontend/css/merlx.css CHANGED
@@ -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);
frontend/js/map.js CHANGED
@@ -282,7 +282,20 @@ export function renderSpatialOverlay(spatialData) {
282
  } else if (mapType === 'grid' && spatialData.data) {
283
  _renderGrid(spatialData);
284
  } else {
285
- _renderStatusOverlay(status);
 
 
 
 
 
 
 
 
 
 
 
 
 
286
  }
287
  }
288
 
@@ -390,17 +403,22 @@ function _renderGrid(spatialData) {
390
  );
391
  }
392
 
393
- _resultsMap.addSource('spatial-grid', { type: 'geojson', data: geojson });
394
- _resultsMap.addLayer({
395
- id: 'spatial-grid',
396
- type: 'fill',
397
- source: 'spatial-grid',
398
- paint: {
399
- 'fill-color': colorExpr,
400
- 'fill-opacity': 0.55,
401
- 'fill-outline-color': 'rgba(0,0,0,0)',
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(label || 'Indicator')}</div>
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
  }
frontend/js/results.js CHANGED
@@ -515,21 +515,32 @@ function _esc(str) {
515
  .replace(/"/g, '&quot;');
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(() => clearSpatialOverlay());
 
 
 
 
 
535
  }
 
515
  .replace(/"/g, '&quot;');
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
  }