KSvend Claude Happy commited on
Commit
85f7c19
·
1 Parent(s): 73b5679

feat: wire up indicator map layers on results dashboard

Browse files

Spatial indicators (cropland, vegetation, water) now cache their 2D
raster data after processing. The worker generates styled PNG maps via
render_indicator_map(), served through a new API endpoint. The frontend
overlays these on the results map when clicking indicator cards.

- Add SpatialData dataclass and get_spatial_data() to BaseIndicator
- Cache NDVI/MNDWI grids in cropland, vegetation, water indicators
- Worker generates map PNGs alongside charts during job processing
- New GET /api/jobs/{job_id}/maps/{indicator_id} endpoint
- Frontend: MapLibre image overlay with graceful fallback for non-spatial indicators
- Fix setuptools package discovery in pyproject.toml

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>

app/indicators/base.py CHANGED
@@ -1,9 +1,24 @@
1
  from __future__ import annotations
2
 
3
  import abc
 
 
 
 
 
4
  from app.models import AOI, TimeRange, IndicatorResult, IndicatorMeta
5
 
6
 
 
 
 
 
 
 
 
 
 
 
7
  class BaseIndicator(abc.ABC):
8
  id: str
9
  name: str
@@ -11,6 +26,8 @@ class BaseIndicator(abc.ABC):
11
  question: str
12
  estimated_minutes: int
13
 
 
 
14
  def meta(self) -> IndicatorMeta:
