feat: wire up indicator map layers on results dashboard
Browse filesSpatial 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 +21 -0
- app/indicators/cropland.py +12 -1
- app/indicators/vegetation.py +12 -1
- app/indicators/water.py +12 -1
- app/main.py +10 -0
- app/worker.py +29 -1
- frontend/js/api.js +10 -0
- frontend/js/app.js +1 -1
- frontend/js/map.js +47 -0
- frontend/js/results.js +28 -4
- pyproject.toml +3 -0
|
@@ -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:
|
|
@@ -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 |
|
|
@@ -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 |
|
|
@@ -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 |
|
|
@@ -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")
|
|
@@ -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(
|
|
@@ -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 |
+
}
|
|
@@ -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;
|
|
@@ -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 {
|
|
@@ -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)
|
|
|
|
|
|
|
|
|
|
| 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, '>')
|
| 108 |
.replace(/"/g, '"');
|
| 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, '>')
|
| 119 |
.replace(/"/g, '"');
|
| 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 |
+
}
|
|
@@ -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"]
|