Complete operational map result workflow
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-17 12:35:50 +02:00
parent 8266929b2e
commit a29c787238
33 changed files with 1121 additions and 107 deletions
+19
View File
@@ -7,6 +7,25 @@
# Changelog
## Sprint 233 Operational correctness and result completion (2026-07-17)
- Fixed the map-first contract so a drawn or manually entered rectangle remains
an exact bbox for vector, raster and temporal analysis. Only the explicit
full-work-area action uses persisted Area geometry and its fast paths.
- Made vector-selection exports Area-aware and added a canonical server-side
map-result export for current vector/raster measurements and historical
comparisons.
- Changed the result handoff to persist the server-recomputed artifact before
opening Downloads; map analyses, evolution results and vector selections now
appear in the latest-download surface.
- Added exact-name project filtering and canonical regional-workspace lookup,
removing the dependency on the first 50 newest operator projects.
- Improved source failure messages, workspace scroll reset, map viewport
ergonomics, Detection Lab flow, QA score interpretation and Dutch download
terminology.
- Added focused regression coverage for selection scope, export persistence,
canonical workspace lookup and the usability guardrails.
## Sprint 232 V1 user-flow completion (2026-07-17)
- Completed the primary end-user handoff from map analysis to a usable result,
+5 -1
View File
@@ -2,7 +2,7 @@
FastAPI backend for GeoIntel Kempen Foundation Sprints.
The map-first explorer uses the existing persisted vector selection endpoint. Its bounded GeoJSON preview reports `feature_count`, while `total_feature_count` reports the exact PostGIS intersection count before the 1,000-feature response cap. This keeps municipality-scale analysis honest without sending unbounded geometry to the browser.
The map-first explorer uses the existing persisted vector selection endpoint. Its bounded GeoJSON preview reports `feature_count`, while `total_feature_count` reports the exact PostGIS intersection count before the 1,000-feature response cap. Drawn/manual selections send only their bbox; `area_id` is reserved for the explicit full-work-area operation. This keeps municipality-scale analysis honest without sending unbounded geometry to the browser.
## Scope implemented
- Project CRUD
@@ -188,12 +188,16 @@ bash scripts/live_migration_smoke.sh
- Added project metadata JSON export for project, dataset and QA/QC summary state.
- Added export read/list/content endpoints:
- `POST /api/v1/exports/geojson`
- `POST /api/v1/exports/map-result`
- `POST /api/v1/exports/metadata`
- `GET /api/v1/exports/projects/{project_id}/exports`
- `GET /api/v1/exports/{export_id}`
- `GET /api/v1/exports/{export_id}/content`
- Exported detection and segmentation GeoJSON is generated from persisted first-class geometry rows.
- No new migrations, product lines, live providers or AI dependencies are introduced by this export pass.
- Map-result exports recompute current governed vector/raster selections or
historical comparisons on the backend before persisting the artifact. Client
metrics are never accepted as authoritative export content.
- Old offline demo export artifacts can be inspected with `python scripts/cleanup_demo_artifacts.py`
and removed only with an explicit `--apply`. The script keeps the newest exports
per demo project, refuses to delete files outside `STORAGE_ROOT`, and blocks
+7 -1
View File
@@ -7,7 +7,7 @@ from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.schemas.export import GeoJsonExportRequest, MetadataExportRequest, ReportExportRequest
from app.schemas.export import GeoJsonExportRequest, MapResultExportRequest, MetadataExportRequest, ReportExportRequest
from app.services.export_service import ExportService
from app.utils.response import envelope
@@ -22,6 +22,7 @@ def export_geojson(payload: GeoJsonExportRequest, db: Session = Depends(get_db))
db,
payload.dataset_id,
payload.bbox.model_dump(),
area_id=payload.area_id,
limit=payload.limit,
name=payload.name,
).model_dump(mode="json")
@@ -49,6 +50,11 @@ def export_project_report(payload: ReportExportRequest, db: Session = Depends(ge
return envelope(ExportService.export_project_report(db, payload.project_id, payload.name).model_dump(mode="json"))
@router.post("/map-result", response_model=dict)
def export_map_result(payload: MapResultExportRequest, db: Session = Depends(get_db)):
return envelope(ExportService.export_map_result(db, payload).model_dump(mode="json"))
@router.get("/projects/{project_id}/exports", response_model=dict)
def list_project_exports(
project_id: UUID,
+2 -1
View File
@@ -17,9 +17,10 @@ router = APIRouter(prefix="/projects", tags=["projects"])
def list_projects(
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
name: str | None = Query(default=None, min_length=1, max_length=255),
db: Session = Depends(get_db),
):
projects, total = ProjectService.list_projects(db, limit=limit, offset=offset)
projects, total = ProjectService.list_projects(db, limit=limit, offset=offset, name=name)
return envelope({"items": [ProjectRead.model_validate(item).model_dump() for item in projects], "total": total, "limit": limit, "offset": offset})
+28
View File
@@ -10,11 +10,13 @@ from app.schemas.operations import VectorSelectionBBox
ExportKind = Literal["dataset", "detection_run", "segmentation_run", "vector_selection"]
MapResultMode = Literal["current", "evolution"]
class GeoJsonExportRequest(BaseModel):
dataset_id: UUID | None = None
analysis_run_id: UUID | None = None
area_id: UUID | None = None
export_kind: ExportKind = "dataset"
name: str | None = None
bbox: VectorSelectionBBox | None = None
@@ -44,6 +46,32 @@ class ReportExportRequest(BaseModel):
name: str | None = None
class MapResultExportRequest(BaseModel):
project_id: UUID
mode: MapResultMode
bbox: VectorSelectionBBox
dataset_id: UUID | None = None
earlier_dataset_id: UUID | None = None
later_dataset_id: UUID | None = None
area_id: UUID | None = None
partitioned: bool = False
product_key: str | None = None
theme_id: str | None = None
name: str | None = None
@model_validator(mode="after")
def validate_map_target(self) -> "MapResultExportRequest":
if self.mode == "current" and self.dataset_id is None:
raise ValueError("dataset_id is required for current map-result exports")
if self.mode == "evolution" and (
self.earlier_dataset_id is None or self.later_dataset_id is None
):
raise ValueError("earlier_dataset_id and later_dataset_id are required for evolution exports")
if self.partitioned and not self.product_key:
raise ValueError("product_key is required for partitioned raster exports")
return self
class ExportRead(BaseModel):
id: UUID
project_id: UUID
+203 -2
View File
@@ -11,20 +11,199 @@ from sqlalchemy.orm import Session
from app.core.errors import AppError
from app.models import AnalysisRun, Area, Dataset, Export, Project, QualityCheck
from app.schemas.export import ExportContentResponse, ExportCreateResponse, ExportListResponse, ExportRead
from app.schemas.dhmv import TerrainPartitionSelectionRequest, TerrainSelectionRequest
from app.schemas.export import (
ExportContentResponse,
ExportCreateResponse,
ExportListResponse,
ExportRead,
MapResultExportRequest,
)
from app.schemas.flood_hazard import FloodHazardPartitionSelectionRequest, FloodHazardSelectionRequest
from app.schemas.temporal import TemporalComparisonRequest
from app.schemas.thematic_raster import ThematicRasterSelectionRequest
from app.services.dataset_service import DatasetService
from app.services.detection_service import DetectionService
from app.services.flood_hazard_analysis_service import FloodHazardAnalysisService
from app.services.segmentation_service import SegmentationService
from app.services.storage_service import StorageService
from app.services.temporal_analysis_service import TemporalAnalysisService
from app.services.terrain_analysis_service import TerrainAnalysisService
from app.services.thematic_raster_analysis_service import ThematicRasterAnalysisService
from app.services.vector_feature_service import VectorFeatureService
class ExportService:
@staticmethod
def export_map_result(
db: Session,
payload: MapResultExportRequest,
) -> ExportCreateResponse:
if payload.mode == "evolution":
comparison = TemporalAnalysisService.compare(
db,
project_id=payload.project_id,
payload=TemporalComparisonRequest(
earlier_dataset_id=payload.earlier_dataset_id,
later_dataset_id=payload.later_dataset_id,
bbox=payload.bbox,
area_id=payload.area_id,
),
)
content = comparison.model_dump(mode="json")
target_id = str(payload.later_dataset_id)
filename = ExportService._filename(
payload.name,
f"{target_id}-evolution.json",
".json",
)
export_path = StorageService.dataset_export_path(
str(payload.project_id),
target_id,
filename,
)
metadata = {
"source": "map_evolution",
"project_id": str(payload.project_id),
"earlier_dataset_id": str(payload.earlier_dataset_id),
"later_dataset_id": str(payload.later_dataset_id),
"selection_bbox": payload.bbox.model_dump(mode="json"),
"selection_area_id": str(payload.area_id) if payload.area_id else None,
"theme_id": payload.theme_id,
"server_recomputed": True,
}
export = ExportService._write_json_export(
db,
project_id=payload.project_id,
analysis_run_id=None,
export_type="map_evolution_json",
storage_path=export_path,
content=content,
metadata=metadata,
)
return ExportService._create_response(export)
dataset = db.get(Dataset, payload.dataset_id)
if not dataset or dataset.project_id != payload.project_id:
raise AppError(code="DATASET_NOT_FOUND", message="Dataset not found", status_code=404)
if dataset.dataset_type in DatasetService.VECTOR_TYPES:
return ExportService.export_vector_selection_geojson(
db,
dataset.id,
payload.bbox.model_dump(mode="json"),
area_id=payload.area_id,
name=payload.name,
limit=1000,
)
if dataset.dataset_type != "raster":
raise AppError(
code="INVALID_DATASET_TYPE",
message="Map-result export requires a vector or governed raster dataset",
details={"dataset_type": dataset.dataset_type},
status_code=400,
)
if dataset.source_name == "digitaal_vlaanderen_dhmv":
result = (
TerrainAnalysisService.analyze_partitions(
db,
payload.project_id,
TerrainPartitionSelectionRequest(
bbox=payload.bbox,
area_id=payload.area_id,
product_key=payload.product_key or "dtm_1m",
),
)
if payload.partitioned
else TerrainAnalysisService.analyze(
db,
payload.project_id,
dataset.id,
TerrainSelectionRequest(bbox=payload.bbox, area_id=payload.area_id),
)
)
elif dataset.source_name == "vmm_flood_hazard":
result = (
FloodHazardAnalysisService.analyze_partitions(
db,
payload.project_id,
FloodHazardPartitionSelectionRequest(
bbox=payload.bbox,
area_id=payload.area_id,
product_key=payload.product_key or "pluviaal_current_t100",
),
)
if payload.partitioned
else FloodHazardAnalysisService.analyze(
db,
payload.project_id,
dataset.id,
FloodHazardSelectionRequest(bbox=payload.bbox, area_id=payload.area_id),
)
)
elif dataset.source_name == "department_omgeving_thematic_raster":
result = ThematicRasterAnalysisService.analyze(
db,
payload.project_id,
dataset.id,
ThematicRasterSelectionRequest(bbox=payload.bbox, area_id=payload.area_id),
)
else:
raise AppError(
code="MAP_RESULT_EXPORT_UNSUPPORTED",
message="This raster source does not expose a governed map-result export",
details={"source_name": dataset.source_name},
status_code=400,
)
content = {
"mode": "current",
"theme_id": payload.theme_id,
"dataset": {
"id": str(dataset.id),
"name": dataset.name,
"source_name": dataset.source_name,
},
"result": result,
}
filename = ExportService._filename(
payload.name,
f"{dataset.id}-map-analysis.json",
".json",
)
export_path = StorageService.dataset_export_path(
str(payload.project_id),
str(dataset.id),
filename,
)
metadata = {
"source": "map_analysis",
"project_id": str(payload.project_id),
"dataset_id": str(dataset.id),
"selection_bbox": payload.bbox.model_dump(mode="json"),
"selection_area_id": str(payload.area_id) if payload.area_id else None,
"theme_id": payload.theme_id,
"partitioned": payload.partitioned,
"product_key": payload.product_key,
"server_recomputed": True,
}
export = ExportService._write_json_export(
db,
project_id=payload.project_id,
analysis_run_id=None,
export_type="map_analysis_json",
storage_path=export_path,
content=content,
metadata=metadata,
)
return ExportService._create_response(export)
@staticmethod
def export_vector_selection_geojson(
db: Session,
dataset_id: uuid.UUID,
bbox: dict[str, Any],
area_id: uuid.UUID | None = None,
limit: int = 250,
name: str | None = None,
) -> ExportCreateResponse:
@@ -39,7 +218,28 @@ class ExportService:
status_code=400,
)
selection = VectorFeatureService.select_features_by_bbox(db, dataset_id=dataset_id, bbox=bbox, limit=limit)
selection_kwargs: dict[str, Any] = {
"dataset_id": dataset_id,
"bbox": bbox,
"limit": limit,
}
if area_id is not None:
area = db.get(Area, area_id)
if not area or area.project_id != dataset.project_id:
raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404)
full_dataset_area = VectorFeatureService.can_use_full_area_fast_path(dataset, area.id)
preclipped_partition_filter = (
None
if full_dataset_area
else VectorFeatureService.preclipped_partition_filter(dataset, getattr(area, "name", None))
)
selection_kwargs.update(
selection_geometry=area.geometry,
selection_area_id=area.id,
full_dataset_area=full_dataset_area,
preclipped_partition_filter=preclipped_partition_filter,
)
selection = VectorFeatureService.select_features_by_bbox(db, **selection_kwargs)
filename = ExportService._filename(name, f"{dataset.id}-selection.geojson", ".geojson")
export_path = StorageService.dataset_export_path(str(dataset.project_id), str(dataset.id), filename)
metadata = {
@@ -48,6 +248,7 @@ class ExportService:
"dataset_id": str(dataset.id),
"dataset_type": dataset.dataset_type,
"selection_bbox": selection["selection_bbox"],
"selection_area_id": selection.get("selection_area_id"),
"feature_count": selection["feature_count"],
"limit": selection["limit"],
"truncated": selection["truncated"],
+10 -2
View File
@@ -11,8 +11,16 @@ from app.schemas.project import ProjectCreate, ProjectRead, ProjectUpdate
class ProjectService:
@staticmethod
def list_projects(db: Session, limit: int = 50, offset: int = 0) -> tuple[list[ProjectRead], int]:
query = db.query(Project).filter(Project.status != "deleted").order_by(Project.created_at.desc())
def list_projects(
db: Session,
limit: int = 50,
offset: int = 0,
name: str | None = None,
) -> tuple[list[ProjectRead], int]:
query = db.query(Project).filter(Project.status != "deleted")
if name:
query = query.filter(Project.name == name.strip())
query = query.order_by(Project.created_at.desc())
total = query.count()
items = query.offset(offset).limit(limit).all()
return [ProjectRead.model_validate(item) for item in items], total
@@ -2,12 +2,13 @@ from __future__ import annotations
import json
from pathlib import Path
from types import SimpleNamespace
from uuid import uuid4
from fastapi.testclient import TestClient
from app.main import app
from app.models import Dataset, Export
from app.models import Area, Dataset, Export
from app.schemas.export import ExportCreateResponse
from app.services.export_service import ExportService
from app.services.storage_service import StorageService
@@ -91,23 +92,31 @@ def test_vector_selection_geojson_export_persists_handoff_artifact(tmp_path, mon
def test_vector_selection_geojson_export_endpoint_returns_canonical_envelope(monkeypatch) -> None:
export_id = uuid4()
dataset_id = uuid4()
area_id = uuid4()
expected_bbox = {"min_x": 4.9, "min_y": 50.9, "max_x": 5.2, "max_y": 51.2, "crs": "EPSG:4326"}
captured: dict = {}
monkeypatch.setattr(
ExportService,
"export_vector_selection_geojson",
lambda *_args, **_kwargs: ExportCreateResponse(
def fake_export(*_args, **kwargs):
captured.update(kwargs)
return ExportCreateResponse(
export_id=export_id,
path="storage/exports/demo-selection.geojson",
status="ready",
export_type="vector_selection_geojson",
metadata_json={"source": "vector_selection", "selection_bbox": expected_bbox},
),
)
)
monkeypatch.setattr(ExportService, "export_vector_selection_geojson", fake_export)
response = TestClient(app).post(
"/api/v1/exports/geojson",
json={"dataset_id": str(dataset_id), "export_kind": "vector_selection", "bbox": expected_bbox, "limit": 250},
json={
"dataset_id": str(dataset_id),
"area_id": str(area_id),
"export_kind": "vector_selection",
"bbox": expected_bbox,
"limit": 250,
},
)
assert response.status_code == 200
@@ -116,6 +125,55 @@ def test_vector_selection_geojson_export_endpoint_returns_canonical_envelope(mon
assert payload["data"]["export_id"] == str(export_id)
assert payload["data"]["export_type"] == "vector_selection_geojson"
assert payload["data"]["metadata_json"]["selection_bbox"] == expected_bbox
assert captured["area_id"] == area_id
def test_vector_selection_export_uses_exact_area_scope_when_requested(tmp_path, monkeypatch) -> None:
project_id = uuid4()
dataset_id = uuid4()
area_id = uuid4()
dataset = Dataset(
id=dataset_id,
project_id=project_id,
name="regional-buildings.geojson",
dataset_type="vector",
source="fixture",
status="ready",
)
area_geometry = object()
area = SimpleNamespace(id=area_id, project_id=project_id, name="Gemeente Mol", geometry=area_geometry)
db = FakeSession({(Dataset, dataset_id): dataset, (Area, area_id): area})
selection_bbox = {"min_x": 5.0, "min_y": 51.1, "max_x": 5.2, "max_y": 51.3, "crs": "EPSG:4326"}
captured: dict = {}
selection_payload = {
"selection_bbox": selection_bbox,
"selection_area_id": str(area_id),
"feature_count": 0,
"limit": 250,
"truncated": False,
"geojson": {"type": "FeatureCollection", "features": []},
}
monkeypatch.setattr(StorageService, "dataset_export_path", lambda *_args: str(tmp_path / "selection.geojson"))
monkeypatch.setattr(VectorFeatureService, "can_use_full_area_fast_path", lambda *_args: True)
def fake_select(*_args, **kwargs):
captured.update(kwargs)
return selection_payload
monkeypatch.setattr(VectorFeatureService, "select_features_by_bbox", fake_select)
response = ExportService.export_vector_selection_geojson(
db,
dataset_id,
selection_bbox,
area_id=area_id,
)
assert captured["selection_geometry"] is area_geometry
assert captured["selection_area_id"] == area_id
assert captured["full_dataset_area"] is True
assert response.metadata_json["selection_area_id"] == str(area_id)
def test_frontend_exposes_map_selection_export_action() -> None:
@@ -127,6 +185,7 @@ def test_frontend_exposes_map_selection_export_action() -> None:
assert "'vector_selection'" in types
assert "bbox?: VectorSelectionBBox" in exports_api
assert "area_id?: string" in exports_api
assert "exportMapSelectionGeoJson" in export_hook
assert "vector_selection" in export_hook
assert "Save area export" in map_workspace
@@ -36,7 +36,10 @@ def test_map_rectangle_drag_is_wired_to_automatic_analysis() -> None:
assert "onMapBboxPreview={handleMapBboxPreview}" in workspace
assert "onMapBboxSelect={handleMapBboxSelect}" in workspace
assert "void analyzeSelection(bbox, selectedMapArea?.id)" in workspace
assert "void analyzeSelection(bbox)" in workspace
assert "const areaIdForSelection" in workspace
assert "&& bboxesEqual(bbox, selectedAreaBbox)" in workspace
assert "void analyzeSelection(selectedAreaBbox, selectedMapArea?.id)" in workspace
assert "map.on('mousedown'" in geomap
assert "map.on('mousemove'" in geomap
assert "map.on('mouseup'" in geomap
@@ -33,8 +33,10 @@ def test_regional_map_uses_logical_partition_groups_and_exact_analysis() -> None
assert "/datasets/raster/terrain/select" in api
assert "/datasets/raster/flood-hazard/select" in api
assert "Rasterlaag actief" in app
assert "void analyzeSelection(bbox, selectedMapArea?.id)" in workspace
assert "onDeriveMapSelectionDataset(bbox, selectedMapArea?.id)" in workspace
assert "void analyzeSelection(bbox)" in workspace
assert "areaIdForSelection(bbox)" in workspace
assert "void analyzeSelection(selectedAreaBbox, selectedMapArea?.id)" in workspace
assert "onDeriveMapSelectionDataset(bbox, areaIdForSelection(bbox))" in workspace
def test_maplibre_supports_multiple_persisted_raster_overlays() -> None:
@@ -36,9 +36,12 @@ def test_completed_analysis_hands_off_to_ai_and_downloads_responsively() -> None
assert "onOpenAssistant: () => void" in workspace
assert "onOpenExports: () => void" in workspace
assert "Stel AI-vraag" in workspace
assert "Open downloads" in workspace
assert "Bewaar in downloads" in workspace
assert "persistActiveResultAndOpenDownloads" in workspace
assert "onPersistMapResult(payload)" in workspace
assert "onOpenAssistant={() => setActiveWorkspace('assistant')}" in app
assert "onOpenExports={() => setActiveWorkspace('exports')}" in app
assert "onPersistMapResult={persistMapResult}" in app
assert 'className="workspace-persistent-map"' in app
assert "hidden={activeWorkspace !== 'map'}" in app
assert "{activeWorkspace === 'map' ? (" not in app
@@ -0,0 +1,338 @@
from __future__ import annotations
import json
from pathlib import Path
from types import SimpleNamespace
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from pydantic import ValidationError
from app.main import app
from app.models import Dataset, Export
from app.schemas.export import ExportCreateResponse, MapResultExportRequest
from app.schemas.project import ProjectRead
from app.services.export_service import ExportService
from app.services.project_service import ProjectService
from app.services.storage_service import StorageService
from app.services.temporal_analysis_service import TemporalAnalysisService
from app.services.thematic_raster_analysis_service import ThematicRasterAnalysisService
class FakeSession:
def __init__(self, rows=None):
self.rows = rows or {}
self.added = []
def get(self, model, row_id):
return self.rows.get((model, row_id))
def add(self, row):
self.added.append(row)
def commit(self):
return None
def refresh(self, row):
return row
def bbox_payload() -> dict:
return {
"min_x": 5.10,
"min_y": 51.17,
"max_x": 5.11,
"max_y": 51.18,
"crs": "EPSG:4326",
}
def test_map_result_export_request_requires_a_complete_target() -> None:
with pytest.raises(ValidationError):
MapResultExportRequest(project_id=uuid4(), mode="current", bbox=bbox_payload())
with pytest.raises(ValidationError):
MapResultExportRequest(project_id=uuid4(), mode="evolution", bbox=bbox_payload())
with pytest.raises(ValidationError):
MapResultExportRequest(
project_id=uuid4(),
mode="current",
dataset_id=uuid4(),
bbox=bbox_payload(),
partitioned=True,
)
def test_current_vector_map_result_uses_authoritative_selection_export(monkeypatch) -> None:
project_id = uuid4()
dataset_id = uuid4()
area_id = uuid4()
dataset = Dataset(
id=dataset_id,
project_id=project_id,
name="buildings.geojson",
dataset_type="vector",
source="fixture",
status="ready",
)
db = FakeSession({(Dataset, dataset_id): dataset})
expected = ExportCreateResponse(
export_id=uuid4(),
path="storage/exports/buildings-selection.geojson",
status="ready",
export_type="vector_selection_geojson",
)
captured: dict = {}
def fake_vector_export(*_args, **kwargs):
captured.update(kwargs)
return expected
monkeypatch.setattr(ExportService, "export_vector_selection_geojson", fake_vector_export)
response = ExportService.export_map_result(
db,
MapResultExportRequest(
project_id=project_id,
mode="current",
dataset_id=dataset_id,
area_id=area_id,
bbox=bbox_payload(),
theme_id="buildings",
),
)
assert response is expected
assert captured["area_id"] == area_id
assert captured["limit"] == 1000
def test_raster_map_result_is_recomputed_and_persisted(tmp_path, monkeypatch) -> None:
project_id = uuid4()
dataset_id = uuid4()
dataset = Dataset(
id=dataset_id,
project_id=project_id,
name="space-occupation.tif",
dataset_type="raster",
source="official",
source_name="department_omgeving_thematic_raster",
status="ready",
)
db = FakeSession({(Dataset, dataset_id): dataset})
export_path = tmp_path / "space-occupation-analysis.json"
captured: dict = {}
def fake_analyze(_db, captured_project_id, captured_dataset_id, payload):
captured.update(
project_id=captured_project_id,
dataset_id=captured_dataset_id,
area_id=payload.area_id,
)
return {
"selection_bbox": bbox_payload(),
"summary": {"metric_label": "Ruimtebeslag", "metric_value": 12.5, "metric_unit": "ha"},
}
monkeypatch.setattr(ThematicRasterAnalysisService, "analyze", fake_analyze)
monkeypatch.setattr(StorageService, "dataset_export_path", lambda *_args: str(export_path))
response = ExportService.export_map_result(
db,
MapResultExportRequest(
project_id=project_id,
mode="current",
dataset_id=dataset_id,
bbox=bbox_payload(),
theme_id="space_occupation",
),
)
persisted = [row for row in db.added if isinstance(row, Export)]
assert response.export_type == "map_analysis_json"
assert len(persisted) == 1
assert persisted[0].metadata_json["server_recomputed"] is True
assert persisted[0].metadata_json["theme_id"] == "space_occupation"
assert captured == {"project_id": project_id, "dataset_id": dataset_id, "area_id": None}
assert json.loads(export_path.read_text(encoding="utf-8"))["result"]["summary"]["metric_value"] == 12.5
def test_evolution_map_result_is_recomputed_and_persisted(tmp_path, monkeypatch) -> None:
project_id = uuid4()
earlier_id = uuid4()
later_id = uuid4()
db = FakeSession()
export_path = tmp_path / "forest-evolution.json"
captured: dict = {}
class Comparison:
def model_dump(self, *, mode):
assert mode == "json"
return {"temporal_series_key": "forest", "metric": {"absolute_change": -2.0}}
def fake_compare(_db, *, project_id, payload):
captured.update(project_id=project_id, payload=payload)
return Comparison()
monkeypatch.setattr(TemporalAnalysisService, "compare", fake_compare)
monkeypatch.setattr(StorageService, "dataset_export_path", lambda *_args: str(export_path))
response = ExportService.export_map_result(
db,
MapResultExportRequest(
project_id=project_id,
mode="evolution",
earlier_dataset_id=earlier_id,
later_dataset_id=later_id,
bbox=bbox_payload(),
theme_id="forest",
),
)
assert response.export_type == "map_evolution_json"
assert captured["project_id"] == project_id
assert captured["payload"].earlier_dataset_id == earlier_id
assert json.loads(export_path.read_text(encoding="utf-8"))["metric"]["absolute_change"] == -2.0
def test_map_result_export_endpoint_uses_canonical_envelope(monkeypatch) -> None:
project_id = uuid4()
dataset_id = uuid4()
export_id = uuid4()
monkeypatch.setattr(
ExportService,
"export_map_result",
lambda *_args: ExportCreateResponse(
export_id=export_id,
path="storage/exports/map-analysis.json",
status="ready",
export_type="map_analysis_json",
),
)
response = TestClient(app).post(
"/api/v1/exports/map-result",
json={
"project_id": str(project_id),
"mode": "current",
"dataset_id": str(dataset_id),
"bbox": bbox_payload(),
"theme_id": "space_occupation",
},
)
assert response.status_code == 200
assert response.json() == {
"data": {
"export_id": str(export_id),
"path": "storage/exports/map-analysis.json",
"status": "ready",
"export_type": "map_analysis_json",
"metadata_json": None,
}
}
def test_frontend_persists_map_result_before_opening_downloads() -> None:
root = Path(__file__).resolve().parents[2]
workspace = (root / "frontend/src/components/map/MapWorkspace.tsx").read_text(encoding="utf-8")
hook = (root / "frontend/src/hooks/useExportWorkflow.ts").read_text(encoding="utf-8")
api = (root / "frontend/src/services/api/exports.ts").read_text(encoding="utf-8")
assert "persistActiveResultAndOpenDownloads" in workspace
assert "onPersistMapResult(payload)" in workspace
assert "Bewaar in downloads" in workspace
assert "persistMapResult" in hook
assert "exportsApi.exportMapResult(payload)" in hook
assert "/api/v1/exports/map-result" in api
def test_project_list_supports_exact_canonical_workspace_lookup(monkeypatch) -> None:
project_id = uuid4()
captured: dict = {}
def fake_list(_db, *, limit, offset, name):
captured.update(limit=limit, offset=offset, name=name)
return [
ProjectRead(
id=project_id,
name="Kempen Regional Workbench",
region="Kempen",
status="active",
)
], 1
monkeypatch.setattr(ProjectService, "list_projects", fake_list)
response = TestClient(app).get(
"/api/v1/projects",
params={"name": "Kempen Regional Workbench", "limit": 1},
)
assert response.status_code == 200
assert response.json()["data"]["items"][0]["id"] == str(project_id)
assert captured == {
"limit": 1,
"offset": 0,
"name": "Kempen Regional Workbench",
}
def test_frontend_fetches_canonical_workspace_outside_default_project_page() -> None:
root = Path(__file__).resolve().parents[2]
workflow = (root / "frontend/src/hooks/useProjectWorkspace.ts").read_text(encoding="utf-8")
api = (root / "frontend/src/services/api/projects.ts").read_text(encoding="utf-8")
assert "projectsApi.list({ name: REGIONAL_WORKSPACE_PROJECT_NAME, limit: 1 })" in workflow
assert "[...canonicalResponse.items, ...response.items]" in workflow
assert "new URLSearchParams()" in api
def test_theme_failures_name_the_source_and_reason() -> None:
root = Path(__file__).resolve().parents[2]
hook = (root / "frontend/src/hooks/useMapThemeSelectionInsights.ts").read_text(encoding="utf-8")
assert "dataset: queries[index]?.dataset.name" in hook
assert "reason: formatError(item.reason" in hook
assert "failure.dataset}: ${failure.reason}" in hook
def test_workspace_navigation_resets_the_actual_scroll_container() -> None:
root = Path(__file__).resolve().parents[2]
app = (root / "frontend/src/App.tsx").read_text(encoding="utf-8")
assert "const workbenchMainRef = useRef<HTMLElement | null>(null)" in app
assert "workbenchMainRef.current?.scrollTo({ top: 0, left: 0 })" in app
assert "<main ref={workbenchMainRef}" in app
def test_quality_scores_have_plain_language_interpretation() -> None:
root = Path(__file__).resolve().parents[2]
quality = (root / "frontend/src/components/quality/QualityResultsPanel.tsx").read_text(encoding="utf-8")
detection = (root / "frontend/src/components/detection/DetectionLab.tsx").read_text(encoding="utf-8")
assert "Laatste score (0-1)" in quality
assert "Bruikbaar na controle" in quality
assert "Verkennend, controle vereist" in quality
assert "detectionQualityInterpretation" in detection
assert "Verkennend resultaat; beoordeel fouten" in detection
def test_map_and_detection_workspaces_avoid_page_length_driven_layouts() -> None:
root = Path(__file__).resolve().parents[2]
styles = (root / "frontend/src/styles/app.css").read_text(encoding="utf-8")
premium = (root / "frontend/src/styles/premium.css").read_text(encoding="utf-8")
assert "height: clamp(34rem, calc(100dvh - 10rem), 58rem);" in styles
assert ".geo-theme-list" in styles and "overflow-y: auto;" in styles
assert "@media (max-width: 1240px)" in styles
assert ".workspace-grid-ai {\n grid-template-columns: minmax(0, 1fr);" in premium
assert "max-height: none;" in premium
def test_download_workspace_surfaces_map_results_in_plain_dutch() -> None:
root = Path(__file__).resolve().parents[2]
exports = (root / "frontend/src/components/exports/ExportCenter.tsx").read_text(encoding="utf-8")
assert "Gebiedsanalyse (JSON)" in exports
assert "Historische vergelijking (JSON)" in exports
assert "Kaartselectie (GeoJSON)" in exports
assert "Klaar om te delen" in exports
assert "Downloads vernieuwen" in exports
assert "JSON bekijken" in exports
@@ -35,10 +35,10 @@ def test_app_entrypoint_has_clean_encoding_and_react_imports() -> None:
app = app_path.read_text(encoding="utf-8")
assert not app_bytes.startswith(b"\xef\xbb\xbf")
assert "import { useEffect, useMemo, useState } from 'react'" in app
assert "import { useEffect, useMemo, useRef, useState } from 'react'" in app
assert "FormEvent" not in app
assert app.count("useEffect(") == 1
assert "window.scrollTo({ top: 0, left: 0 })" in app
assert "workbenchMainRef.current?.scrollTo({ top: 0, left: 0 })" in app
assert "const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceKey>('map')" in app
@@ -38,12 +38,12 @@ def test_export_center_uses_artifact_actions_and_cards() -> None:
assert "exportMatchesSearch" in export_center
assert "filteredExports = useMemo" in export_center
assert "visibleExports = showAllExports ? filteredExports : filteredExports.slice(0, 10)" in export_center
assert "No exports match the current filters." in export_center
assert "Reset view" in export_center
assert "Show all exports" in export_center
assert "Geen downloads passen bij deze filters." in export_center
assert "Filters wissen" in export_center
assert "Toon alle downloads" in export_center
assert "onExportDataset" in export_center
assert "onExportDetectionRun" in export_center
assert "onExportSegmentationRun" in export_center
assert "download-only HTML artifact" in export_center
assert "HTML alleen downloaden" in export_center
assert 'className="export-preview-panel"' in export_preview
assert "Nog geen bestand gekozen." in export_preview
@@ -35,7 +35,7 @@ def test_shell_preserves_workspace_width_on_standard_desktop_viewports() -> None
assert "@media (max-width: 1360px)" in css
assert ".workbench-inspector {\n grid-column: 1 / -1;" in css
assert "height: auto;" in css
assert "window.scrollTo({ top: 0, left: 0 })" in app
assert "workbenchMainRef.current?.scrollTo({ top: 0, left: 0 })" in app
assert "}, [activeWorkspace])" in app
@@ -16,7 +16,7 @@ def test_quality_results_panel_exposes_selected_check_drilldown() -> None:
assert "Te controleren laag" in panel
assert "Referentielaag" in panel
assert "Analyserun" in panel
assert "Job" in panel
assert "Taak" in panel
assert "Afgerond" in panel
assert "Laatste controle bekijken" in panel
assert "Controle bekijken" in panel
+65 -2
View File
@@ -90,7 +90,13 @@ Returns enabled feature flags and tool availability.
### GET `/api/v1/projects`
Returns all projects.
Returns active projects with `limit`/`offset` pagination. Optional exact
`name` filtering supports stable lookup of a canonical operational workspace
without depending on its position among newer operator or benchmark projects:
```text
GET /api/v1/projects?name=Kempen%20Regional%20Workbench&limit=1
```
### POST `/api/v1/projects`
@@ -1729,6 +1735,7 @@ Map vector selection export request:
{
"export_kind": "vector_selection",
"dataset_id": "uuid",
"area_id": "optional-uuid-for-an-exact-full-work-area-export",
"bbox": {
"min_x": 5.0,
"min_y": 51.0,
@@ -1781,9 +1788,65 @@ segmentation exports use persisted first-class geometry records and the
existing Detection/Segmentation GeoJSON conversion services. Vector selection
exports query persisted PostGIS `vector_features` with the supplied EPSG:4326
bbox, write the selected FeatureCollection as a `vector_selection_geojson`
artifact, and persist bbox/feature-count metadata in the export record. Raster
artifact, and persist bbox/feature-count metadata in the export record. When
`area_id` is present, the persisted Area geometry is the exact export scope;
the frontend only sends this for the explicit `Volledig werkgebied` action.
Drawn or manually entered rectangles omit `area_id` and remain exact bbox
queries. Raster
datasets are rejected for dataset and selection GeoJSON export.
### POST `/api/v1/exports/map-result`
Persists the active map result before opening the Downloads workspace. The
backend recomputes the result from persisted data; it never accepts client
metrics as authoritative export content.
Current vector or governed raster request:
```json
{
"project_id": "uuid",
"mode": "current",
"dataset_id": "uuid",
"bbox": {
"min_x": 5.10,
"min_y": 51.17,
"max_x": 5.11,
"max_y": 51.18,
"crs": "EPSG:4326"
},
"area_id": null,
"partitioned": false,
"product_key": null,
"theme_id": "buildings",
"name": "buildings-analysis"
}
```
Historical comparison request:
```json
{
"project_id": "uuid",
"mode": "evolution",
"earlier_dataset_id": "uuid",
"later_dataset_id": "uuid",
"bbox": {
"min_x": 5.10,
"min_y": 51.17,
"max_x": 5.11,
"max_y": 51.18,
"crs": "EPSG:4326"
},
"theme_id": "forest"
}
```
Vector results reuse the authoritative `vector_selection_geojson` flow.
Governed raster results are persisted as `map_analysis_json`; temporal
comparisons use `map_evolution_json`. Metadata records the bbox, optional exact
Area scope, source datasets, theme and `server_recomputed=true`.
### POST `/api/v1/exports/metadata`
Exports project metadata JSON for projects, datasets, persisted QA/QC summary
+36
View File
@@ -9898,3 +9898,39 @@ V1 completion status:
source editions, additional independent AI review data and real segmentation
models are optional controlled expansions, not prerequisites for using the
current map, measurement, evolution, local-AI and export workflow.
## Sprint 233 - Operational correctness and result completion (2026-07-17)
Implemented:
- Corrected the map-first selection scope after live API evidence showed that a
drawn bbox plus the active `area_id` returned the complete municipality
vector population. Drawn and manual bboxes now omit `area_id`; the explicit
full-work-area action keeps the exact persisted Area geometry.
- Applied the same scope decision to current theme queries, temporal
comparisons, derived selection datasets and vector exports.
- Added Area-aware persistent vector selection exports and
`POST /api/v1/exports/map-result`. Current vector, governed raster and
temporal exports are recomputed server-side from persisted data before an
export row and artifact are written.
- Replaced the passive `Open downloads` handoff with `Bewaar in downloads`.
Downloads opens only after successful persistence and surfaces current map
analyses, historical comparisons and vector selections among the latest
artifacts.
- Added exact project-name filtering and merged the canonical
`Kempen Regional Workbench` into the frontend project page. This remains
stable even with more than 1,000 retained operator/benchmark projects.
- Named failed map sources and their API reasons, reset the actual scrolling
workbench container on navigation, bounded the desktop map layout, removed
nested desktop scrolling from Detection Lab and added plain-language QA/F1
interpretation.
- Translated the visible Downloads workflow and moved technical identifiers to
the existing history disclosure.
Validation:
- Focused operational-correctness, map-selection, export and V1 flow tests
passed after the corrected selection semantics were applied.
- The complete readiness gate passed 895 backend tests, backend compilation,
documentation smoke, API contract audit, the single Alembic head
`202607160001`, frontend TypeScript typecheck and the production build.
- Live all-in-one deployment, exact bbox/full-Area API comparison and browser
acceptance follow from this validated repository state.
+8
View File
@@ -11,6 +11,9 @@ geen open productroadmap meer.
vereist.
- [x] Kies een begrijpbaar datathema, teken een rechthoek of gebruik het
volledige werkgebied en analyseer alle 15 beschikbare thema's uit PostGIS.
- [x] Houd getekende en handmatig ingevoerde rechthoeken als exacte bbox-scope
voor vector-, raster- en tijdreeksmetingen; gebruik de persistente
Area-geometrie uitsluitend voor de expliciete actie `Volledig werkgebied`.
- [x] Toon betekenisvolle eenheden en metrieklabels voor oppervlakte, lengte,
inwoners, hoogte, scenario's en stationsmetingen; objectaantallen zijn
ondersteunend.
@@ -21,6 +24,11 @@ geen open productroadmap meer.
- [x] Ga vanuit een afgeronde analyse rechtstreeks naar de lokale
brongebonden Ollama-assistent of naar Downloads en keer terug zonder het
zichtbare kaartresultaat of de evolutievergelijking te verliezen.
- [x] Herbereken en bewaar het actieve vector-, raster- of evolutieresultaat
server-side voordat Downloads wordt geopend, met traceerbare selectie- en
bronmetadata.
- [x] Haal de canonieke regionale werkruimte exact op naam op zodat groei van
operator- en benchmarkprojecten de standaard kaartcontext niet kan verdringen.
- [x] Gebruik de operationele lokale YOLO/PyTorch-keten alleen met persisted
detecties, GRB-QA en expliciete controlewaarschuwingen.
- [x] Draai de volledige applicatie in de beheersbare Unraid-container op poort
+4 -2
View File
@@ -69,7 +69,7 @@ result. Water explicitly explains that volume cannot be derived without a
reliable depth or bathymetry source. The advanced workbench remains available
but is not required for the primary choose-theme, draw-area, read-result flow.
The primary workflow is deliberately short: choose a municipality or the complete region, choose a data theme, drag a rectangle on the MapLibre map and read the resulting PostGIS evidence. Releasing the drag runs the active theme query and every other available theme query for the same EPSG:4326 bbox. The result panel shows selection area, exact intersection totals, active-theme density, source identity and bounded feature properties. Map rendering remains capped at 1,000 features while `total_feature_count` reports the exact database count.
The primary workflow is deliberately short: choose a municipality or the complete region, choose a data theme, drag a rectangle on the MapLibre map and read the resulting PostGIS evidence. Releasing the drag runs the active theme query and every other available theme query for the same EPSG:4326 bbox. Drawn/manual rectangles never inherit the active municipality `area_id`; only `Volledig werkgebied` uses the exact persisted Area geometry. The result panel shows selection area, exact intersection totals, active-theme density, source identity and bounded feature properties. Map rendering remains capped at 1,000 features while `total_feature_count` reports the exact database count.
Every cross-theme result now names the measured quantity next to the value, so
a station water level, mapped area and line length cannot appear as an
@@ -77,7 +77,9 @@ unexplained bare number. Current vector selections download as GeoJSON,
current raster analyses download as JSON and historical comparisons download
as JSON with their complete timeline and provenance. After a successful
analysis, compact next actions carry the same spatial context directly into
`AI-vragen` or `Downloads`.
`AI-vragen` or `Downloads`. `Bewaar in downloads` first asks the backend to
recompute and persist the active vector, governed raster or historical result;
Downloads opens only when that traceable artifact exists.
For a rectangle in `Laatste toestand`, `Herken gebouwen` runs the complete
operational image path without opening the technical AI screen: bounded
+7 -4
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import './styles/app.css'
import './styles/premium.css'
import { ChangeDetectionPanel } from './components/analysis/ChangeDetectionPanel'
@@ -68,8 +68,9 @@ function App(): JSX.Element {
const [inspectorOpen, setInspectorOpen] = useState(false)
const [mapContentMode, setMapContentMode] = useState<'dataset' | 'analysis'>('dataset')
const [mapContextSourceLabel, setMapContextSourceLabel] = useState<string | null>(null)
const workbenchMainRef = useRef<HTMLElement | null>(null)
useEffect(() => {
window.scrollTo({ top: 0, left: 0 })
workbenchMainRef.current?.scrollTo({ top: 0, left: 0 })
setInspectorOpen(false)
}, [activeWorkspace])
const {
@@ -411,6 +412,7 @@ function App(): JSX.Element {
exportSelectedDetectionRunGeoJson,
exportSelectedSegmentationRunGeoJson,
exportMapSelectionGeoJson,
persistMapResult,
exportProjectMetadata,
exportProjectReport,
previewExportContent,
@@ -805,7 +807,7 @@ function App(): JSX.Element {
</nav>
</aside>
<main className="workbench-main" id="workspace-main" tabIndex={-1}>
<main ref={workbenchMainRef} className="workbench-main" id="workspace-main" tabIndex={-1}>
{activeWorkspace !== 'map' ? <div className="workspace-heading">
<div className="workspace-heading-copy">
<p className="eyebrow">{selectedProject?.region ?? 'Mol, Kempen'}</p>
@@ -821,7 +823,7 @@ function App(): JSX.Element {
aria-expanded={inspectorOpen}
aria-controls="workbench-inspector"
>
{inspectorOpen ? 'Details sluiten' : 'Details openen'}
{inspectorOpen ? 'Context sluiten' : 'Context bekijken'}
</button>
) : null}
</div>
@@ -1072,6 +1074,7 @@ function App(): JSX.Element {
onRunMapSelectionExtract={runMapSelectionExtract}
onClearMapSelectionExtract={resetMapSelectionExtract}
onExportMapSelection={exportMapSelectionGeoJson}
onPersistMapResult={persistMapResult}
onDeriveMapSelectionDataset={deriveMapSelectionDataset}
onSelectMapQaReferenceDataset={setSelectedMapQaReferenceDatasetId}
onRunMapSelectionQa={runMapSelectionQa}
@@ -23,6 +23,14 @@ function detectionModelLabel(model: DetectionModelCapability): string {
return model.display_name
}
function detectionQualityInterpretation(f1: number | null | undefined): string {
if (typeof f1 !== 'number' || !Number.isFinite(f1)) return 'Nog geen gevalideerde kwaliteitsmeting.'
if (f1 >= 0.85) return 'Sterk resultaat; steekproefcontrole blijft vereist.'
if (f1 >= 0.70) return 'Bruikbaar met gerichte handmatige controle.'
if (f1 >= 0.50) return 'Verkennend resultaat; beoordeel fouten voor operationeel gebruik.'
return 'Onvoldoende betrouwbaar voor operationeel gebruik.'
}
interface CalibrationRow {
analysisRunId: string
qualityCheckId: string
@@ -272,6 +280,9 @@ export function DetectionLab({
<p>{rasterDatasets.length > 0 ? 'Klaar om een beeld te kiezen.' : 'Laad eerst een gegeorefereerd luchtbeeld in.'}</p>
</div>
</div>
<p className="ai-quality-guidance">
{detectionQualityInterpretation(selectedOperatorProfile?.f1)}
</p>
<details className="ai-lab-model-surface" aria-label="Detection model capabilities">
<summary>
@@ -44,6 +44,19 @@ function compactPath(value: string): string {
}
function formatExportType(value: string): string {
const labels: Record<string, string> = {
vector_selection_geojson: 'Kaartselectie (GeoJSON)',
map_analysis_json: 'Gebiedsanalyse (JSON)',
map_evolution_json: 'Historische vergelijking (JSON)',
dataset_geojson: 'Kaartlaag (GeoJSON)',
detection_geojson: 'Gebouwdetecties (GeoJSON)',
segmentation_geojson: 'Segmentaties (GeoJSON)',
project_metadata_json: 'Werkruimtedata (JSON)',
project_report_html: 'Projectrapport (HTML)',
}
if (labels[value]) {
return labels[value]
}
return value
.split('_')
.filter(Boolean)
@@ -53,9 +66,10 @@ function formatExportType(value: string): string {
function formatExportCreated(value?: string | null): string {
if (!value) {
return 'created time unavailable'
return 'Tijdstip onbekend'
}
return value
const parsed = new Date(value)
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString('nl-BE')
}
function getLatestExportByType(exports: ExportRead[], exportType: string): ExportRead | null {
@@ -103,6 +117,24 @@ export function ExportCenter({
const exportStatuses = useMemo(() => Array.from(new Set(exports.map((item) => item.status))).sort(), [exports])
const latestHandoffArtifacts = useMemo(
() => [
{
key: 'map_analysis_json',
label: 'Gebiedsanalyse',
detail: 'Server-side herberekende metingen van de kaartselectie',
item: getLatestExportByType(exports, 'map_analysis_json'),
},
{
key: 'map_evolution_json',
label: 'Historische vergelijking',
detail: 'Bewaarde evolutie van het geselecteerde gebied',
item: getLatestExportByType(exports, 'map_evolution_json'),
},
{
key: 'vector_selection_geojson',
label: 'Kaartselectie (GeoJSON)',
detail: 'Persistente objecten binnen de geselecteerde rechthoek',
item: getLatestExportByType(exports, 'vector_selection_geojson'),
},
{
key: 'project_report_html',
label: 'Leesbaar rapport',
@@ -182,8 +214,8 @@ export function ExportCenter({
<div className="handoff-summary-card">
<div className="panel-title-row">
<div>
<h3>Handoff readiness</h3>
<p className="muted">Pick the artifact that matches the current review state before sharing data outside the workbench.</p>
<h3>Klaar om te delen</h3>
<p className="muted">Controleer het juiste bestand voordat je gegevens buiten GeoIntel gebruikt.</p>
</div>
<span className="status-badge">{handoffStatus}</span>
</div>
@@ -260,9 +292,9 @@ export function ExportCenter({
<div className="export-action-grid">
<div className="handoff-action-grid">
<div className="handoff-action-card handoff-action-card-technical">
<span>Refresh</span>
<strong>Export registry</strong>
<p>Reload the persisted artifact list for the selected project.</p>
<span>Vernieuwen</span>
<strong>Bewaarde bestanden</strong>
<p>Laad de persistente downloads van de geselecteerde werkruimte opnieuw.</p>
<button
type="button"
className="secondary-action"
@@ -270,7 +302,7 @@ export function ExportCenter({
disabled={!selectedProjectId || loadingExports}
data-testid="refresh-exports"
>
Refresh exports
Downloads vernieuwen
</button>
</div>
<div className="handoff-action-card handoff-action-card-primary">
@@ -304,19 +336,19 @@ export function ExportCenter({
</button>
</div>
{selectedDetectionRunId ? <div className="handoff-action-card">
<span>Detection run</span>
<strong>Detections GeoJSON</strong>
<p>Export persisted detection geometries for the selected analysis run.</p>
<span>Gebouwanalyse</span>
<strong>Herkende gebouwen (GeoJSON)</strong>
<p>Bewaar de persistente detectiegeometrie van de geselecteerde analyserun.</p>
<button type="button" className="secondary-action" onClick={onExportDetectionRun} disabled={!selectedDetectionRunId || exporting}>
Export selected detection run GeoJSON
Gebouwanalyse bewaren
</button>
</div> : null}
{selectedSegmentationRunId ? <div className="handoff-action-card">
<span>Segmentation run</span>
<strong>Segmentations GeoJSON</strong>
<p>Export persisted segmentation polygons for the selected analysis run.</p>
<span>Segmentatieanalyse</span>
<strong>Segmentaties (GeoJSON)</strong>
<p>Bewaar de persistente polygonen van de geselecteerde segmentatierun.</p>
<button type="button" className="secondary-action" onClick={onExportSegmentationRun} disabled={!selectedSegmentationRunId || exporting}>
Export selected segmentation run GeoJSON
Segmentatie bewaren
</button>
</div> : null}
</div>
@@ -358,16 +390,16 @@ export function ExportCenter({
</summary>
<div className="export-history-body">
<div>
<h3>Export history</h3>
<p className="muted">Review persisted artifacts and open JSON previews where supported.</p>
<h3>Bewaarde downloads</h3>
<p className="muted">Bekijk persistente bestanden en open beschikbare JSON-voorbeelden.</p>
</div>
{exports.length > 0 ? (
<div className="export-history-controls" aria-label="Export history filters">
<label>
Search exports
Downloads zoeken
<input
type="search"
placeholder="Type, id or path"
placeholder="Type, id of pad"
value={exportSearchQuery}
onChange={(event) => setExportSearchQuery(event.target.value)}
/>
@@ -375,7 +407,7 @@ export function ExportCenter({
<label>
Type
<select value={exportTypeFilter} onChange={(event) => setExportTypeFilter(event.target.value)}>
<option value="all">All types</option>
<option value="all">Alle types</option>
{exportTypes.map((type) => (
<option key={type} value={type}>
{type}
@@ -386,7 +418,7 @@ export function ExportCenter({
<label>
Status
<select value={exportStatusFilter} onChange={(event) => setExportStatusFilter(event.target.value)}>
<option value="all">All statuses</option>
<option value="all">Alle statussen</option>
{exportStatuses.map((status) => (
<option key={status} value={status}>
{status}
@@ -405,25 +437,25 @@ export function ExportCenter({
}}
disabled={!hasActiveExportFilters && !showAllExports}
>
Reset view
Filters wissen
</button>
</div>
) : null}
{filteredExports.length > 10 ? (
<div className="list-limit-banner">
<span>
Showing {visibleExports.length} of {filteredExports.length} matching exports.
{hiddenExportCount > 0 ? ` ${hiddenExportCount} older artifacts hidden.` : ' All matching artifacts shown.'}
{visibleExports.length} van {filteredExports.length} passende downloads zichtbaar.
{hiddenExportCount > 0 ? ` ${hiddenExportCount} oudere bestanden verborgen.` : ' Alle passende bestanden zijn zichtbaar.'}
</span>
<button type="button" className="secondary-action" onClick={() => setShowAllExports((value) => !value)}>
{showAllExports ? 'Show latest 10' : 'Show all exports'}
{showAllExports ? 'Toon laatste 10' : 'Toon alle downloads'}
</button>
</div>
) : null}
{exports.length > 0 && filteredExports.length === 0 ? (
<div className="result-state result-state-empty">
<strong>No exports match the current filters.</strong>
<p>Clear the search, type or status filter to return to the latest artifacts.</p>
<strong>Geen downloads passen bij deze filters.</strong>
<p>Wis de zoekopdracht of filters om de recentste bestanden opnieuw te tonen.</p>
</div>
) : null}
<ul className="export-list">
@@ -433,9 +465,9 @@ export function ExportCenter({
<div>
<span className="export-type-badge">{formatExportType(item.export_type)}</span>
<div className="entity-meta">
<span>export id: {item.id}</span>
{item.analysis_run_id ? <span>analysis run: {item.analysis_run_id}</span> : null}
{item.created_at ? <span>created: {item.created_at}</span> : null}
<span>download-id: {item.id}</span>
{item.analysis_run_id ? <span>analyserun: {item.analysis_run_id}</span> : null}
{item.created_at ? <span>gemaakt: {formatExportCreated(item.created_at)}</span> : null}
</div>
</div>
<span className={item.status === 'ready' ? 'status-badge status-badge-ready' : 'status-badge'}>{item.status}</span>
@@ -444,13 +476,13 @@ export function ExportCenter({
<div className="button-row">
{canPreviewJson(item) ? (
<button type="button" className="secondary-action" onClick={() => onPreviewContent(item.id)}>
Preview JSON content
JSON bekijken
</button>
) : (
<span className="status-badge">download-only HTML artifact</span>
<span className="status-badge">HTML alleen downloaden</span>
)}
<button type="button" className="primary-action" onClick={() => onDownload(item.id)}>
Download artifact
Downloaden
</button>
</div>
</li>
+80 -14
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import GeoMap from '../GeoMap'
import type { AreaRead, DatasetCreateResponse, DetectionQaResult, MapViewportState, OrthophotoAcquisitionResult, OrthophotoProductRead, ProjectRead, QaComparisonResult, VectorSelectionBBox, VectorSelectionMetric, VectorSelectionResponse } from '../../types'
import type { AreaRead, DatasetCreateResponse, DetectionQaResult, MapResultExportRequest, MapViewportState, OrthophotoAcquisitionResult, OrthophotoProductRead, ProjectRead, QaComparisonResult, VectorSelectionBBox, VectorSelectionMetric, VectorSelectionResponse } from '../../types'
import { featureCollectionBounds } from '../../lib/geojsonBounds'
import { useMapThemeSelectionInsights } from '../../hooks/useMapThemeSelectionInsights'
import { useTemporalComparison } from '../../hooks/useTemporalComparison'
@@ -708,7 +708,8 @@ interface MapWorkspaceProps {
onSetMapSelectionBbox: (bbox: VectorSelectionBBox | null) => void
onRunMapSelectionExtract: (bbox: VectorSelectionBBox, areaId?: string) => Promise<VectorSelectionResponse | null>
onClearMapSelectionExtract: () => void
onExportMapSelection: (bbox: VectorSelectionBBox) => Promise<unknown>
onExportMapSelection: (bbox: VectorSelectionBBox, areaId?: string) => Promise<unknown>
onPersistMapResult: (payload: MapResultExportRequest) => Promise<unknown>
onDeriveMapSelectionDataset: (bbox: VectorSelectionBBox, areaId?: string) => Promise<DatasetCreateResponse | null>
onSelectMapQaReferenceDataset: (datasetId: string) => void
onRunMapSelectionQa: (candidateDataset?: DatasetCreateResponse | null) => Promise<QaComparisonResult | null>
@@ -792,6 +793,7 @@ export function MapWorkspace({
onRunMapSelectionExtract,
onClearMapSelectionExtract,
onExportMapSelection,
onPersistMapResult,
onDeriveMapSelectionDataset,
onSelectMapQaReferenceDataset,
onRunMapSelectionQa,
@@ -1151,6 +1153,15 @@ export function MapWorkspace({
setBboxInput(bboxToInputState(bbox))
}
const areaIdForSelection = (bbox: VectorSelectionBBox | null): string | undefined => (
bbox
&& selectedMapArea
&& selectedAreaBbox
&& bboxesEqual(bbox, selectedAreaBbox)
? selectedMapArea.id
: undefined
)
const startBboxSelection = () => {
setFirstSelectionCorner(null)
clearThemeInsights()
@@ -1167,7 +1178,7 @@ export function MapWorkspace({
setSelectionBbox(bbox)
setFirstSelectionCorner(null)
setBboxSelectionMode(false)
void analyzeSelection(bbox, selectedMapArea?.id)
void analyzeSelection(bbox)
}
const runAreaExtract = () => {
@@ -1175,7 +1186,7 @@ export function MapWorkspace({
if (!bbox) {
return
}
void analyzeSelection(bbox, selectedMapArea?.id)
void analyzeSelection(bbox, areaIdForSelection(bbox))
}
const clearAreaSelection = () => {
@@ -1210,7 +1221,7 @@ export function MapWorkspace({
if (activeThemeDataset?.dataset_type === 'raster') {
downloadJsonFile(`${activeTheme.id}-analysis.json`, {
project_id: selectedProjectId,
area_id: selectedMapArea?.id ?? null,
area_id: areaIdForSelection(mapSelectionBbox) ?? null,
area_name: selectedMapArea?.name ?? null,
theme: activeTheme,
dataset_id: activeThemeDataset.id,
@@ -1238,12 +1249,54 @@ export function MapWorkspace({
copyText(JSON.stringify(temporalComparison ?? {}, null, 2))
}
const persistActiveResultAndOpenDownloads = async () => {
if (!selectedProjectId || !mapSelectionBbox) {
onOpenExports()
return
}
const areaId = areaIdForSelection(mapSelectionBbox)
const payload: MapResultExportRequest | null = analysisMode === 'evolution'
? temporalComparison && earlierDatasetId && laterDatasetId
? {
project_id: selectedProjectId,
mode: 'evolution',
bbox: { ...mapSelectionBbox, crs: 'EPSG:4326' },
earlier_dataset_id: earlierDatasetId,
later_dataset_id: laterDatasetId,
area_id: areaId,
theme_id: activeTheme.id,
name: `${activeTheme.id}-evolution`,
}
: null
: activeSelectionResult && activeThemeDataset
? {
project_id: selectedProjectId,
mode: 'current',
bbox: { ...mapSelectionBbox, crs: 'EPSG:4326' },
dataset_id: activeThemeDataset.id,
area_id: areaId,
partitioned: regionalRasterThemeActive,
product_key: String(activeThemeDataset.source_metadata?.['product_key'] ?? '') || undefined,
theme_id: activeTheme.id,
name: `${activeTheme.id}-analysis`,
}
: null
if (!payload) {
onOpenExports()
return
}
const persisted = await onPersistMapResult(payload)
if (persisted) {
onOpenExports()
}
}
const saveAreaSelectionExport = () => {
const bbox = parseBboxInput(bboxInput)
if (!bbox) {
return
}
onExportMapSelection(bbox)
onExportMapSelection(bbox, areaIdForSelection(bbox))
}
const saveAreaSelectionDataset = () => {
@@ -1251,7 +1304,7 @@ export function MapWorkspace({
if (!bbox) {
return
}
onDeriveMapSelectionDataset(bbox, selectedMapArea?.id)
onDeriveMapSelectionDataset(bbox, areaIdForSelection(bbox))
}
const openSelectedDatabaseLayer = (datasetId: string) => {
@@ -1319,7 +1372,12 @@ export function MapWorkspace({
if (!mapSelectionBbox || !earlierDatasetId || !laterDatasetId) {
return
}
void compareTemporalSnapshots(earlierDatasetId, laterDatasetId, mapSelectionBbox, selectedMapArea?.id)
void compareTemporalSnapshots(
earlierDatasetId,
laterDatasetId,
mapSelectionBbox,
areaIdForSelection(mapSelectionBbox),
)
}
const handleMapBboxPreview = (bbox: VectorSelectionBBox) => {
@@ -1329,7 +1387,7 @@ export function MapWorkspace({
const handleMapBboxSelect = (bbox: VectorSelectionBBox) => {
setFirstSelectionCorner(null)
setBboxSelectionMode(false)
void analyzeSelection(bbox, selectedMapArea?.id)
void analyzeSelection(bbox)
}
const runQuickAoiExtract = () => {
@@ -1338,7 +1396,7 @@ export function MapWorkspace({
return
}
setSelectionBbox(bbox)
void analyzeSelection(bbox, selectedMapArea?.id)
void analyzeSelection(bbox, areaIdForSelection(bbox))
}
const runFullGisWorkflow = async () => {
@@ -1377,7 +1435,8 @@ export function MapWorkspace({
try {
setFullWorkflowStatus('1/4 Querying persisted vector_features...')
setSelectionBbox(bbox)
const selection = await onRunMapSelectionExtract(bbox, selectedMapArea?.id)
const selectionAreaId = areaIdForSelection(bbox)
const selection = await onRunMapSelectionExtract(bbox, selectionAreaId)
if (!selection) {
setFullWorkflowError('Persisted vector query did not complete.')
setFullWorkflowStatus('Stopped at query.')
@@ -1385,7 +1444,7 @@ export function MapWorkspace({
}
setFullWorkflowStatus('2/4 Saving derived result dataset...')
const derived = await onDeriveMapSelectionDataset(bbox, selectedMapArea?.id)
const derived = await onDeriveMapSelectionDataset(bbox, selectionAreaId)
if (!derived) {
setFullWorkflowError('Derived result dataset was not created.')
setFullWorkflowStatus('Stopped at dataset save.')
@@ -1393,7 +1452,7 @@ export function MapWorkspace({
}
setFullWorkflowStatus('3/4 Saving GeoJSON export artifact...')
await onExportMapSelection(bbox)
await onExportMapSelection(bbox, selectionAreaId)
if (selectedMapQaReferenceDatasetId) {
setFullWorkflowStatus('4/4 Running QA/QC against selected reference...')
@@ -1982,7 +2041,14 @@ export function MapWorkspace({
<small>Stel een vraag over dit gebied of open je bewaarde resultaten.</small>
</span>
<button className="primary-action" type="button" onClick={onOpenAssistant}>Stel AI-vraag</button>
<button className="secondary-action" type="button" onClick={onOpenExports}>Open downloads</button>
<button
className="secondary-action"
type="button"
disabled={selectionExporting}
onClick={() => void persistActiveResultAndOpenDownloads()}
>
{selectionExporting ? 'Resultaat bewaren…' : 'Bewaar in downloads'}
</button>
</div>
) : null}
</>
@@ -25,19 +25,19 @@ interface QualityResultsPanelProps {
function qualityMetricLabel(metricKey: string): string {
const labels: Record<string, string> = {
precision: 'Precision',
recall: 'Recall',
precision: 'Precisie',
recall: 'Teruggevonden aandeel',
f1: 'F1',
mean_iou: 'Mean IoU',
false_positive_count: 'False positives',
false_negative_count: 'False negatives',
mean_iou: 'Gemiddelde overlap',
false_positive_count: 'Onterecht gevonden',
false_negative_count: 'Gemiste objecten',
}
return labels[metricKey] ?? metricKey.replaceAll('_', ' ')
}
function qualityMetricValue(metric: MetricRead | undefined): string {
if (!metric || metric.metric_value === null || metric.metric_value === undefined) {
return 'n/a'
return 'n.v.t.'
}
const value = Number(metric.metric_value)
if (!Number.isFinite(value)) {
@@ -47,7 +47,15 @@ function qualityMetricValue(metric: MetricRead | undefined): string {
}
function qualityScoreValue(value: number | null | undefined): string {
return typeof value === 'number' && Number.isFinite(value) ? value.toFixed(3) : 'n/a'
return typeof value === 'number' && Number.isFinite(value) ? value.toFixed(3) : 'n.v.t.'
}
function qualityScoreInterpretation(value: number | null | undefined): string {
if (typeof value !== 'number' || !Number.isFinite(value)) return 'Nog geen score'
if (value >= 0.85) return 'Sterk resultaat'
if (value >= 0.70) return 'Bruikbaar na controle'
if (value >= 0.50) return 'Verkennend, controle vereist'
return 'Onvoldoende betrouwbaar'
}
function qualityStatusLabel(status: string | null | undefined): string {
@@ -76,7 +84,7 @@ function evidenceLabel(item: Record<string, unknown>): string {
}
function formatQualityTimestamp(value?: string | null): string {
return value || 'n/a'
return value || 'n.v.t.'
}
function qualityMatchesSearch(check: QualityCheckRead, query: string, datasetNameById: Map<string, string>): boolean {
@@ -176,8 +184,9 @@ export function QualityResultsPanel({
<strong>{completedChecks}</strong>
</div>
<div>
<span>Laatste score</span>
<span>Laatste score (0-1)</span>
<strong>{qualityScoreValue(latestCheck?.score)}</strong>
<small>{qualityScoreInterpretation(latestCheck?.score)}</small>
</div>
<div>
<span>Laatste status</span>
@@ -261,7 +270,7 @@ export function QualityResultsPanel({
<div>
<span>Te controleren laag</span>
<strong>{selectedCandidateName}</strong>
<p>{selectedQualityCheck.candidate_dataset_id ?? 'candidate not stored'}</p>
<p>{selectedQualityCheck.candidate_dataset_id ?? 'niet bewaard'}</p>
</div>
<div>
<span>Referentielaag</span>
@@ -270,8 +279,8 @@ export function QualityResultsPanel({
</div>
<div>
<span>Analyserun</span>
<strong>{selectedQualityCheck.analysis_run_id ?? 'n/a'}</strong>
<p>Job: {selectedQualityCheck.job_id ?? 'n/a'}</p>
<strong>{selectedQualityCheck.analysis_run_id ?? 'n.v.t.'}</strong>
<p>Taak: {selectedQualityCheck.job_id ?? 'n.v.t.'}</p>
</div>
<div>
<span>Status</span>
@@ -281,7 +290,7 @@ export function QualityResultsPanel({
<div>
<span>Afgerond</span>
<strong>{formatQualityTimestamp(selectedQualityCheck.completed_at)}</strong>
<p>Created: {formatQualityTimestamp(selectedQualityCheck.created_at)}</p>
<p>Gemaakt: {formatQualityTimestamp(selectedQualityCheck.created_at)}</p>
</div>
</div>
<div className="quality-evidence-token-grid">
+27 -2
View File
@@ -1,6 +1,12 @@
import { useState } from 'react'
import { exportsApi } from '../services/api'
import type { DatasetCreateResponse, ExportCreateResponse, ExportRead, VectorSelectionBBox } from '../types'
import type {
DatasetCreateResponse,
ExportCreateResponse,
ExportRead,
MapResultExportRequest,
VectorSelectionBBox,
} from '../types'
import { formatError } from '../lib/formatError'
interface ExportWorkflowOptions {
@@ -109,7 +115,7 @@ export function useExportWorkflow({
}
}
const exportMapSelectionGeoJson = async (bbox: VectorSelectionBBox) => {
const exportMapSelectionGeoJson = async (bbox: VectorSelectionBBox, areaId?: string) => {
if (!selectedDataset || !isVectorDatasetType(selectedDataset.dataset_type)) {
setSelectionExportError('Select a vector dataset before saving an area export.')
return null
@@ -119,6 +125,7 @@ export function useExportWorkflow({
try {
const response = await exportsApi.exportGeojson({
dataset_id: selectedDataset.id,
area_id: areaId,
export_kind: 'vector_selection',
bbox: { ...bbox, crs: 'EPSG:4326' },
limit: 250,
@@ -172,6 +179,23 @@ export function useExportWorkflow({
}
}
const persistMapResult = async (payload: MapResultExportRequest) => {
setSelectionExporting(true)
setSelectionExportError(null)
try {
const response = await exportsApi.exportMapResult(payload)
setLatestExport(response)
setLatestSelectionExport(response)
await loadExports(payload.project_id)
return response
} catch (error) {
setSelectionExportError(formatError(error, 'Het kaartresultaat kon niet worden bewaard.'))
return null
} finally {
setSelectionExporting(false)
}
}
const previewExportContent = async (exportId: string) => {
setExportError(null)
try {
@@ -209,6 +233,7 @@ export function useExportWorkflow({
exportSelectedDetectionRunGeoJson,
exportSelectedSegmentationRunGeoJson,
exportMapSelectionGeoJson,
persistMapResult,
exportProjectMetadata,
exportProjectReport,
previewExportContent,
@@ -97,14 +97,27 @@ export function useMapThemeSelectionInsights<TThemeId extends string>(
})),
)
const successful = settled.flatMap((item) => (item.status === 'fulfilled' ? [item.value] : []))
const failureCount = settled.length - successful.length
const failures = settled.flatMap((item, index) => (
item.status === 'rejected'
? [{
dataset: queries[index]?.dataset.name ?? queries[index]?.themeId ?? 'Onbekende bron',
reason: formatError(item.reason, 'Bron kon niet worden bevraagd.'),
}]
: []
))
const failureCount = failures.length
if (requestSequence.current !== sequence) {
return []
}
setThemeInsights(successful)
if (failureCount > 0) {
const details = failures
.slice(0, 4)
.map((failure) => `${failure.dataset}: ${failure.reason}`)
.join(' · ')
const remainder = failureCount > 4 ? ` · en ${failureCount - 4} andere` : ''
setThemeInsightsError(
`${failureCount} beschikbare databron${failureCount === 1 ? '' : 'nen'} kon niet worden bevraagd.`,
`${failureCount} beschikbare databron${failureCount === 1 ? '' : 'nen'} kon niet worden bevraagd. ${details}${remainder}`,
)
}
return successful
+11 -3
View File
@@ -144,9 +144,17 @@ export function useProjectWorkspace() {
setLoadingProjects(true)
setErrorMessage(null)
try {
const response = await projectsApi.list()
setProjects(response.items)
const nextProjectId = await pickInitialProjectId(response.items, preferredProjectId)
const [response, canonicalResponse] = await Promise.all([
projectsApi.list(),
projectsApi.list({ name: REGIONAL_WORKSPACE_PROJECT_NAME, limit: 1 }),
])
const items = Array.from(
new Map(
[...canonicalResponse.items, ...response.items].map((project) => [project.id, project]),
).values(),
)
setProjects(items)
const nextProjectId = await pickInitialProjectId(items, preferredProjectId)
if (nextProjectId && nextProjectId !== selectedProjectId) {
setSelectedProjectId(nextProjectId)
}
+4
View File
@@ -4,6 +4,7 @@ import type {
ExportCreateResponse,
ExportKind,
ExportListResponse,
MapResultExportRequest,
ExportRead,
VectorSelectionBBox,
} from '../../types'
@@ -14,6 +15,7 @@ export const exportsApi = {
| {
dataset_id?: string
analysis_run_id?: string
area_id?: string
export_kind?: ExportKind
name?: string
bbox?: VectorSelectionBBox
@@ -28,6 +30,8 @@ export const exportsApi = {
apiPost<ExportCreateResponse>(`/api/v1/exports/metadata`, { project_id: projectId, name }),
exportProjectReport: (projectId: string, name?: string): Promise<ExportCreateResponse> =>
apiPost<ExportCreateResponse>(`/api/v1/exports/report`, { project_id: projectId, name }),
exportMapResult: (payload: MapResultExportRequest): Promise<ExportCreateResponse> =>
apiPost<ExportCreateResponse>(`/api/v1/exports/map-result`, payload),
listProjectExports: (projectId: string): Promise<ExportListResponse> =>
apiGet<ExportListResponse>(`/api/v1/exports/projects/${projectId}/exports`),
getExport: (exportId: string): Promise<ExportRead> => apiGet<ExportRead>(`/api/v1/exports/${exportId}`),
+7 -1
View File
@@ -2,7 +2,13 @@ import { apiDelete, apiGet, apiPatch, apiPost } from './client'
import type { ProjectCreate, ProjectListResponse, ProjectRead } from '../../types'
export const projectsApi = {
list: (): Promise<ProjectListResponse> => apiGet<ProjectListResponse>('/api/v1/projects'),
list: (options?: { name?: string; limit?: number }): Promise<ProjectListResponse> => {
const search = new URLSearchParams()
if (options?.name) search.set('name', options.name)
if (options?.limit) search.set('limit', String(options.limit))
const query = search.toString()
return apiGet<ProjectListResponse>(`/api/v1/projects${query ? `?${query}` : ''}`)
},
create: (payload: ProjectCreate): Promise<ProjectRead> => apiPost<ProjectRead>('/api/v1/projects', payload),
get: (id: string): Promise<ProjectRead> => apiGet<ProjectRead>(`/api/v1/projects/${id}`),
update: (id: string, payload: Partial<ProjectCreate>): Promise<ProjectRead> =>
+18 -3
View File
@@ -5599,8 +5599,9 @@ section {
grid-template-columns: minmax(15.5rem, 17rem) minmax(30rem, 1fr) minmax(18rem, 20rem);
gap: 0.72rem;
align-items: stretch;
height: clamp(34rem, calc(100dvh - 10rem), 58rem);
min-width: 0;
min-height: calc(100dvh - 13.8rem);
min-height: 34rem;
}
.geo-theme-panel,
@@ -5618,6 +5619,7 @@ section {
display: flex;
flex-direction: column;
gap: 0.7rem;
overflow: hidden;
padding: 0.78rem;
}
@@ -5659,6 +5661,13 @@ section {
.geo-theme-list {
display: grid;
gap: 0.36rem;
min-height: 0;
overflow-y: auto;
padding-right: 0.18rem;
}
.geo-results-panel {
overflow-y: auto;
}
.geo-project-scope-select {
@@ -6590,7 +6599,8 @@ section {
@media (min-width: 1800px) {
.geo-explorer-layout {
grid-template-columns: minmax(17rem, 19rem) minmax(38rem, 1fr) minmax(20rem, 23rem);
min-height: calc(100dvh - 14.2rem);
height: clamp(38rem, calc(100dvh - 11rem), 64rem);
min-height: 38rem;
}
.geo-map-canvas .map-container {
@@ -6598,9 +6608,11 @@ section {
}
}
@media (max-width: 1320px) {
@media (max-width: 1240px) {
.geo-explorer-layout {
grid-template-columns: minmax(14rem, 16rem) minmax(28rem, 1fr);
height: auto;
min-height: 0;
}
.geo-results-panel {
@@ -6644,6 +6656,8 @@ section {
.geo-theme-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
overflow: visible;
padding-right: 0;
}
.geo-map-toolbar {
@@ -6657,6 +6671,7 @@ section {
.geo-results-panel {
display: flex;
overflow: visible;
}
}
+27
View File
@@ -696,6 +696,11 @@ details.ai-lab-model-surface > summary strong {
align-items: start;
}
.workspace-grid-ai {
grid-template-columns: minmax(0, 1fr);
align-items: start;
}
.workspace-grid-exports {
grid-template-columns: minmax(28rem, 1.2fr) minmax(22rem, 0.8fr);
align-items: start;
@@ -708,6 +713,11 @@ details.ai-lab-model-surface > summary strong {
overflow: auto;
align-self: start;
}
.workspace-grid-ai > section:nth-child(n) {
max-height: none;
overflow: visible;
}
}
/* Overview */
@@ -2007,6 +2017,23 @@ details.ai-lab-model-surface > summary strong {
margin-top: 0.75rem;
}
.quality-summary-grid small,
.ai-quality-guidance {
display: block;
margin-top: 0.22rem;
color: var(--muted);
font-size: 0.72rem;
line-height: 1.35;
}
.ai-quality-guidance {
margin: -0.25rem 0 0.65rem;
border-left: 3px solid #b37a2d;
padding: 0.45rem 0.65rem;
background: #fffbeb;
color: #6f4b18;
}
.quality-advanced-disclosure > summary strong {
margin-right: 0.25rem;
color: var(--ink);
+14
View File
@@ -1392,6 +1392,20 @@ export interface ExportCreateResponse {
metadata_json?: Record<string, unknown> | null
}
export interface MapResultExportRequest {
project_id: string
mode: 'current' | 'evolution'
bbox: VectorSelectionBBox
dataset_id?: string
earlier_dataset_id?: string
later_dataset_id?: string
area_id?: string
partitioned?: boolean
product_key?: string
theme_id?: string
name?: string
}
export interface ExportListResponse {
items: ExportRead[]
total: number