15
  return IndicatorMeta(
16
  id=self.id,
@@ -24,6 +41,10 @@ class BaseIndicator(abc.ABC):
24
  async def process(self, aoi: AOI, time_range: TimeRange) -> IndicatorResult:
25
  ...
26
 
 
 
 
 
27
 
28
  class IndicatorRegistry:
29
  def __init__(self) -> None:
 
1
  from __future__ import annotations
2
 
3
  import abc
4
+ from dataclasses import dataclass, field
5
+ from typing import Optional
6
+
7
+ import numpy as np
8
+
9
  from app.models import AOI, TimeRange, IndicatorResult, IndicatorMeta
10
 
11
 
12
+ @dataclass
13
+ class SpatialData:
14
+ """Raster data produced by an indicator for map rendering."""
15
+ data: np.ndarray # 2-D array (lats, lons)
16
+ lons: np.ndarray # 1-D longitude coordinates
17
+ lats: np.ndarray # 1-D latitude coordinates
18
+ label: str = "" # colorbar label
19
+ colormap: str = "RdYlGn"
20
+
21
+
22
  class BaseIndicator(abc.ABC):
23
  id: str
24
  name: str
 
26
  question: str
27
  estimated_minutes: int
28
 
29
+ _spatial_data: Optional[SpatialData] = None
30
+
31
  def meta(self) -> IndicatorMeta:
32
  return IndicatorMeta(
33
  id=self.id,
 
41
  async def process(self, aoi: AOI, time_range: TimeRange) -> IndicatorResult:
42
  ...
43
 
44
+ def get_spatial_data(self) -> Optional[SpatialData]:
45
+ """Return cached spatial data from the last process() call, or None."""
46
+ return self._spatial_data
47
+
48
 
49
  class IndicatorRegistry:
50
  def __init__(self) -> None:
app/indicators/cropland.py CHANGED
@@ -5,7 +5,7 @@ from typing import Any
5
 
6
  import numpy as np
7
 
8
- from app.indicators.base import BaseIndicator
9
  from app.models import (
10
  AOI,
11
  TimeRange,
@@ -30,6 +30,17 @@ class CroplandIndicator(BaseIndicator):
30
  async def process(self, aoi: AOI, time_range: TimeRange) -> IndicatorResult:
31
  baseline_ndvi, current_ndvi = await self._fetch_ndvi_composite(aoi, time_range)
32
 
 
 
 
 
 
 
 
 
 
 
 
33
  baseline_mean = float(np.nanmean(baseline_ndvi))
34
  current_mean = float(np.nanmean(current_ndvi))
35
 
 
5
 
6
  import numpy as np
7
 
8
+ from app.indicators.base import BaseIndicator, SpatialData
9
  from app.models import (
10
  AOI,
11
  TimeRange,
 
30
  async def process(self, aoi: AOI, time_range: TimeRange) -> IndicatorResult:
31
  baseline_ndvi, current_ndvi = await self._fetch_ndvi_composite(aoi, time_range)
32
 
33
+ # Cache spatial data for map rendering
34
+ rows, cols = current_ndvi.shape
35
+ min_lon, min_lat, max_lon, max_lat = aoi.bbox
36
+ self._spatial_data = SpatialData(
37
+ data=current_ndvi,
38
+ lons=np.linspace(min_lon, max_lon, cols),
39
+ lats=np.linspace(max_lat, min_lat, rows),
40
+ label="Peak-season NDVI",
41
+ colormap="RdYlGn",
42
+ )
43
+
44
  baseline_mean = float(np.nanmean(baseline_ndvi))
45
  current_mean = float(np.nanmean(current_ndvi))
46
 
app/indicators/vegetation.py CHANGED
@@ -5,7 +5,7 @@ from typing import Any
5
 
6
  import numpy as np
7
 
8
- from app.indicators.base import BaseIndicator
9
  from app.models import (
10
  AOI,
11
  TimeRange,
@@ -28,6 +28,17 @@ class VegetationIndicator(BaseIndicator):
28
  async def process(self, aoi: AOI, time_range: TimeRange) -> IndicatorResult:
29
  baseline_ndvi, current_ndvi = await self._fetch_ndvi_composite(aoi, time_range)
30
 
 
 
 
 
 
 
 
 
 
 
 
31
  baseline_mean = float(np.nanmean(baseline_ndvi))
32
  current_mean = float(np.nanmean(current_ndvi))
33
 
 
5
 
6
  import numpy as np
7
 
8
+ from app.indicators.base import BaseIndicator, SpatialData
9
  from app.models import (
10
  AOI,
11
  TimeRange,
 
28
  async def process(self, aoi: AOI, time_range: TimeRange) -> IndicatorResult:
29
  baseline_ndvi, current_ndvi = await self._fetch_ndvi_composite(aoi, time_range)
30
 
31
+ # Cache spatial data for map rendering
32
+ rows, cols = current_ndvi.shape
33
+ min_lon, min_lat, max_lon, max_lat = aoi.bbox
34
+ self._spatial_data = SpatialData(
35
+ data=current_ndvi,
36
+ lons=np.linspace(min_lon, max_lon, cols),
37
+ lats=np.linspace(max_lat, min_lat, rows),
38
+ label="Mean NDVI",
39
+ colormap="RdYlGn",
40
+ )
41
+
42
  baseline_mean = float(np.nanmean(baseline_ndvi))
43
  current_mean = float(np.nanmean(current_ndvi))
44
 
app/indicators/water.py CHANGED
@@ -5,7 +5,7 @@ from typing import Any
5
 
6
  import numpy as np
7
 
8
- from app.indicators.base import BaseIndicator
9
  from app.models import (
10
  AOI,
11
  TimeRange,
@@ -29,6 +29,17 @@ class WaterIndicator(BaseIndicator):
29
  async def process(self, aoi: AOI, time_range: TimeRange) -> IndicatorResult:
30
  baseline_mndwi, current_mndwi = await self._fetch_mndwi_composite(aoi, time_range)
31
 
 
 
 
 
 
 
 
 
 
 
 
32
  baseline_water = float(np.nanmean(baseline_mndwi > MNDWI_THRESHOLD))
33
  current_water = float(np.nanmean(current_mndwi > MNDWI_THRESHOLD))
34
 
 
5
 
6
  import numpy as np
7
 
8
+ from app.indicators.base import BaseIndicator, SpatialData
9
  from app.models import (
10
  AOI,
11
  TimeRange,
 
29
  async def process(self, aoi: AOI, time_range: TimeRange) -> IndicatorResult:
30
  baseline_mndwi, current_mndwi = await self._fetch_mndwi_composite(aoi, time_range)
31
 
32
+ # Cache spatial data for map rendering
33
+ rows, cols = current_mndwi.shape
34
+ min_lon, min_lat, max_lon, max_lat = aoi.bbox
35
+ self._spatial_data = SpatialData(
36
+ data=current_mndwi,
37
+ lons=np.linspace(min_lon, max_lon, cols),
38
+ lats=np.linspace(max_lat, min_lat, rows),
39
+ label="MNDWI",
40
+ colormap="RdBu",
41
+ )
42
+
43
  baseline_water = float(np.nanmean(baseline_mndwi > MNDWI_THRESHOLD))
44
  current_water = float(np.nanmean(current_mndwi > MNDWI_THRESHOLD))
45
 
app/main.py CHANGED
@@ -69,6 +69,16 @@ def create_app(db_path: str = "aperture.db", run_worker: bool = False) -> FastAP
69
  filename=f"aperture_package_{job_id}.zip",
70
  )
71
 
 
 
 
 
 
 
 
 
 
 
72
  # ── Static files + SPA root ───────────────────────────────────────
73
  if _FRONTEND_DIR.exists():
74
  app.mount("/static", StaticFiles(directory=str(_FRONTEND_DIR)), name="static")
 
69
  filename=f"aperture_package_{job_id}.zip",
70
  )
71
 
72
+ @app.get("/api/jobs/{job_id}/maps/{indicator_id}")
73
+ async def get_indicator_map(job_id: str, indicator_id: str):
74
+ map_path = _HERE.parent / "results" / job_id / f"{indicator_id}_map.png"
75
+ if not map_path.exists():
76
+ raise HTTPException(status_code=404, detail="Map not available for this indicator")
77
+ return FileResponse(
78
+ path=str(map_path),
79
+ media_type="image/png",
80
+ )
81
+
82
  # ── Static files + SPA root ───────────────────────────────────────
83
  if _FRONTEND_DIR.exists():
84
  app.mount("/static", StaticFiles(directory=str(_FRONTEND_DIR)), name="static")
app/worker.py CHANGED
@@ -9,6 +9,7 @@ from app.models import JobStatus
9
  from app.outputs.report import generate_pdf_report
10
  from app.outputs.package import create_data_package
11
  from app.outputs.charts import render_timeseries_chart
 
12
  from app.core.email import send_completion_email
13
 
14
  logger = logging.getLogger(__name__)
@@ -21,12 +22,22 @@ async def process_job(job_id: str, db: Database, registry: IndicatorRegistry) ->
21
  return
22
  await db.update_job_status(job_id, JobStatus.PROCESSING)
23
  try:
 
 
 
24
  for indicator_id in job.request.indicator_ids:
25
  await db.update_job_progress(job_id, indicator_id, "processing")
26
  indicator = registry.get(indicator_id)
27
  result = await indicator.process(job.request.aoi, job.request.time_range)
 
 
 
 
 
 
28
  await db.save_job_result(job_id, result)
29
  await db.update_job_progress(job_id, indicator_id, "complete")
 
30
  # Generate outputs
31
  job = await db.get_job(job_id)
32
  results_dir = os.path.join("results", job_id)
@@ -34,7 +45,7 @@ async def process_job(job_id: str, db: Database, registry: IndicatorRegistry) ->
34
 
35
  output_files = []
36
 
37
- # Generate charts for each result
38
  for result in job.results:
39
  chart_path = os.path.join(results_dir, f"{result.indicator_id}_chart.png")
40
  render_timeseries_chart(
@@ -46,6 +57,23 @@ async def process_job(job_id: str, db: Database, registry: IndicatorRegistry) ->
46
  )
47
  output_files.append(chart_path)
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  # Generate PDF report
50
  report_path = os.path.join(results_dir, "report.pdf")
51
  generate_pdf_report(
 
9
  from app.outputs.report import generate_pdf_report
10
  from app.outputs.package import create_data_package
11
  from app.outputs.charts import render_timeseries_chart
12
+ from app.outputs.maps import render_indicator_map
13
  from app.core.email import send_completion_email
14
 
15
  logger = logging.getLogger(__name__)
 
22
  return
23
  await db.update_job_status(job_id, JobStatus.PROCESSING)
24
  try:
25
+ # Track spatial data per indicator for map generation
26
+ spatial_cache = {}
27
+
28
  for indicator_id in job.request.indicator_ids:
29
  await db.update_job_progress(job_id, indicator_id, "processing")
30
  indicator = registry.get(indicator_id)
31
  result = await indicator.process(job.request.aoi, job.request.time_range)
32
+
33
+ # Capture spatial data before it's lost
34
+ spatial = indicator.get_spatial_data()
35
+ if spatial is not None:
36
+ spatial_cache[indicator_id] = spatial
37
+
38
  await db.save_job_result(job_id, result)
39
  await db.update_job_progress(job_id, indicator_id, "complete")
40
+
41
  # Generate outputs
42
  job = await db.get_job(job_id)
43
  results_dir = os.path.join("results", job_id)
 
45
 
46
  output_files = []
47
 
48
+ # Generate charts and maps for each result
49
  for result in job.results:
50
  chart_path = os.path.join(results_dir, f"{result.indicator_id}_chart.png")
51
  render_timeseries_chart(
 
57
  )
58
  output_files.append(chart_path)
59
 
60
+ # Generate map PNG if spatial data is available
61
+ spatial = spatial_cache.get(result.indicator_id)
62
+ if spatial is not None:
63
+ map_path = os.path.join(results_dir, f"{result.indicator_id}_map.png")
64
+ render_indicator_map(
65
+ data=spatial.data,
66
+ lons=spatial.lons,
67
+ lats=spatial.lats,
68
+ aoi=job.request.aoi,
69
+ indicator_name=result.indicator_id.replace("_", " ").title(),
70
+ status=result.status,
71
+ output_path=map_path,
72
+ colormap=spatial.colormap,
73
+ label=spatial.label,
74
+ )
75
+ output_files.append(map_path)
76
+
77
  # Generate PDF report
78
  report_path = os.path.join(results_dir, "report.pdf")
79
  generate_pdf_report(
frontend/js/api.js CHANGED
@@ -81,3 +81,13 @@ export function reportUrl(jobId) {
81
  export function packageUrl(jobId) {
82
  return `${BASE}/api/jobs/${jobId}/package`;
83
  }
 
 
 
 
 
 
 
 
 
 
 
81
  export function packageUrl(jobId) {
82
  return `${BASE}/api/jobs/${jobId}/package`;
83
  }
84
+
85
+ /**
86
+ * Returns the URL for an indicator map PNG.
87
+ * @param {string} jobId
88
+ * @param {string} indicatorId
89
+ * @returns {string}
90
+ */
91
+ export function mapUrl(jobId, indicatorId) {
92
+ return `${BASE}/api/jobs/${jobId}/maps/${indicatorId}`;
93
+ }
frontend/js/app.js CHANGED
@@ -295,7 +295,7 @@ function setupResults() {
295
  return;
296
  }
297
 
298
- renderResults(panelEl, footerEl, job.results || [], state.jobId);
299
 
300
  if (!_resultsMapInit && state.aoi?.bbox) {
301
  _resultsMapInit = true;
 
295
  return;
296
  }
297
 
298
+ renderResults(panelEl, footerEl, job.results || [], state.jobId, state.aoi?.bbox);
299
 
300
  if (!_resultsMapInit && state.aoi?.bbox) {
301
  _resultsMapInit = true;
frontend/js/map.js CHANGED
@@ -179,6 +179,53 @@ export function initResultsMap(containerId, bbox) {
179
  });
180
  }
181
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
  function bboxToPolygon(bbox) {
183
  const [minLon, minLat, maxLon, maxLat] = bbox;
184
  return {
 
179
  });
180
  }
181
 
182
+ /**
183
+ * Show an indicator map image overlay on the results map.
184
+ * @param {string} imageUrl - URL to the map PNG
185
+ * @param {Array<number>} bbox - [minLon, minLat, maxLon, maxLat]
186
+ */
187
+ export function showMapOverlay(imageUrl, bbox) {
188
+ if (!_resultsMap) return;
189
+
190
+ const sourceId = 'indicator-overlay';
191
+ const layerId = 'indicator-overlay-layer';
192
+
193
+ // Remove existing overlay
194
+ if (_resultsMap.getLayer(layerId)) _resultsMap.removeLayer(layerId);
195
+ if (_resultsMap.getSource(sourceId)) _resultsMap.removeSource(sourceId);
196
+
197
+ const [minLon, minLat, maxLon, maxLat] = bbox;
198
+
199
+ _resultsMap.addSource(sourceId, {
200
+ type: 'image',
201
+ url: imageUrl,
202
+ coordinates: [
203
+ [minLon, maxLat], // top-left
204
+ [maxLon, maxLat], // top-right
205
+ [maxLon, minLat], // bottom-right
206
+ [minLon, minLat], // bottom-left
207
+ ],
208
+ });
209
+
210
+ _resultsMap.addLayer({
211
+ id: layerId,
212
+ type: 'raster',
213
+ source: sourceId,
214
+ paint: { 'raster-opacity': 0.85 },
215
+ }, 'aoi-fill'); // insert below AOI layers
216
+ }
217
+
218
+ /**
219
+ * Remove any indicator map overlay from the results map.
220
+ */
221
+ export function clearMapOverlay() {
222
+ if (!_resultsMap) return;
223
+ const sourceId = 'indicator-overlay';
224
+ const layerId = 'indicator-overlay-layer';
225
+ if (_resultsMap.getLayer(layerId)) _resultsMap.removeLayer(layerId);
226
+ if (_resultsMap.getSource(sourceId)) _resultsMap.removeSource(sourceId);
227
+ }
228
+
229
  function bboxToPolygon(bbox) {
230
  const [minLon, minLat, maxLon, maxLat] = bbox;
231
  return {
frontend/js/results.js CHANGED
@@ -3,7 +3,11 @@
3
  * Renders indicator result cards and manages the active-card state.
4
  */
5
 
6
- import { reportUrl, packageUrl } from './api.js';
 
 
 
 
7
 
8
  /**
9
  * Populate the results panel with indicator result cards.
@@ -11,8 +15,11 @@ import { reportUrl, packageUrl } from './api.js';
11
  * @param {HTMLElement} footerEl - Footer with download links
12
  * @param {Array} results - Array of IndicatorResult objects
13
  * @param {string} jobId - Job ID (for download links)
 
14
  */
15
- export function renderResults(panelEl, footerEl, results, jobId) {
 
 
16
  panelEl.innerHTML = '';
17
 
18
  if (!results.length) {
@@ -28,9 +35,12 @@ export function renderResults(panelEl, footerEl, results, jobId) {
28
  panelEl.appendChild(card);
29
  }
30
 
31
- // Activate first card by default
32
  const first = panelEl.querySelector('.result-card');
33
- if (first) first.classList.add('active');
 
 
 
34
 
35
  // Download links
36
  footerEl.innerHTML = `
@@ -69,6 +79,7 @@ function _buildResultCard(result) {
69
  const panel = card.closest('.results-panel-body');
70
  panel.querySelectorAll('.result-card').forEach(c => c.classList.remove('active'));
71
  card.classList.add('active');
 
72
  });
73
 
74
  card.addEventListener('keydown', (e) => {
@@ -107,3 +118,16 @@ function _esc(str) {
107
  .replace(/>/g, '&gt;')
108
  .replace(/"/g, '&quot;');
109
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  * Renders indicator result cards and manages the active-card state.
4
  */
5
 
6
+ import { reportUrl, packageUrl, mapUrl } from './api.js';
7
+ import { showMapOverlay, clearMapOverlay } from './map.js';
8
+
9
+ let _currentJobId = null;
10
+ let _currentBbox = null;
11
 
12
  /**
13
  * Populate the results panel with indicator result cards.
 
15
  * @param {HTMLElement} footerEl - Footer with download links
16
  * @param {Array} results - Array of IndicatorResult objects
17
  * @param {string} jobId - Job ID (for download links)
18
+ * @param {Array<number>} bbox - AOI bounding box for map overlays
19
  */
20
+ export function renderResults(panelEl, footerEl, results, jobId, bbox) {
21
+ _currentJobId = jobId;
22
+ _currentBbox = bbox;
23
  panelEl.innerHTML = '';
24
 
25
  if (!results.length) {
 
35
  panelEl.appendChild(card);
36
  }
37
 
38
+ // Activate first card by default and show its map
39
  const first = panelEl.querySelector('.result-card');
40
+ if (first) {
41
+ first.classList.add('active');
42
+ _showMapForIndicator(first.dataset.indicatorId);
43
+ }
44
 
45
  // Download links
46
  footerEl.innerHTML = `
 
79
  const panel = card.closest('.results-panel-body');
80
  panel.querySelectorAll('.result-card').forEach(c => c.classList.remove('active'));
81
  card.classList.add('active');
82
+ _showMapForIndicator(result.indicator_id);
83
  });
84
 
85
  card.addEventListener('keydown', (e) => {
 
118
  .replace(/>/g, '&gt;')
119
  .replace(/"/g, '&quot;');
120
  }
121
+
122
+ function _showMapForIndicator(indicatorId) {
123
+ if (!_currentJobId || !_currentBbox) return;
124
+ const url = mapUrl(_currentJobId, indicatorId);
125
+ // Check if map exists before showing overlay
126
+ fetch(url, { method: 'HEAD' }).then(res => {
127
+ if (res.ok) {
128
+ showMapOverlay(url, _currentBbox);
129
+ } else {
130
+ clearMapOverlay();
131
+ }
132
+ }).catch(() => clearMapOverlay());
133
+ }
pyproject.toml CHANGED
@@ -31,6 +31,9 @@ dev = [
31
  "pytest-httpx>=0.30.0",
32
  ]
33
 
 
 
 
34
  [tool.pytest.ini_options]
35
  asyncio_mode = "auto"
36
  testpaths = ["tests"]
 
31
  "pytest-httpx>=0.30.0",
32
  ]
33
 
34
+ [tool.setuptools.packages.find]
35
+ include = ["app*"]
36
+
37
  [tool.pytest.ini_options]
38
  asyncio_mode = "auto"
39
  testpaths = ["tests"]