diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c29f75d..25d77b55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, diff --git a/backend/README.md b/backend/README.md index 43d09232..9130d45f 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 diff --git a/backend/app/api/routes/exports.py b/backend/app/api/routes/exports.py index 064f232e..537410da 100644 --- a/backend/app/api/routes/exports.py +++ b/backend/app/api/routes/exports.py @@ -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, diff --git a/backend/app/api/routes/projects.py b/backend/app/api/routes/projects.py index b61c3bb6..e76e508f 100644 --- a/backend/app/api/routes/projects.py +++ b/backend/app/api/routes/projects.py @@ -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}) diff --git a/backend/app/schemas/export.py b/backend/app/schemas/export.py index 13da4bb7..8788bdef 100644 --- a/backend/app/schemas/export.py +++ b/backend/app/schemas/export.py @@ -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 diff --git a/backend/app/services/export_service.py b/backend/app/services/export_service.py index bf2742fb..12bb4235 100644 --- a/backend/app/services/export_service.py +++ b/backend/app/services/export_service.py @@ -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"], diff --git a/backend/app/services/project_service.py b/backend/app/services/project_service.py index 177641f3..4090da47 100644 --- a/backend/app/services/project_service.py +++ b/backend/app/services/project_service.py @@ -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 diff --git a/backend/tests/test_sprint107_map_selection_export.py b/backend/tests/test_sprint107_map_selection_export.py index f7665e96..e8db16fa 100644 --- a/backend/tests/test_sprint107_map_selection_export.py +++ b/backend/tests/test_sprint107_map_selection_export.py @@ -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 diff --git a/backend/tests/test_sprint186_map_first_geographic_explorer.py b/backend/tests/test_sprint186_map_first_geographic_explorer.py index 35ebfa62..e36bfd75 100644 --- a/backend/tests/test_sprint186_map_first_geographic_explorer.py +++ b/backend/tests/test_sprint186_map_first_geographic_explorer.py @@ -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 diff --git a/backend/tests/test_sprint219_regional_raster_explorer.py b/backend/tests/test_sprint219_regional_raster_explorer.py index 45479331..896e523c 100644 --- a/backend/tests/test_sprint219_regional_raster_explorer.py +++ b/backend/tests/test_sprint219_regional_raster_explorer.py @@ -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: diff --git a/backend/tests/test_sprint232_v1_completion_flow.py b/backend/tests/test_sprint232_v1_completion_flow.py index 93fbc8b4..dd7b1b7f 100644 --- a/backend/tests/test_sprint232_v1_completion_flow.py +++ b/backend/tests/test_sprint232_v1_completion_flow.py @@ -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 diff --git a/backend/tests/test_sprint233_operational_completion.py b/backend/tests/test_sprint233_operational_completion.py new file mode 100644 index 00000000..60433200 --- /dev/null +++ b/backend/tests/test_sprint233_operational_completion.py @@ -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(null)" in app + assert "workbenchMainRef.current?.scrollTo({ top: 0, left: 0 })" in app + assert "
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 diff --git a/backend/tests/test_sprint39_frontend_orchestration_hooks.py b/backend/tests/test_sprint39_frontend_orchestration_hooks.py index 98caf0ca..b80dda9f 100644 --- a/backend/tests/test_sprint39_frontend_orchestration_hooks.py +++ b/backend/tests/test_sprint39_frontend_orchestration_hooks.py @@ -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('map')" in app diff --git a/backend/tests/test_sprint51_quality_export_polish.py b/backend/tests/test_sprint51_quality_export_polish.py index 145312d7..77001efb 100644 --- a/backend/tests/test_sprint51_quality_export_polish.py +++ b/backend/tests/test_sprint51_quality_export_polish.py @@ -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 diff --git a/backend/tests/test_sprint53_selection_ergonomics.py b/backend/tests/test_sprint53_selection_ergonomics.py index 4322c5cc..60cecdf1 100644 --- a/backend/tests/test_sprint53_selection_ergonomics.py +++ b/backend/tests/test_sprint53_selection_ergonomics.py @@ -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 diff --git a/backend/tests/test_sprint94_quality_drilldown.py b/backend/tests/test_sprint94_quality_drilldown.py index 0fcb6b2c..d4a435d7 100644 --- a/backend/tests/test_sprint94_quality_drilldown.py +++ b/backend/tests/test_sprint94_quality_drilldown.py @@ -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 diff --git a/docs/API_CONTRACTS.md b/docs/API_CONTRACTS.md index 00c8f0eb..899f0482 100644 --- a/docs/API_CONTRACTS.md +++ b/docs/API_CONTRACTS.md @@ -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 diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index fd771053..85242e28 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -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. diff --git a/docs/TODO.md b/docs/TODO.md index b5b19b3e..19f49bb0 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -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 diff --git a/frontend/README.md b/frontend/README.md index d41a48f6..85bd18fb 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c6464e05..d36498fb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(null) + const workbenchMainRef = useRef(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 { -
+
{activeWorkspace !== 'map' ?

{selectedProject?.region ?? 'Mol, Kempen'}

@@ -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'} ) : null}
@@ -1072,6 +1074,7 @@ function App(): JSX.Element { onRunMapSelectionExtract={runMapSelectionExtract} onClearMapSelectionExtract={resetMapSelectionExtract} onExportMapSelection={exportMapSelectionGeoJson} + onPersistMapResult={persistMapResult} onDeriveMapSelectionDataset={deriveMapSelectionDataset} onSelectMapQaReferenceDataset={setSelectedMapQaReferenceDatasetId} onRunMapSelectionQa={runMapSelectionQa} diff --git a/frontend/src/components/detection/DetectionLab.tsx b/frontend/src/components/detection/DetectionLab.tsx index e37d0cdf..5212b1cc 100644 --- a/frontend/src/components/detection/DetectionLab.tsx +++ b/frontend/src/components/detection/DetectionLab.tsx @@ -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({

{rasterDatasets.length > 0 ? 'Klaar om een beeld te kiezen.' : 'Laad eerst een gegeorefereerd luchtbeeld in.'}

+

+ {detectionQualityInterpretation(selectedOperatorProfile?.f1)} +

diff --git a/frontend/src/components/exports/ExportCenter.tsx b/frontend/src/components/exports/ExportCenter.tsx index 2877268f..90b0f78e 100644 --- a/frontend/src/components/exports/ExportCenter.tsx +++ b/frontend/src/components/exports/ExportCenter.tsx @@ -44,6 +44,19 @@ function compactPath(value: string): string { } function formatExportType(value: string): string { + const labels: Record = { + 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({
-

Handoff readiness

-

Pick the artifact that matches the current review state before sharing data outside the workbench.

+

Klaar om te delen

+

Controleer het juiste bestand voordat je gegevens buiten GeoIntel gebruikt.

{handoffStatus}
@@ -260,9 +292,9 @@ export function ExportCenter({
- Refresh - Export registry -

Reload the persisted artifact list for the selected project.

+ Vernieuwen + Bewaarde bestanden +

Laad de persistente downloads van de geselecteerde werkruimte opnieuw.

@@ -304,19 +336,19 @@ export function ExportCenter({
{selectedDetectionRunId ?
- Detection run - Detections GeoJSON -

Export persisted detection geometries for the selected analysis run.

+ Gebouwanalyse + Herkende gebouwen (GeoJSON) +

Bewaar de persistente detectiegeometrie van de geselecteerde analyserun.

: null} {selectedSegmentationRunId ?
- Segmentation run - Segmentations GeoJSON -

Export persisted segmentation polygons for the selected analysis run.

+ Segmentatieanalyse + Segmentaties (GeoJSON) +

Bewaar de persistente polygonen van de geselecteerde segmentatierun.

: null}
@@ -358,16 +390,16 @@ export function ExportCenter({
-

Export history

-

Review persisted artifacts and open JSON previews where supported.

+

Bewaarde downloads

+

Bekijk persistente bestanden en open beschikbare JSON-voorbeelden.

{exports.length > 0 ? (
) : null} {filteredExports.length > 10 ? (
- 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.'}
) : null} {exports.length > 0 && filteredExports.length === 0 ? (
- No exports match the current filters. -

Clear the search, type or status filter to return to the latest artifacts.

+ Geen downloads passen bij deze filters. +

Wis de zoekopdracht of filters om de recentste bestanden opnieuw te tonen.

) : null}
    @@ -433,9 +465,9 @@ export function ExportCenter({
    {formatExportType(item.export_type)}
    - export id: {item.id} - {item.analysis_run_id ? analysis run: {item.analysis_run_id} : null} - {item.created_at ? created: {item.created_at} : null} + download-id: {item.id} + {item.analysis_run_id ? analyserun: {item.analysis_run_id} : null} + {item.created_at ? gemaakt: {formatExportCreated(item.created_at)} : null}
    {item.status} @@ -444,13 +476,13 @@ export function ExportCenter({
    {canPreviewJson(item) ? ( ) : ( - download-only HTML artifact + HTML alleen downloaden )}
    diff --git a/frontend/src/components/map/MapWorkspace.tsx b/frontend/src/components/map/MapWorkspace.tsx index 5cbe01d1..0cdf7335 100644 --- a/frontend/src/components/map/MapWorkspace.tsx +++ b/frontend/src/components/map/MapWorkspace.tsx @@ -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 onClearMapSelectionExtract: () => void - onExportMapSelection: (bbox: VectorSelectionBBox) => Promise + onExportMapSelection: (bbox: VectorSelectionBBox, areaId?: string) => Promise + onPersistMapResult: (payload: MapResultExportRequest) => Promise onDeriveMapSelectionDataset: (bbox: VectorSelectionBBox, areaId?: string) => Promise onSelectMapQaReferenceDataset: (datasetId: string) => void onRunMapSelectionQa: (candidateDataset?: DatasetCreateResponse | null) => Promise @@ -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({ Stel een vraag over dit gebied of open je bewaarde resultaten. - +
) : null} diff --git a/frontend/src/components/quality/QualityResultsPanel.tsx b/frontend/src/components/quality/QualityResultsPanel.tsx index e91dab78..23d7ca20 100644 --- a/frontend/src/components/quality/QualityResultsPanel.tsx +++ b/frontend/src/components/quality/QualityResultsPanel.tsx @@ -25,19 +25,19 @@ interface QualityResultsPanelProps { function qualityMetricLabel(metricKey: string): string { const labels: Record = { - 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 { } function formatQualityTimestamp(value?: string | null): string { - return value || 'n/a' + return value || 'n.v.t.' } function qualityMatchesSearch(check: QualityCheckRead, query: string, datasetNameById: Map): boolean { @@ -176,8 +184,9 @@ export function QualityResultsPanel({ {completedChecks}
- Laatste score + Laatste score (0-1) {qualityScoreValue(latestCheck?.score)} + {qualityScoreInterpretation(latestCheck?.score)}
Laatste status @@ -261,7 +270,7 @@ export function QualityResultsPanel({
Te controleren laag {selectedCandidateName} -

{selectedQualityCheck.candidate_dataset_id ?? 'candidate not stored'}

+

{selectedQualityCheck.candidate_dataset_id ?? 'niet bewaard'}

Referentielaag @@ -270,8 +279,8 @@ export function QualityResultsPanel({
Analyserun - {selectedQualityCheck.analysis_run_id ?? 'n/a'} -

Job: {selectedQualityCheck.job_id ?? 'n/a'}

+ {selectedQualityCheck.analysis_run_id ?? 'n.v.t.'} +

Taak: {selectedQualityCheck.job_id ?? 'n.v.t.'}

Status @@ -281,7 +290,7 @@ export function QualityResultsPanel({
Afgerond {formatQualityTimestamp(selectedQualityCheck.completed_at)} -

Created: {formatQualityTimestamp(selectedQualityCheck.created_at)}

+

Gemaakt: {formatQualityTimestamp(selectedQualityCheck.created_at)}

diff --git a/frontend/src/hooks/useExportWorkflow.ts b/frontend/src/hooks/useExportWorkflow.ts index 135617df..f1c91322 100644 --- a/frontend/src/hooks/useExportWorkflow.ts +++ b/frontend/src/hooks/useExportWorkflow.ts @@ -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, diff --git a/frontend/src/hooks/useMapThemeSelectionInsights.ts b/frontend/src/hooks/useMapThemeSelectionInsights.ts index 607488e7..1215d987 100644 --- a/frontend/src/hooks/useMapThemeSelectionInsights.ts +++ b/frontend/src/hooks/useMapThemeSelectionInsights.ts @@ -97,14 +97,27 @@ export function useMapThemeSelectionInsights( })), ) 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 diff --git a/frontend/src/hooks/useProjectWorkspace.ts b/frontend/src/hooks/useProjectWorkspace.ts index a6e224a7..d4a7a4e6 100644 --- a/frontend/src/hooks/useProjectWorkspace.ts +++ b/frontend/src/hooks/useProjectWorkspace.ts @@ -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) } diff --git a/frontend/src/services/api/exports.ts b/frontend/src/services/api/exports.ts index acc9ca01..6df73fb1 100644 --- a/frontend/src/services/api/exports.ts +++ b/frontend/src/services/api/exports.ts @@ -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(`/api/v1/exports/metadata`, { project_id: projectId, name }), exportProjectReport: (projectId: string, name?: string): Promise => apiPost(`/api/v1/exports/report`, { project_id: projectId, name }), + exportMapResult: (payload: MapResultExportRequest): Promise => + apiPost(`/api/v1/exports/map-result`, payload), listProjectExports: (projectId: string): Promise => apiGet(`/api/v1/exports/projects/${projectId}/exports`), getExport: (exportId: string): Promise => apiGet(`/api/v1/exports/${exportId}`), diff --git a/frontend/src/services/api/projects.ts b/frontend/src/services/api/projects.ts index 4a7fae1c..41966c01 100644 --- a/frontend/src/services/api/projects.ts +++ b/frontend/src/services/api/projects.ts @@ -2,7 +2,13 @@ import { apiDelete, apiGet, apiPatch, apiPost } from './client' import type { ProjectCreate, ProjectListResponse, ProjectRead } from '../../types' export const projectsApi = { - list: (): Promise => apiGet('/api/v1/projects'), + list: (options?: { name?: string; limit?: number }): Promise => { + 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(`/api/v1/projects${query ? `?${query}` : ''}`) + }, create: (payload: ProjectCreate): Promise => apiPost('/api/v1/projects', payload), get: (id: string): Promise => apiGet(`/api/v1/projects/${id}`), update: (id: string, payload: Partial): Promise => diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index dabe8460..5a6bfadb 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -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; } } diff --git a/frontend/src/styles/premium.css b/frontend/src/styles/premium.css index be58f4af..3c8da1bc 100644 --- a/frontend/src/styles/premium.css +++ b/frontend/src/styles/premium.css @@ -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); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index d51a5283..0cab789c 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1392,6 +1392,20 @@ export interface ExportCreateResponse { metadata_json?: Record | 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