diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000..4a91067c --- /dev/null +++ b/.mcp.json @@ -0,0 +1,21 @@ +{ + "mcpServers": { + "higgsfield": { + "command": "npx", + "args": ["-y", "higgsfield-mcp"], + "env": { + "HF_API_KEY": "${HF_API_KEY}", + "HF_SECRET": "${HF_SECRET}" + } + }, + "aistudio": { + "command": "npx", + "args": ["-y", "aistudio-mcp-server"], + "env": { + "GEMINI_API_KEY": "${GEMINI_API_KEY}", + "GEMINI_MODEL": "gemini-2.5-flash", + "GEMINI_TIMEOUT": "600000" + } + } + } +} diff --git a/backend/README.md b/backend/README.md index aa42fc0b..c1fc9ce1 100644 --- a/backend/README.md +++ b/backend/README.md @@ -142,6 +142,7 @@ bash scripts/live_migration_smoke.sh - `GET /api/v1/detection/models` - `GET /api/v1/detection/model-assets` - `POST /api/v1/detection/run` + - `POST /api/v1/detection/run-async` (production browser path) - `GET /api/v1/detection/runs/{analysis_run_id}` - `GET /api/v1/detection/runs/{analysis_run_id}/detections` - YOLO/PyTorch real inference is not enabled in Sprint 8. @@ -184,6 +185,7 @@ bash scripts/live_migration_smoke.sh - Added segmentation endpoints: - `GET /api/v1/segmentation/models` - `POST /api/v1/segmentation/run` + - `POST /api/v1/segmentation/run-async` (production browser path) - `GET /api/v1/segmentation/runs` - `GET /api/v1/segmentation/runs/{analysis_run_id}` - `GET /api/v1/segmentation/runs/{analysis_run_id}/segmentations` @@ -191,6 +193,11 @@ bash scripts/live_migration_smoke.sh - `POST /api/v1/segmentation/runs/{analysis_run_id}/qa/reference` - Real SAM and YOLO-seg inference are not enabled in Sprint 9. - Mask paths are provenance/debug artifacts; persisted PostGIS geometry is authoritative for QA, map display and GeoJSON. +- Current configured detection and segmentation run through the async analysis + worker (`GEOINTEL_ANALYSIS_WORKER_ENABLED`) and are followed through + `GET /api/v1/projects/{project_id}/jobs/{job_id}`. The Unraid profile sets + `YOLO_REQUIRE_CUDA=true`, so both pipelines fail closed instead of silently + falling back from NVIDIA CUDA to CPU. ## Sprint 17 additions - Added export foundation backed by the existing `exports` table. diff --git a/backend/app/api/guest_scope.py b/backend/app/api/guest_scope.py new file mode 100644 index 00000000..1baada3a --- /dev/null +++ b/backend/app/api/guest_scope.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from uuid import UUID + +from fastapi import Request + +from app.core.errors import AppError + + +def guest_project_scope(request: Request) -> UUID | None: + principal = getattr(request.state, "auth_principal", None) + if getattr(principal, "role", None) != "guest": + return None + project_id = getattr(principal, "project_id", None) + if isinstance(project_id, UUID): + return project_id + raise AppError( + code="GUEST_PROJECT_SCOPE_REQUIRED", + message="Deze gastensessie heeft alleen toegang tot de GeoIntel-demowerkruimte.", + status_code=403, + ) + + +def assert_guest_project_scope(request: Request, project_id: UUID) -> None: + guest_project_id = guest_project_scope(request) + if guest_project_id is not None and project_id != guest_project_id: + raise AppError( + code="GUEST_PROJECT_SCOPE_REQUIRED", + message="Deze gastensessie heeft alleen toegang tot de GeoIntel-demowerkruimte.", + status_code=403, + ) + + +def guest_scoped_project_filter( + request: Request, + requested_project_id: UUID | None, +) -> UUID | None: + guest_project_id = guest_project_scope(request) + if guest_project_id is None: + return requested_project_id + if requested_project_id is not None: + assert_guest_project_scope(request, requested_project_id) + return guest_project_id diff --git a/backend/app/api/routes/detection.py b/backend/app/api/routes/detection.py index c61e0915..5ea713d8 100644 --- a/backend/app/api/routes/detection.py +++ b/backend/app/api/routes/detection.py @@ -2,9 +2,14 @@ from __future__ import annotations from uuid import UUID -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, Query, Request from sqlalchemy.orm import Session +from app.api.guest_scope import ( + assert_guest_project_scope, + guest_project_scope, + guest_scoped_project_filter, +) from app.db.session import get_db from app.schemas import ( AnalysisQaResponse, @@ -25,6 +30,7 @@ from app.schemas import ( YoloPreflightResponse, ) from app.services.detection_comparison_service import DetectionComparisonService +from app.services.dataset_service import DatasetService from app.services.detection_service import DetectionService from app.services.model_asset_catalog_service import ModelAssetCatalogService from app.services.model_registry_service import ModelRegistryService @@ -60,7 +66,12 @@ def get_yolo_preflight( @router.post("/run", response_model=Envelope[DetectionRunResponse]) -def run_detection(payload: DetectionRunRequest, db: Session = Depends(get_db)) -> dict: +def run_detection( + payload: DetectionRunRequest, + request: Request, + db: Session = Depends(get_db), +) -> dict: + assert_guest_project_scope(request, payload.project_id) result = DetectionService.run_detection( db=db, project_id=payload.project_id, @@ -76,7 +87,11 @@ def run_detection(payload: DetectionRunRequest, db: Session = Depends(get_db)) - @router.post("/run-async", response_model=Envelope[JobRead]) -def queue_detection(payload: DetectionRunRequest, db: Session = Depends(get_db)) -> dict: +def queue_detection( + payload: DetectionRunRequest, + request: Request, + db: Session = Depends(get_db), +) -> dict: """Queue a detection run for the background worker. Tiled GPU inference takes minutes; ``POST /detection/run`` performs it @@ -84,6 +99,7 @@ def queue_detection(payload: DetectionRunRequest, db: Session = Depends(get_db)) ``GET /jobs/{id}`` for the queued run instead. """ + assert_guest_project_scope(request, payload.project_id) job = DetectionService.enqueue_detection( db=db, project_id=payload.project_id, @@ -100,12 +116,14 @@ def queue_detection(payload: DetectionRunRequest, db: Session = Depends(get_db)) @router.get("/runs", response_model=Envelope[DetectionRunListResponse]) def list_detection_runs( + request: Request, project_id: UUID | None = None, dataset_id: UUID | None = None, limit: int = Query(default=DetectionService.DEFAULT_RUN_LIST_LIMIT, ge=0, le=5_000), offset: int = Query(default=0, ge=0), db: Session = Depends(get_db), ) -> dict: + project_id = guest_scoped_project_filter(request, project_id) return envelope( DetectionService.list_runs( db, project_id=project_id, dataset_id=dataset_id, limit=limit, offset=offset @@ -114,8 +132,14 @@ def list_detection_runs( @router.get("/runs/{analysis_run_id}", response_model=Envelope[DetectionRunRead]) -def get_detection_run(analysis_run_id: UUID, db: Session = Depends(get_db)) -> dict: - return envelope(DetectionService.get_run(db, analysis_run_id).model_dump()) +def get_detection_run( + analysis_run_id: UUID, + request: Request, + db: Session = Depends(get_db), +) -> dict: + run = DetectionService.get_run(db, analysis_run_id) + assert_guest_project_scope(request, run.project_id) + return envelope(run.model_dump()) @router.get( @@ -124,6 +148,7 @@ def get_detection_run(analysis_run_id: UUID, db: Session = Depends(get_db)) -> d ) def list_detection_run_detections( analysis_run_id: UUID, + request: Request, dataset_id: UUID | None = None, class_name: str | None = None, min_confidence: float | None = None, @@ -136,6 +161,9 @@ def list_detection_run_detections( offset: int = Query(default=0, ge=0), db: Session = Depends(get_db), ) -> dict: + if guest_project_scope(request) is not None: + run = DetectionService.get_run(db, analysis_run_id) + assert_guest_project_scope(request, run.project_id) return envelope( DetectionService.list_detections( db, @@ -155,6 +183,7 @@ def list_detection_run_detections( ) def list_dataset_detections( dataset_id: UUID, + request: Request, analysis_run_id: UUID | None = None, class_name: str | None = None, min_confidence: float | None = None, @@ -167,6 +196,9 @@ def list_dataset_detections( offset: int = Query(default=0, ge=0), db: Session = Depends(get_db), ) -> dict: + if guest_project_scope(request) is not None: + dataset = DatasetService.get_dataset(db, dataset_id) + assert_guest_project_scope(request, dataset.project_id) return envelope( DetectionService.list_detections( db, @@ -181,8 +213,14 @@ def list_dataset_detections( @router.get("/detections/{detection_id}", response_model=Envelope[DetectionRead]) -def get_detection(detection_id: UUID, db: Session = Depends(get_db)) -> dict: - return envelope(DetectionService.get_detection(db, detection_id).model_dump()) +def get_detection( + detection_id: UUID, + request: Request, + db: Session = Depends(get_db), +) -> dict: + detection = DetectionService.get_detection(db, detection_id) + assert_guest_project_scope(request, detection.project_id) + return envelope(detection.model_dump()) @router.get( @@ -191,6 +229,7 @@ def get_detection(detection_id: UUID, db: Session = Depends(get_db)) -> dict: ) def get_detection_run_geojson( analysis_run_id: UUID, + request: Request, class_name: str | None = None, min_confidence: float | None = None, limit: int = Query( @@ -201,6 +240,9 @@ def get_detection_run_geojson( ), db: Session = Depends(get_db), ) -> dict: + if guest_project_scope(request) is not None: + run = DetectionService.get_run(db, analysis_run_id) + assert_guest_project_scope(request, run.project_id) return envelope( DetectionService.detections_to_geojson( db, @@ -218,6 +260,7 @@ def get_detection_run_geojson( ) def get_dataset_detection_geojson( dataset_id: UUID, + request: Request, analysis_run_id: UUID | None = None, class_name: str | None = None, min_confidence: float | None = None, @@ -229,6 +272,9 @@ def get_dataset_detection_geojson( ), db: Session = Depends(get_db), ) -> dict: + if guest_project_scope(request) is not None: + dataset = DatasetService.get_dataset(db, dataset_id) + assert_guest_project_scope(request, dataset.project_id) return envelope( DetectionService.detections_to_geojson( db, @@ -269,8 +315,12 @@ def compare_detection_runs(payload: DetectionComparisonRequest, db: Session = De def compare_detection_run_with_reference( analysis_run_id: UUID, payload: DetectionQaRequest, + request: Request, db: Session = Depends(get_db), ) -> dict: + if guest_project_scope(request) is not None: + run = DetectionService.get_run(db, analysis_run_id) + assert_guest_project_scope(request, run.project_id) return envelope( DetectionService.compare_detections_with_reference( db=db, diff --git a/backend/app/api/routes/exports.py b/backend/app/api/routes/exports.py index 399122c8..bab2be34 100644 --- a/backend/app/api/routes/exports.py +++ b/backend/app/api/routes/exports.py @@ -2,10 +2,11 @@ from __future__ import annotations from uuid import UUID -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, Query, Request from fastapi.responses import FileResponse from sqlalchemy.orm import Session +from app.api.guest_scope import assert_guest_project_scope, guest_project_scope from app.core.errors import AppError from app.db.session import get_db from app.schemas import Envelope @@ -20,13 +21,30 @@ from app.schemas.export import ( ReportExportRequest, ) from app.services.export_service import ExportService +from app.services.dataset_service import DatasetService +from app.services.detection_service import DetectionService +from app.services.segmentation_service import SegmentationService from app.utils.response import envelope router = APIRouter(prefix="/exports", tags=["exports"]) @router.post("/geojson", response_model=Envelope[ExportCreateResponse]) -def export_geojson(payload: GeoJsonExportRequest, db: Session = Depends(get_db)): +def export_geojson( + payload: GeoJsonExportRequest, + request: Request, + db: Session = Depends(get_db), +): + if guest_project_scope(request) is not None: + if payload.export_kind in {"dataset", "vector_selection"} and payload.dataset_id is not None: + dataset = DatasetService.get_dataset(db, payload.dataset_id) + assert_guest_project_scope(request, dataset.project_id) + elif payload.export_kind == "detection_run" and payload.analysis_run_id is not None: + run = DetectionService.get_run(db, payload.analysis_run_id) + assert_guest_project_scope(request, run.project_id) + elif payload.export_kind == "segmentation_run" and payload.analysis_run_id is not None: + run = SegmentationService.get_run(db, payload.analysis_run_id) + assert_guest_project_scope(request, run.project_id) if payload.export_kind == "vector_selection" and payload.dataset_id is not None and payload.bbox is not None: return envelope( ExportService.export_vector_selection_geojson( @@ -61,17 +79,32 @@ def export_geojson(payload: GeoJsonExportRequest, db: Session = Depends(get_db)) @router.post("/metadata", response_model=Envelope[ExportCreateResponse]) -def export_project_metadata(payload: MetadataExportRequest, db: Session = Depends(get_db)): +def export_project_metadata( + payload: MetadataExportRequest, + request: Request, + db: Session = Depends(get_db), +): + assert_guest_project_scope(request, payload.project_id) return envelope(ExportService.export_project_metadata(db, payload.project_id, payload.name).model_dump(mode="json")) @router.post("/report", response_model=Envelope[ExportCreateResponse]) -def export_project_report(payload: ReportExportRequest, db: Session = Depends(get_db)): +def export_project_report( + payload: ReportExportRequest, + request: Request, + db: Session = Depends(get_db), +): + assert_guest_project_scope(request, payload.project_id) return envelope(ExportService.export_project_report(db, payload.project_id, payload.name).model_dump(mode="json")) @router.post("/map-result", response_model=Envelope[ExportCreateResponse]) -def export_map_result(payload: MapResultExportRequest, db: Session = Depends(get_db)): +def export_map_result( + payload: MapResultExportRequest, + request: Request, + db: Session = Depends(get_db), +): + assert_guest_project_scope(request, payload.project_id) return envelope(ExportService.export_map_result(db, payload).model_dump(mode="json")) @@ -81,25 +114,35 @@ def export_map_result(payload: MapResultExportRequest, db: Session = Depends(get ) def list_project_exports( project_id: UUID, + request: Request, limit: int = Query(default=50, ge=1, le=100), offset: int = Query(default=0, ge=0), db: Session = Depends(get_db), ): + assert_guest_project_scope(request, project_id) return envelope(ExportService.list_project_exports(db, project_id, limit=limit, offset=offset).model_dump(mode="json")) @router.get("/{export_id}", response_model=Envelope[ExportRead]) -def get_export(export_id: UUID, db: Session = Depends(get_db)): - return envelope(ExportService.get_export(db, export_id).model_dump(mode="json")) +def get_export(export_id: UUID, request: Request, db: Session = Depends(get_db)): + export = ExportService.get_export(db, export_id) + assert_guest_project_scope(request, export.project_id) + return envelope(export.model_dump(mode="json")) @router.get("/{export_id}/download") -def download_export(export_id: UUID, db: Session = Depends(get_db)): +def download_export(export_id: UUID, request: Request, db: Session = Depends(get_db)): + if guest_project_scope(request) is not None: + export = ExportService.get_export(db, export_id) + assert_guest_project_scope(request, export.project_id) path = ExportService.get_export_download_path(db, export_id) media_type = "text/html" if path.suffix.lower() in {".html", ".htm"} else "application/json" return FileResponse(path, filename=path.name, media_type=media_type) @router.get("/{export_id}/content", response_model=Envelope[ExportContentResponse]) -def get_export_content(export_id: UUID, db: Session = Depends(get_db)): +def get_export_content(export_id: UUID, request: Request, db: Session = Depends(get_db)): + if guest_project_scope(request) is not None: + export = ExportService.get_export(db, export_id) + assert_guest_project_scope(request, export.project_id) return envelope(ExportService.get_export_content(db, export_id).model_dump(mode="json")) diff --git a/backend/app/api/routes/segmentation.py b/backend/app/api/routes/segmentation.py index aba33bb7..7113bb9d 100644 --- a/backend/app/api/routes/segmentation.py +++ b/backend/app/api/routes/segmentation.py @@ -2,9 +2,14 @@ from __future__ import annotations from uuid import UUID -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, Query, Request from sqlalchemy.orm import Session +from app.api.guest_scope import ( + assert_guest_project_scope, + guest_project_scope, + guest_scoped_project_filter, +) from app.db.session import get_db from app.schemas import ( AnalysisQaResponse, @@ -21,6 +26,7 @@ from app.schemas import ( SegmentationRunResponse, ) from app.services.model_registry_service import ModelRegistryService +from app.services.dataset_service import DatasetService from app.services.detection_service import DetectionService from app.services.segmentation_service import SegmentationService from app.utils.response import envelope @@ -34,7 +40,12 @@ def list_segmentation_models() -> dict: @router.post("/run", response_model=Envelope[SegmentationRunResponse]) -def run_segmentation(payload: SegmentationRunRequest, db: Session = Depends(get_db)) -> dict: +def run_segmentation( + payload: SegmentationRunRequest, + request: Request, + db: Session = Depends(get_db), +) -> dict: + assert_guest_project_scope(request, payload.project_id) result = SegmentationService.run_segmentation( db=db, project_id=payload.project_id, @@ -49,13 +60,18 @@ def run_segmentation(payload: SegmentationRunRequest, db: Session = Depends(get_ @router.post("/run-async", response_model=Envelope[JobRead]) -def queue_segmentation(payload: SegmentationRunRequest, db: Session = Depends(get_db)) -> dict: +def queue_segmentation( + payload: SegmentationRunRequest, + request: Request, + db: Session = Depends(get_db), +) -> dict: """Queue a segmentation run for the background worker. Configured segmentation walks the same tile manifest as detection and is just as unsuited to running inside the request. Poll ``GET /jobs/{id}``. """ + assert_guest_project_scope(request, payload.project_id) job = SegmentationService.enqueue_segmentation( db=db, project_id=payload.project_id, @@ -71,12 +87,14 @@ def queue_segmentation(payload: SegmentationRunRequest, db: Session = Depends(ge @router.get("/runs", response_model=Envelope[SegmentationRunListResponse]) def list_segmentation_runs( + request: Request, project_id: UUID | None = None, dataset_id: UUID | None = None, limit: int = Query(default=DetectionService.DEFAULT_RUN_LIST_LIMIT, ge=0, le=5_000), offset: int = Query(default=0, ge=0), db: Session = Depends(get_db), ) -> dict: + project_id = guest_scoped_project_filter(request, project_id) return envelope( SegmentationService.list_runs( db, project_id=project_id, dataset_id=dataset_id, limit=limit, offset=offset @@ -85,8 +103,14 @@ def list_segmentation_runs( @router.get("/runs/{analysis_run_id}", response_model=Envelope[SegmentationRunRead]) -def get_segmentation_run(analysis_run_id: UUID, db: Session = Depends(get_db)) -> dict: - return envelope(SegmentationService.get_run(db, analysis_run_id).model_dump()) +def get_segmentation_run( + analysis_run_id: UUID, + request: Request, + db: Session = Depends(get_db), +) -> dict: + run = SegmentationService.get_run(db, analysis_run_id) + assert_guest_project_scope(request, run.project_id) + return envelope(run.model_dump()) @router.get( @@ -95,6 +119,7 @@ def get_segmentation_run(analysis_run_id: UUID, db: Session = Depends(get_db)) - ) def list_segmentation_run_outputs( analysis_run_id: UUID, + request: Request, dataset_id: UUID | None = None, class_name: str | None = None, min_confidence: float | None = None, @@ -107,6 +132,9 @@ def list_segmentation_run_outputs( offset: int = Query(default=0, ge=0), db: Session = Depends(get_db), ) -> dict: + if guest_project_scope(request) is not None: + run = SegmentationService.get_run(db, analysis_run_id) + assert_guest_project_scope(request, run.project_id) return envelope( SegmentationService.list_segmentations( db, @@ -126,6 +154,7 @@ def list_segmentation_run_outputs( ) def list_dataset_segmentations( dataset_id: UUID, + request: Request, analysis_run_id: UUID | None = None, class_name: str | None = None, min_confidence: float | None = None, @@ -138,6 +167,9 @@ def list_dataset_segmentations( offset: int = Query(default=0, ge=0), db: Session = Depends(get_db), ) -> dict: + if guest_project_scope(request) is not None: + dataset = DatasetService.get_dataset(db, dataset_id) + assert_guest_project_scope(request, dataset.project_id) return envelope( SegmentationService.list_segmentations( db, @@ -152,8 +184,14 @@ def list_dataset_segmentations( @router.get("/segmentations/{segmentation_id}", response_model=Envelope[SegmentationRead]) -def get_segmentation(segmentation_id: UUID, db: Session = Depends(get_db)) -> dict: - return envelope(SegmentationService.get_segmentation(db, segmentation_id).model_dump()) +def get_segmentation( + segmentation_id: UUID, + request: Request, + db: Session = Depends(get_db), +) -> dict: + segmentation = SegmentationService.get_segmentation(db, segmentation_id) + assert_guest_project_scope(request, segmentation.project_id) + return envelope(segmentation.model_dump()) @router.get( @@ -162,6 +200,7 @@ def get_segmentation(segmentation_id: UUID, db: Session = Depends(get_db)) -> di ) def get_segmentation_run_geojson( analysis_run_id: UUID, + request: Request, class_name: str | None = None, min_confidence: float | None = None, limit: int = Query( @@ -172,6 +211,9 @@ def get_segmentation_run_geojson( ), db: Session = Depends(get_db), ) -> dict: + if guest_project_scope(request) is not None: + run = SegmentationService.get_run(db, analysis_run_id) + assert_guest_project_scope(request, run.project_id) return envelope( SegmentationService.segmentations_to_geojson( db, @@ -189,6 +231,7 @@ def get_segmentation_run_geojson( ) def get_dataset_segmentation_geojson( dataset_id: UUID, + request: Request, analysis_run_id: UUID | None = None, class_name: str | None = None, min_confidence: float | None = None, @@ -200,6 +243,9 @@ def get_dataset_segmentation_geojson( ), db: Session = Depends(get_db), ) -> dict: + if guest_project_scope(request) is not None: + dataset = DatasetService.get_dataset(db, dataset_id) + assert_guest_project_scope(request, dataset.project_id) return envelope( SegmentationService.segmentations_to_geojson( db, @@ -219,8 +265,12 @@ def get_dataset_segmentation_geojson( def compare_segmentation_run_with_reference( analysis_run_id: UUID, payload: SegmentationQaRequest, + request: Request, db: Session = Depends(get_db), ) -> dict: + if guest_project_scope(request) is not None: + run = SegmentationService.get_run(db, analysis_run_id) + assert_guest_project_scope(request, run.project_id) return envelope( SegmentationService.compare_segmentations_with_reference( db=db, diff --git a/backend/app/main.py b/backend/app/main.py index 0ce07d41..f9fcd5f8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -264,7 +264,9 @@ def create_app() -> FastAPI: } guest_scoped_analysis_post_paths = { f"{settings.api_prefix}/detection/run", + f"{settings.api_prefix}/detection/run-async", f"{settings.api_prefix}/segmentation/run", + f"{settings.api_prefix}/segmentation/run-async", f"{settings.api_prefix}/qa/detections-vs-reference", f"{settings.api_prefix}/exports/geojson", f"{settings.api_prefix}/exports/metadata", diff --git a/backend/app/schemas/segmentation.py b/backend/app/schemas/segmentation.py index 42e5f3ce..a90155c7 100644 --- a/backend/app/schemas/segmentation.py +++ b/backend/app/schemas/segmentation.py @@ -102,4 +102,9 @@ class SegmentationRead(BaseModel): class SegmentationListResponse(BaseModel): items: list[SegmentationRead] + # ``total`` describes the complete filtered population; ``items`` is one + # stable confidence-ranked page of it. total: int + limit: int | None = None + offset: int = 0 + truncated: bool = False diff --git a/backend/app/services/outbound_request_guard.py b/backend/app/services/outbound_request_guard.py index b8f0cdf4..ec205965 100644 --- a/backend/app/services/outbound_request_guard.py +++ b/backend/app/services/outbound_request_guard.py @@ -18,7 +18,7 @@ import socket from collections.abc import Callable from typing import Any from urllib.parse import urlparse -from urllib.request import HTTPRedirectHandler, build_opener, urlopen +from urllib.request import HTTPRedirectHandler, build_opener from app.core.errors import AppError @@ -38,12 +38,30 @@ class _RejectRedirects(HTTPRedirectHandler): return None +class _ValidatedRedirects(HTTPRedirectHandler): + """Validate a redirect target before urllib opens the next connection.""" + + def __init__(self, expected_url: str) -> None: + super().__init__() + self.expected_url = expected_url + + def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ANN001, D102 + assert_same_origin_redirect(self.expected_url, newurl) + return super().redirect_request(req, fp, code, msg, headers, newurl) + + def no_redirect_opener(): """An opener that will not follow a redirect anywhere.""" return build_opener(_RejectRedirects()) +def validated_redirect_opener(expected_url: str): + """An opener that validates each redirect before following it.""" + + return build_opener(_ValidatedRedirects(expected_url)) + + def _reject(code: str, message: str, **details: Any) -> AppError: return AppError(code=code, message=message, details=details or None, status_code=502) @@ -88,6 +106,20 @@ def assert_public_http_url(url: str) -> None: host = parsed.hostname if not host: raise _reject("OUTBOUND_URL_NOT_ALLOWED", "Outbound request has no host.", url=url) + if parsed.username is not None or parsed.password is not None: + raise _reject( + "OUTBOUND_URL_NOT_ALLOWED", + "Bounded acquisition refuses credentials embedded in an outbound URL.", + host=host, + ) + try: + parsed.port + except ValueError as error: + raise _reject( + "OUTBOUND_URL_NOT_ALLOWED", + "Outbound request contains an invalid port.", + host=host, + ) from error literal = host.strip("[]") candidates = [literal] if _looks_like_ip(literal) else _resolved_addresses(host) @@ -133,15 +165,28 @@ def assert_same_origin_redirect(original_url: str, final_url: str) -> None: "The official endpoint redirected from HTTPS to an unprotected scheme.", redirect_scheme=final.scheme, ) + # This catches embedded credentials, invalid ports and non-public + # resolutions before the redirect handler can construct the next request. assert_public_http_url(final_url) + original_port = original.port or (443 if original.scheme == "https" else 80) + final_port = final.port or (443 if final.scheme == "https" else 80) + same_scheme_port = final.scheme == original.scheme and final_port == original_port + safe_https_upgrade = original.scheme == "http" and final.scheme == "https" and final_port == 443 + if not (same_scheme_port or safe_https_upgrade): + raise _reject( + "OUTBOUND_REDIRECT_NOT_ALLOWED", + "The official endpoint redirected to a different network origin.", + expected_port=original_port, + redirect_port=final_port, + ) def guarded_opener(expected_url: str, *, allow_redirect: bool = True) -> Callable[..., Any]: - """An ``urlopen`` replacement that verifies where the response came from. + """An ``urlopen`` replacement that keeps redirects on the expected origin. - ``urlopen`` has already followed the redirect chain by the time it returns, - so the check is on ``response.url``: the body is still unread, and raising - here means nothing off-origin is ever parsed or persisted. + Redirect targets are validated by the handler *before* urllib opens the + next connection. The final response URL is checked again as a defensive + invariant for injected/custom transports. ``allow_redirect=False`` refuses any redirect at all, which is what the paged OGC feature readers want: a page URL they built themselves should be @@ -151,7 +196,11 @@ def guarded_opener(expected_url: str, *, allow_redirect: bool = True) -> Callabl assert_public_http_url(expected_url) - default_transport = urlopen if allow_redirect else no_redirect_opener().open + default_transport = ( + validated_redirect_opener(expected_url).open + if allow_redirect + else no_redirect_opener().open + ) def _open(request: Any, *args: Any, _transport: Callable[..., Any] | None = None, **kwargs: Any) -> Any: response = (_transport or default_transport)(request, *args, **kwargs) diff --git a/backend/app/services/segmentation_adapter.py b/backend/app/services/segmentation_adapter.py index 73b4b1bc..5d33510d 100644 --- a/backend/app/services/segmentation_adapter.py +++ b/backend/app/services/segmentation_adapter.py @@ -61,6 +61,41 @@ class _UltralyticsSegmentationAdapterBase: message="Segmentation dependencies are not installed. Install backend optional extras with geointel-backend[ai].", status_code=503, ) + self.validate_runtime() + + def validate_runtime(self) -> None: + """Fail closed when the deployment contract requires NVIDIA CUDA. + + Detection and segmentation share ``YOLO_DEVICE`` and + ``YOLO_REQUIRE_CUDA``. Without this check segmentation could advertise + a GPU job while Ultralytics silently used CPU or failed only after the + model had already been loaded. + """ + + if not self.settings.yolo_require_cuda: + return + try: + import torch + except Exception as exc: + raise AppError( + code="SEGMENTATION_ACCELERATOR_UNAVAILABLE", + message="NVIDIA CUDA is required for configured segmentation, but PyTorch is not importable.", + status_code=503, + ) from exc + if not torch.cuda.is_available(): + raise AppError( + code="SEGMENTATION_ACCELERATOR_UNAVAILABLE", + message="NVIDIA CUDA is required for configured segmentation, but no CUDA device is available.", + details={"configured_device": self.settings.yolo_device}, + status_code=503, + ) + if not str(self.settings.yolo_device).lower().startswith(("cuda", "0", "1", "2", "3")): + raise AppError( + code="SEGMENTATION_ACCELERATOR_MISCONFIGURED", + message="NVIDIA CUDA is required, but YOLO_DEVICE does not select a CUDA device.", + details={"configured_device": self.settings.yolo_device}, + status_code=503, + ) def _predict(self, model, tile_path: Path, confidence_threshold: float) -> list[Any]: if not tile_path.exists() or not tile_path.is_file(): diff --git a/backend/app/services/segmentation_service.py b/backend/app/services/segmentation_service.py index be3052a9..62031149 100644 --- a/backend/app/services/segmentation_service.py +++ b/backend/app/services/segmentation_service.py @@ -272,6 +272,8 @@ class SegmentationService: dataset_id: uuid.UUID | None = None, class_name: str | None = None, min_confidence: float | None = None, + limit: int | None = None, + offset: int = 0, ) -> SegmentationListResponse: if analysis_run_id is not None: run = db.get(AnalysisRun, analysis_run_id) @@ -284,8 +286,19 @@ class SegmentationService: class_name=class_name, min_confidence=min_confidence, ) - items = [SegmentationRead.model_validate(row) for row in rows] - return SegmentationListResponse(items=items, total=len(items)) + resolved_limit = DetectionService.DEFAULT_RESULT_LIMIT if limit is None else int(limit) + page, total, truncated = DetectionService.paginate( + rows, + limit=resolved_limit, + offset=offset, + ) + return SegmentationListResponse( + items=[SegmentationRead.model_validate(row) for row in page], + total=total, + limit=resolved_limit, + offset=max(0, int(offset)), + truncated=truncated, + ) @staticmethod def get_segmentation(db, segmentation_id: uuid.UUID) -> SegmentationRead: @@ -847,7 +860,6 @@ class SegmentationService: "suppressed_segmentation_count": len(candidates) - len(filtered_candidates), "duplicate_iou_threshold": float(settings.segmentation_duplicate_iou_threshold), "containment_suppression_threshold": float(settings.segmentation_containment_nms_threshold), - "duplicate_iou_threshold": float(settings.segmentation_duplicate_iou_threshold), "tile_manifest_path": str(Path(tile_manifest_path or "").expanduser()), "runtime_model_provenance": runtime_model_provenance.as_dict(), } diff --git a/backend/tests/frontend_contract.py b/backend/tests/frontend_contract.py index 6b94cd30..70b9dfe2 100644 --- a/backend/tests/frontend_contract.py +++ b/backend/tests/frontend_contract.py @@ -93,6 +93,7 @@ FEATURE_SOURCES: dict[str, tuple[str, ...]] = { ), "shell": ( "App.tsx", + "WorkbenchApp.tsx", "components/shell/WorkbenchNavigation.tsx", "components/shell/SecondaryDisplay.tsx", "components/inspector/WorkbenchInspector.tsx", diff --git a/backend/tests/test_guest_resource_scope.py b/backend/tests/test_guest_resource_scope.py new file mode 100644 index 00000000..3a719e34 --- /dev/null +++ b/backend/tests/test_guest_resource_scope.py @@ -0,0 +1,347 @@ +from __future__ import annotations + +from pathlib import Path +from uuid import UUID + +import pytest +from fastapi.testclient import TestClient + +from app.core.config import get_settings +from app.db.session import get_db +from app.main import create_app +from app.models import AnalysisRun, Dataset, Detection, Export, Job, Segmentation +from app.schemas import ( + DetectionRunListResponse, + DetectionRunResponse, + SegmentationRunListResponse, + SegmentationRunResponse, +) +from app.services.auth_service import AuthService +from app.services.detection_service import DetectionService +from app.services.segmentation_service import SegmentationService + + +GUEST_PROJECT_ID = UUID("00000000-0000-0000-0000-000000000123") +OTHER_PROJECT_ID = UUID("00000000-0000-0000-0000-000000000999") +DATASET_ID = UUID("00000000-0000-0000-0000-000000000201") +DETECTION_RUN_ID = UUID("00000000-0000-0000-0000-000000000202") +SEGMENTATION_RUN_ID = UUID("00000000-0000-0000-0000-000000000203") +DETECTION_ID = UUID("00000000-0000-0000-0000-000000000204") +SEGMENTATION_ID = UUID("00000000-0000-0000-0000-000000000205") +EXPORT_ID = UUID("00000000-0000-0000-0000-000000000206") +JOB_ID = UUID("00000000-0000-0000-0000-000000000207") + + +class FakeSession: + def __init__(self, objects: dict[tuple[type, UUID], object]) -> None: + self.objects = objects + + def get(self, model, row_id): + return self.objects.get((model, row_id)) + + +def _guest_client(monkeypatch, db: FakeSession) -> TestClient: + password_hash = AuthService.hash_password( + "operator-password", + salt=b"guest-scope-test-salt", + iterations=100_000, + ) + monkeypatch.setenv("GEOINTEL_AUTH_ENABLED", "true") + monkeypatch.setenv("GEOINTEL_AUTH_USERNAME", "operator") + monkeypatch.setenv("GEOINTEL_AUTH_PASSWORD_HASH", password_hash) + monkeypatch.setenv( + "GEOINTEL_AUTH_SESSION_SECRET", + "guest-scope-test-session-secret-value", + ) + monkeypatch.setenv("GEOINTEL_GUEST_ACCESS_ENABLED", "true") + monkeypatch.setenv("GEOINTEL_GUEST_DISPLAY_NAME", "Gast") + + client = TestClient(create_app()) + + def fake_db(): + yield db + + client.app.dependency_overrides[get_db] = fake_db + token = AuthService.create_session_token( + "Gast", + get_settings(), + role="guest", + project_id=GUEST_PROJECT_ID, + ) + client.cookies.set("geointel_session", token) + return client + + +def _project_objects(project_id: UUID, export_path: Path) -> dict[tuple[type, UUID], object]: + return { + (Dataset, DATASET_ID): Dataset( + id=DATASET_ID, + project_id=project_id, + name="scope-test.tif", + dataset_type="raster", + source="fixture", + ), + (AnalysisRun, DETECTION_RUN_ID): AnalysisRun( + id=DETECTION_RUN_ID, + project_id=project_id, + dataset_id=DATASET_ID, + analysis_type="detection", + status="success", + parameters_json={}, + ), + (AnalysisRun, SEGMENTATION_RUN_ID): AnalysisRun( + id=SEGMENTATION_RUN_ID, + project_id=project_id, + dataset_id=DATASET_ID, + analysis_type="segmentation", + status="success", + parameters_json={}, + ), + (Detection, DETECTION_ID): Detection( + id=DETECTION_ID, + project_id=project_id, + dataset_id=DATASET_ID, + analysis_run_id=DETECTION_RUN_ID, + model_name="fixture-detector", + class_name="building", + confidence=0.9, + geometry="SRID=4326;POINT (5 51)", + ), + (Segmentation, SEGMENTATION_ID): Segmentation( + id=SEGMENTATION_ID, + project_id=project_id, + dataset_id=DATASET_ID, + analysis_run_id=SEGMENTATION_RUN_ID, + model_name="fixture-segmenter", + class_name="building", + confidence=0.9, + geometry="SRID=4326;MULTIPOLYGON (((5 51, 5.1 51, 5.1 51.1, 5 51)))", + ), + (Export, EXPORT_ID): Export( + id=EXPORT_ID, + project_id=project_id, + export_type="dataset_geojson", + storage_path=str(export_path), + metadata_json={}, + ), + } + + +@pytest.mark.parametrize( + "path", + [ + f"/api/v1/detection/runs/{DETECTION_RUN_ID}", + f"/api/v1/detection/runs/{DETECTION_RUN_ID}/detections", + f"/api/v1/detection/runs/{DETECTION_RUN_ID}/geojson", + f"/api/v1/detection/datasets/{DATASET_ID}/detections", + f"/api/v1/detection/datasets/{DATASET_ID}/geojson", + f"/api/v1/detection/detections/{DETECTION_ID}", + f"/api/v1/segmentation/runs/{SEGMENTATION_RUN_ID}", + f"/api/v1/segmentation/runs/{SEGMENTATION_RUN_ID}/segmentations", + f"/api/v1/segmentation/runs/{SEGMENTATION_RUN_ID}/geojson", + f"/api/v1/segmentation/datasets/{DATASET_ID}/segmentations", + f"/api/v1/segmentation/datasets/{DATASET_ID}/geojson", + f"/api/v1/segmentation/segmentations/{SEGMENTATION_ID}", + f"/api/v1/exports/{EXPORT_ID}", + f"/api/v1/exports/{EXPORT_ID}/content", + f"/api/v1/exports/{EXPORT_ID}/download", + f"/api/v1/exports/projects/{OTHER_PROJECT_ID}/exports", + ], +) +def test_matching_guest_query_cannot_authorize_another_projects_resource( + path: str, + tmp_path: Path, + monkeypatch, +) -> None: + artifact = tmp_path / "other-project.geojson" + artifact.write_text('{"type":"FeatureCollection","features":[]}', encoding="utf-8") + client = _guest_client(monkeypatch, FakeSession(_project_objects(OTHER_PROJECT_ID, artifact))) + + response = client.get(f"{path}?project_id={GUEST_PROJECT_ID}") + + assert response.status_code == 403 + assert response.json()["error"] == "GUEST_PROJECT_SCOPE_REQUIRED" + + +@pytest.mark.parametrize( + ("path", "payload"), + [ + ( + "/api/v1/detection/run", + {"project_id": str(OTHER_PROJECT_ID), "dataset_id": str(DATASET_ID), "model_id": "fixture"}, + ), + ( + "/api/v1/detection/run-async", + {"project_id": str(OTHER_PROJECT_ID), "dataset_id": str(DATASET_ID), "model_id": "fixture"}, + ), + ( + "/api/v1/segmentation/run", + {"project_id": str(OTHER_PROJECT_ID), "dataset_id": str(DATASET_ID), "model_id": "fixture"}, + ), + ( + "/api/v1/segmentation/run-async", + {"project_id": str(OTHER_PROJECT_ID), "dataset_id": str(DATASET_ID), "model_id": "fixture"}, + ), + ( + "/api/v1/detection/runs/{run_id}/qa/reference".format(run_id=DETECTION_RUN_ID), + {"reference_dataset_id": str(DATASET_ID)}, + ), + ( + "/api/v1/segmentation/runs/{run_id}/qa/reference".format(run_id=SEGMENTATION_RUN_ID), + {"reference_dataset_id": str(DATASET_ID)}, + ), + ( + "/api/v1/exports/geojson", + {"export_kind": "dataset", "dataset_id": str(DATASET_ID)}, + ), + ( + "/api/v1/exports/geojson", + {"export_kind": "detection_run", "analysis_run_id": str(DETECTION_RUN_ID)}, + ), + ( + "/api/v1/exports/geojson", + {"export_kind": "segmentation_run", "analysis_run_id": str(SEGMENTATION_RUN_ID)}, + ), + ( + "/api/v1/exports/metadata", + {"project_id": str(OTHER_PROJECT_ID)}, + ), + ( + "/api/v1/exports/report", + {"project_id": str(OTHER_PROJECT_ID)}, + ), + ( + "/api/v1/exports/map-result", + { + "project_id": str(OTHER_PROJECT_ID), + "mode": "current", + "dataset_id": str(DATASET_ID), + "bbox": {"min_x": 5.0, "min_y": 51.0, "max_x": 5.1, "max_y": 51.1, "crs": "EPSG:4326"}, + }, + ), + ], +) +def test_matching_guest_query_cannot_override_post_body_or_target_scope( + path: str, + payload: dict, + tmp_path: Path, + monkeypatch, +) -> None: + artifact = tmp_path / "other-project.geojson" + artifact.write_text("{}", encoding="utf-8") + client = _guest_client(monkeypatch, FakeSession(_project_objects(OTHER_PROJECT_ID, artifact))) + + response = client.post(f"{path}?project_id={GUEST_PROJECT_ID}", json=payload) + + assert response.status_code == 403 + assert response.json()["error"] == "GUEST_PROJECT_SCOPE_REQUIRED" + + +def test_guest_can_still_read_and_download_its_own_resources( + tmp_path: Path, + monkeypatch, +) -> None: + artifact = tmp_path / "demo.geojson" + artifact.write_text('{"type":"FeatureCollection","features":[]}', encoding="utf-8") + client = _guest_client(monkeypatch, FakeSession(_project_objects(GUEST_PROJECT_ID, artifact))) + suffix = f"?project_id={GUEST_PROJECT_ID}" + + detection = client.get(f"/api/v1/detection/runs/{DETECTION_RUN_ID}{suffix}") + segmentation = client.get(f"/api/v1/segmentation/runs/{SEGMENTATION_RUN_ID}{suffix}") + export = client.get(f"/api/v1/exports/{EXPORT_ID}{suffix}") + download = client.get(f"/api/v1/exports/{EXPORT_ID}/download{suffix}") + + assert detection.status_code == 200 + assert segmentation.status_code == 200 + assert export.status_code == 200 + assert download.status_code == 200 + assert download.json()["type"] == "FeatureCollection" + + +def test_guest_run_lists_and_new_runs_remain_bound_to_the_session_project( + tmp_path: Path, + monkeypatch, +) -> None: + artifact = tmp_path / "demo.geojson" + artifact.write_text("{}", encoding="utf-8") + client = _guest_client(monkeypatch, FakeSession(_project_objects(GUEST_PROJECT_ID, artifact))) + observed: list[UUID] = [] + + def detection_list(_db, *, project_id, **_kwargs): + observed.append(project_id) + return DetectionRunListResponse(items=[], total=0, limit=50, offset=0, truncated=False) + + def segmentation_list(_db, *, project_id, **_kwargs): + observed.append(project_id) + return SegmentationRunListResponse(items=[], total=0, limit=50, offset=0, truncated=False) + + def detection_run(**kwargs): + observed.append(kwargs["project_id"]) + return DetectionRunResponse( + analysis_run_id=DETECTION_RUN_ID, + job_id=JOB_ID, + project_id=kwargs["project_id"], + dataset_id=kwargs["dataset_id"], + model_id=kwargs["model_id"], + status="success", + detection_count=0, + message="Demo run completed", + ) + + def segmentation_run(**kwargs): + observed.append(kwargs["project_id"]) + return SegmentationRunResponse( + analysis_run_id=SEGMENTATION_RUN_ID, + job_id=JOB_ID, + project_id=kwargs["project_id"], + dataset_id=kwargs["dataset_id"], + model_id=kwargs["model_id"], + status="success", + segmentation_count=0, + message="Demo run completed", + ) + + monkeypatch.setattr(DetectionService, "list_runs", detection_list) + monkeypatch.setattr(SegmentationService, "list_runs", segmentation_list) + monkeypatch.setattr(DetectionService, "run_detection", detection_run) + monkeypatch.setattr(SegmentationService, "run_segmentation", segmentation_run) + + def enqueue_detection(**kwargs): + observed.append(kwargs["project_id"]) + return Job( + id=JOB_ID, + job_type="detection.run", + status="queued", + project_id=kwargs["project_id"], + dataset_id=kwargs["dataset_id"], + parameters_json={}, + ) + + monkeypatch.setattr(DetectionService, "enqueue_detection", enqueue_detection) + + def enqueue_segmentation(**kwargs): + observed.append(kwargs["project_id"]) + return Job( + id=JOB_ID, + job_type="segmentation.run", + status="queued", + project_id=kwargs["project_id"], + dataset_id=kwargs["dataset_id"], + parameters_json={}, + ) + + monkeypatch.setattr(SegmentationService, "enqueue_segmentation", enqueue_segmentation) + query = f"?project_id={GUEST_PROJECT_ID}" + payload = {"project_id": str(GUEST_PROJECT_ID), "dataset_id": str(DATASET_ID), "model_id": "fixture"} + + responses = [ + client.get(f"/api/v1/detection/runs{query}"), + client.get(f"/api/v1/segmentation/runs{query}"), + client.post(f"/api/v1/detection/run{query}", json=payload), + client.post(f"/api/v1/detection/run-async{query}", json=payload), + client.post(f"/api/v1/segmentation/run{query}", json=payload), + client.post(f"/api/v1/segmentation/run-async{query}", json=payload), + ] + + assert all(response.status_code == 200 for response in responses) + assert observed == [GUEST_PROJECT_ID] * 6 diff --git a/backend/tests/test_outbound_request_guard.py b/backend/tests/test_outbound_request_guard.py index c0bc3e68..f866cd5f 100644 --- a/backend/tests/test_outbound_request_guard.py +++ b/backend/tests/test_outbound_request_guard.py @@ -18,8 +18,10 @@ import pytest from app.core.errors import AppError from app.services.outbound_request_guard import ( + _ValidatedRedirects, assert_public_http_url, assert_same_origin_redirect, + validated_redirect_opener, ) @@ -89,6 +91,30 @@ class TestRedirects: def test_an_upgrade_to_https_stays_allowed(self) -> None: assert_same_origin_redirect("http://geo.example.be/wcs", "https://geo.example.be/wcs") + def test_a_redirect_to_another_port_is_refused(self) -> None: + with pytest.raises(AppError) as exc_info: + assert_same_origin_redirect( + "https://geo.api.vlaanderen.be/wcs", + "https://geo.api.vlaanderen.be:8443/wcs", + ) + + assert exc_info.value.code == "OUTBOUND_REDIRECT_NOT_ALLOWED" + + def test_embedded_credentials_are_refused(self) -> None: + with pytest.raises(AppError) as exc_info: + assert_public_http_url("https://operator:secret@geo.example.be/wcs") + + assert exc_info.value.code == "OUTBOUND_URL_NOT_ALLOWED" + + def test_a_redirect_with_an_invalid_port_fails_closed(self) -> None: + with pytest.raises(AppError) as exc_info: + assert_same_origin_redirect( + "https://geo.api.vlaanderen.be/wcs", + "https://geo.api.vlaanderen.be:not-a-port/wcs", + ) + + assert exc_info.value.code == "OUTBOUND_URL_NOT_ALLOWED" + def test_the_guard_opener_refuses_a_cross_host_redirect() -> None: """The opener is what the acquisition services actually call.""" @@ -249,6 +275,26 @@ def test_a_refused_redirect_is_never_requested() -> None: assert "_RejectRedirects" in handlers +def test_the_default_guard_validates_before_following_a_redirect() -> None: + opener = validated_redirect_opener("https://geo.api.vlaanderen.be/wcs") + handlers = [type(handler).__name__ for handler in opener.handlers] + + assert "_ValidatedRedirects" in handlers + + handler = _ValidatedRedirects("https://geo.api.vlaanderen.be/wcs") + with pytest.raises(AppError) as exc_info: + handler.redirect_request( + None, + None, + 302, + "Found", + {}, + "http://169.254.169.254/latest/meta-data/", + ) + + assert exc_info.value.code == "OUTBOUND_REDIRECT_NOT_ALLOWED" + + def test_the_rejecting_handler_returns_no_new_request() -> None: from app.services.outbound_request_guard import _RejectRedirects diff --git a/backend/tests/test_segmentation_adapter_runtime.py b/backend/tests/test_segmentation_adapter_runtime.py new file mode 100644 index 00000000..f5d2d10b --- /dev/null +++ b/backend/tests/test_segmentation_adapter_runtime.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from app.core.config import Settings +from app.core.errors import AppError +from app.services.segmentation_adapter import YoloSegmentationAdapter + + +def _settings(*, require_cuda: bool, device: str) -> Settings: + return Settings( + _env_file=None, + YOLO_REQUIRE_CUDA=require_cuda, + YOLO_DEVICE=device, + ) + + +def test_segmentation_runtime_allows_cpu_only_when_cuda_is_not_required() -> None: + adapter = YoloSegmentationAdapter(_settings(require_cuda=False, device="cpu")) + + adapter.validate_runtime() + + +def test_segmentation_runtime_rejects_missing_cuda(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setitem( + __import__("sys").modules, + "torch", + SimpleNamespace(cuda=SimpleNamespace(is_available=lambda: False)), + ) + adapter = YoloSegmentationAdapter(_settings(require_cuda=True, device="cuda:0")) + + with pytest.raises(AppError) as exc_info: + adapter.validate_runtime() + + assert exc_info.value.code == "SEGMENTATION_ACCELERATOR_UNAVAILABLE" + + +def test_segmentation_runtime_rejects_cpu_device_when_cuda_is_required( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setitem( + __import__("sys").modules, + "torch", + SimpleNamespace(cuda=SimpleNamespace(is_available=lambda: True)), + ) + adapter = YoloSegmentationAdapter(_settings(require_cuda=True, device="cpu")) + + with pytest.raises(AppError) as exc_info: + adapter.validate_runtime() + + assert exc_info.value.code == "SEGMENTATION_ACCELERATOR_MISCONFIGURED" + + +def test_segmentation_runtime_accepts_configured_cuda(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setitem( + __import__("sys").modules, + "torch", + SimpleNamespace(cuda=SimpleNamespace(is_available=lambda: True)), + ) + adapter = YoloSegmentationAdapter(_settings(require_cuda=True, device="cuda:0")) + + adapter.validate_runtime() diff --git a/backend/tests/test_segmentation_result_pagination.py b/backend/tests/test_segmentation_result_pagination.py new file mode 100644 index 00000000..b449001f --- /dev/null +++ b/backend/tests/test_segmentation_result_pagination.py @@ -0,0 +1,151 @@ +"""Regression coverage for bounded, stable segmentation result listings.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from types import SimpleNamespace +from uuid import UUID + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.api.routes import segmentation as segmentation_routes +from app.db.session import get_db +from app.schemas.segmentation import SegmentationListResponse +from app.services.segmentation_service import SegmentationService + + +RUN_ID = UUID("00000000-0000-0000-0000-000000000101") +DATASET_ID = UUID("00000000-0000-0000-0000-000000000102") +PROJECT_ID = UUID("00000000-0000-0000-0000-000000000103") + + +def _segmentation(index: int) -> SimpleNamespace: + return SimpleNamespace( + id=UUID(int=index + 1), + project_id=PROJECT_ID, + dataset_id=DATASET_ID, + analysis_run_id=RUN_ID, + job_id=None, + model_name="segmentation-test-model", + model_version="1", + class_name="building", + confidence=0.99 - index / 100, + bbox_json=None, + area_m2=float(index + 1), + mask_path=None, + source_tile_path=None, + tile_index=index, + properties_json={}, + provenance_json={}, + created_at=datetime(2026, 8, 23, tzinfo=UTC), + ) + + +class _Session: + def get(self, _model, identifier): + if identifier == RUN_ID: + return SimpleNamespace(analysis_type="segmentation") + return None + + +def test_service_returns_one_stable_page_with_complete_metadata(monkeypatch) -> None: + rows = [_segmentation(index) for index in range(5)] + monkeypatch.setattr( + SegmentationService, + "_query_segmentation_rows", + staticmethod(lambda _db, **_filters: rows), + ) + + result = SegmentationService.list_segmentations( + _Session(), + analysis_run_id=RUN_ID, + dataset_id=DATASET_ID, + limit=2, + offset=1, + ) + + assert [item.id for item in result.items] == [rows[1].id, rows[2].id] + assert result.total == 5 + assert result.limit == 2 + assert result.offset == 1 + assert result.truncated is True + + +def test_service_pages_cover_the_stably_ordered_population_once(monkeypatch) -> None: + rows = [_segmentation(index) for index in range(5)] + monkeypatch.setattr( + SegmentationService, + "_query_segmentation_rows", + staticmethod(lambda _db, **_filters: rows), + ) + + seen = [] + for offset in (0, 2, 4): + result = SegmentationService.list_segmentations( + _Session(), + dataset_id=DATASET_ID, + limit=2, + offset=offset, + ) + seen.extend(item.id for item in result.items) + assert result.total == len(rows) + assert result.offset == offset + + assert seen == [row.id for row in rows] + + +@pytest.mark.parametrize( + ("path", "expected_run_id", "expected_dataset_id"), + [ + (f"/api/v1/segmentation/runs/{RUN_ID}/segmentations", RUN_ID, None), + (f"/api/v1/segmentation/datasets/{DATASET_ID}/segmentations", None, DATASET_ID), + ], +) +def test_both_listing_routes_forward_the_page_window_and_return_it( + monkeypatch, + path: str, + expected_run_id: UUID | None, + expected_dataset_id: UUID | None, +) -> None: + calls: list[dict] = [] + + def _list(_db, analysis_run_id=None, **parameters): + calls.append({"analysis_run_id": analysis_run_id, **parameters}) + return SegmentationListResponse( + items=[], + total=9, + limit=2, + offset=4, + truncated=True, + ) + + monkeypatch.setattr(SegmentationService, "list_segmentations", staticmethod(_list)) + app = FastAPI() + app.include_router(segmentation_routes.router, prefix="/api/v1") + app.dependency_overrides[get_db] = lambda: object() + + response = TestClient(app).get( + path, + params={"limit": 2, "offset": 4, "class_name": "building", "min_confidence": 0.5}, + ) + + assert response.status_code == 200 + assert response.json()["data"] == { + "items": [], + "total": 9, + "limit": 2, + "offset": 4, + "truncated": True, + } + assert calls == [ + { + "analysis_run_id": expected_run_id, + "limit": 2, + "offset": 4, + "dataset_id": expected_dataset_id, + "class_name": "building", + "min_confidence": 0.5, + } + ] diff --git a/backend/tests/test_sprint104_ai_lab_action_guardrails.py b/backend/tests/test_sprint104_ai_lab_action_guardrails.py index 264d67c0..340f5032 100644 --- a/backend/tests/test_sprint104_ai_lab_action_guardrails.py +++ b/backend/tests/test_sprint104_ai_lab_action_guardrails.py @@ -15,7 +15,8 @@ def test_detection_lab_distinguishes_configured_model_from_ui_runnable_action() assert "detectionRunBlockedReason" in lab assert "Het fixturemodel is alleen bedoeld voor expliciete tests" in lab assert "Klaar om gebouwen te zoeken" in lab - assert "disabled={runningDetection || !detectionRunReady}" in lab + assert "disabled={runningDetection || runningDetectionCalibration || detectionJobActive || !detectionRunReady}" in lab + assert "detectionJob?.status === 'queued' || detectionJob?.status === 'running'" in lab def test_segmentation_lab_distinguishes_configured_model_from_ui_runnable_action() -> None: @@ -26,7 +27,8 @@ def test_segmentation_lab_distinguishes_configured_model_from_ui_runnable_action assert "segmentationRunBlockedReason" in lab assert "Het fixturemodel is alleen bedoeld voor expliciete tests" in lab assert "Analyse" in lab - assert "disabled={runningSegmentation || !segmentationRunReady}" in lab + assert "disabled={runningSegmentation || segmentationJobActive || !segmentationRunReady}" in lab + assert "segmentationJob?.status === 'queued' || segmentationJob?.status === 'running'" in lab def test_ai_lab_guardrail_styles_remain_compact() -> None: diff --git a/backend/tests/test_sprint123_raster_detection_handoff_operational.py b/backend/tests/test_sprint123_raster_detection_handoff_operational.py index cfaf7140..938e2a02 100644 --- a/backend/tests/test_sprint123_raster_detection_handoff_operational.py +++ b/backend/tests/test_sprint123_raster_detection_handoff_operational.py @@ -28,7 +28,12 @@ def test_raster_controls_show_manifest_details_and_ai_handoff_action() -> None: def test_detection_handoff_opens_ai_lab_preflights_manifest_and_keeps_asset_explicit() -> None: - app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8") + app = "\n".join( + ( + (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8"), + (ROOT / "frontend" / "src" / "WorkbenchApp.tsx").read_text(encoding="utf-8"), + ) + ) lab = "\n".join( ( (ROOT / "frontend" / "src" / "components" / "detection" / "DetectionLab.tsx").read_text(encoding="utf-8"), @@ -40,7 +45,7 @@ def test_detection_handoff_opens_ai_lab_preflights_manifest_and_keeps_asset_expl assert "setSelectedDetectionDatasetId(selectedDataset.id)" in app assert "setSelectedDetectionModelId('yolo-configured')" in app assert "setDetectionConfidenceThreshold(0.25)" in app - assert "loadYoloPreflight(manifestPath).catch(() => null)" in app + assert "loadYoloPreflight(manifestPath).catch(() => meldLaadfout('modelcontrole'))" in app assert "setSelectedModelAssetId(" not in app[app.index("const useRasterTileManifestForDetection"):app.index("const {", app.index("const useRasterTileManifestForDetection"))] assert "Gekoppelde beeldtegels" in lab assert "Gekoppelde beeldtegels" in lab diff --git a/backend/tests/test_sprint195_guided_detection_workflow.py b/backend/tests/test_sprint195_guided_detection_workflow.py index 0f8b4221..59a4e87e 100644 --- a/backend/tests/test_sprint195_guided_detection_workflow.py +++ b/backend/tests/test_sprint195_guided_detection_workflow.py @@ -26,8 +26,10 @@ def test_guided_detection_reuses_canonical_raster_and_detection_apis() -> None: assert "effectiveModelId" in hook assert "effectiveModelAssetId" in hook assert "await loadDetectionResults(result.analysis_run_id)" in hook - assert "model_id: selectedDetectionModelId" in hook - assert "model_asset_id: selectedModelAssetId || null" in hook + assert "model_id: modelId" in hook + assert "model_asset_id: modelAssetId || null" in hook + assert "effectiveModelId" in hook + assert "effectiveModelAssetId" in hook def test_guided_detection_upload_uses_existing_dataset_persistence_boundary() -> None: @@ -63,7 +65,8 @@ def test_detection_qa_remains_persisted_and_primary_not_parallel() -> None: assert 'aria-label="Kwaliteitscontrole gebouwdetectie"' in lab assert "als kwaliteitscontrole in de database bewaard" in lab assert "detectionApi.compareWithReference" in hook - assert "await loadQualityChecks(selectedProjectId)" in hook + assert "await loadQualityChecks(projectId)" in hook + assert "detectionQaRequestSequence.current" in hook assert "Minimale IoU voor een match" in lab assert "detectionQaResult.iou_threshold.toFixed(2)" in lab diff --git a/docs/API_CONTRACTS.md b/docs/API_CONTRACTS.md index 122216c6..2a94b0a8 100644 --- a/docs/API_CONTRACTS.md +++ b/docs/API_CONTRACTS.md @@ -1528,7 +1528,10 @@ Response: ## Detection Lab -Sprint 8 implements Detection Lab foundation only. YOLO/PyTorch real inference is not enabled, no model is downloaded, and fixture detections require explicit fixture mode. +Detection Lab exposes the governed local YOLO/PyTorch runtime only when model, +dependencies and the configured NVIDIA accelerator pass preflight. GeoIntel +never downloads a model implicitly; fixture detections still require explicit +fixture mode and are not production inference. ### Guided browser orchestration @@ -1537,11 +1540,17 @@ The current frontend offers one guided building-analysis action, but does not ad 1. optional explicit `POST /api/v1/projects/{project_id}/datasets/upload` for a georeferenced GeoTIFF; 2. `POST /api/v1/projects/{project_id}/datasets/{dataset_id}/raster/tile` with 512 px tiles and 64 px overlap; 3. `GET /api/v1/detection/yolo/preflight` with the returned manifest and selected local model asset; -4. `POST /api/v1/detection/run` only after successful preflight; -5. persisted run, Detection list and Detection GeoJSON reads; -6. optional persisted reference QA through the existing detection QA endpoint. +4. `POST /api/v1/detection/run-async` only after successful preflight; +5. project-bound polling through + `GET /api/v1/projects/{project_id}/jobs/{job_id}` until a terminal state; +6. persisted run, Detection list and Detection GeoJSON reads; +7. optional persisted reference QA through the existing detection QA endpoint. -The strict `POST /api/v1/detection/run` contract still requires `tile_manifest_path` for configured YOLO. The frontend does not create fake tiles, bypass tile limits, fetch external imagery or download model weights. +The strict async request contract still requires `tile_manifest_path` for +configured YOLO. The production frontend does not fall back to the synchronous +inference route, create fake tiles, bypass tile limits, fetch external imagery +or download model weights. A zero-count success remains a completed inference, +not proof that the selected area contains no objects. ### GET `/api/v1/detection/models` @@ -1758,8 +1767,12 @@ rejected immediately rather than by a job that fails minutes later. Queued jobs are executed by the background analysis worker (`GEOINTEL_ANALYSIS_WORKER_ENABLED`, poll interval `GEOINTEL_ANALYSIS_WORKER_POLL_SECONDS`), which claims a job before dispatching -it so the same run is never started twice. Poll `GET /api/v1/jobs/{id}` for -progress. `POST /api/v1/segmentation/run-async` behaves identically. +it so the same run is never started twice. Poll the project-bound +`GET /api/v1/projects/{project_id}/jobs/{job_id}` endpoint for progress. +`POST /api/v1/segmentation/run-async` behaves identically. Guest sessions may +queue and read analysis only for the project id embedded in their signed +session; query parameters never authorize a run, result or export belonging to +another project. Unavailable model response: @@ -2032,7 +2045,12 @@ Same pattern as object detection, but output includes masks and polygonized geom ## Segmentation Lab -Sprint 9 implements Segmentation Lab foundation only. Real SAM and YOLO-seg inference are not enabled, no model is downloaded, and fixture segmentations require explicit fixture mode. +Segmentation Lab exposes a configured local YOLO-seg or SAM runtime when its +model file, immutable runtime provenance and dependencies validate. No model is +downloaded. On the NVIDIA server, `YOLO_REQUIRE_CUDA=true` makes both configured +segmentation adapters fail closed when CUDA is absent or `YOLO_DEVICE` selects +CPU. Fixture segmentations remain explicit test-only data and the production +browser never queues that model. ### GET `/api/v1/segmentation/models` @@ -2043,6 +2061,9 @@ Returns segmentation model capability descriptors: - `yolo-seg-configured`: `not_configured` - `sam-configured`: `not_configured` +The two configured entries become `configured` only when their corresponding +enable flag, local model file and provenance sidecar validate. + ### POST `/api/v1/segmentation/run` Creates a segmentation job and segmentation analysis run. If the requested model is unavailable, the job and analysis run are marked `failed` with `SEGMENTATION_MODEL_UNAVAILABLE`. @@ -2063,6 +2084,16 @@ Request: Fixture segmenter mode is test/demo-only. It persists only explicit `parameters_json.fixture_segmentations` entries when `parameters_json.fixture_mode=true`; it is never invoked automatically and does not represent production inference. +The production frontend uses `POST /api/v1/segmentation/run-async`, then polls +`GET /api/v1/projects/{project_id}/jobs/{job_id}` and reconciles the terminal +job with its persisted `AnalysisRun` and polygon records. It does not fall back +to the synchronous route. A configured model requires an existing +`tile_manifest_path`; missing CUDA fails with +`SEGMENTATION_ACCELERATOR_UNAVAILABLE` or +`SEGMENTATION_ACCELERATOR_MISCONFIGURED` when CUDA is required. A valid +zero-polygon run is shown as an empty model result, never as proof that the AOI +contains no relevant objects. + Validation errors: - `INVALID_DATASET_TYPE` when the dataset is not raster. @@ -2086,6 +2117,11 @@ Returns persisted segmentation records for a segmentation analysis run. Optional - `dataset_id` - `class_name` - `min_confidence` +- `limit` (`0` means every matching record, otherwise capped at `50000`) +- `offset` + +The response reports `total`, `limit`, `offset` and `truncated`; clients must +not present a truncated page as the complete polygon population. ### GET `/api/v1/segmentation/datasets/{dataset_id}/segmentations` diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index 5d7ef2e5..1eae78b1 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -12867,3 +12867,71 @@ Open: - Browser emulation covers responsive layout and interaction; certification on physical touch hardware and with a screen reader remains a separate human QA activity. + +## 2026-08-23 - Sol Ultra product-, runtime- en betrouwbaarheidsronde + +### Delivered + +- Split the public landing foundation from the lazy workbench and MapLibre + styles. The initial production CSS payload dropped from roughly 219 kB to + 38.38 kB while the authenticated workbench keeps its complete styling. +- Extended the reproducible browser audit to cover the landing and workbench at + 390 x 844, 1366 x 768 and 2560 x 1080, including mobile navigation, + keyboard tabs, loading state, advanced map flow and every guest workspace. +- Corrected the smartphone shell hierarchy: topbar, guest banner and page + heading no longer overlap, and the live Selecteer/Bronnen/Verwerk/Controleer + rail now sits below the map actions instead of behind the fixed navigation. +- Kept full workspace titles for headings and accessible names while shortening + the two mobile navigation labels to `AI-beeld` and `Export`; the browser gate + now rejects any visible sidebar label whose text box is clipped. +- Made map-analysis failures outrank empty states and added an explicit retry; + new selections clear stale coverage immediately. +- Replaced synchronous browser inference with governed async detection and + segmentation queues, project-bound job polling and persisted-run + reconciliation. Detection has NVIDIA preflight; segmentation now fails + closed under the same server CUDA contract. Zero-result runs are communicated + without claiming that the AOI is object-free. +- Bound guest detection, segmentation and export reads/writes to the signed + demo project at the resource level. Matching query parameters can no longer + authorize another project's run, dataset, result or download. +- Closed stale-response races in temporal comparison and the local GeoAI + assistant, plus detection/segmentation run, result and QA flows across project + switches. Previously visited workspaces no longer reload together after every + navigation change. +- Added keyboard-complete pipeline tabs and React-driven model-dialog state, + initial focus and trigger-focus restoration. Landing scrolling now respects + `prefers-reduced-motion`. +- Hardened outbound acquisition redirects before the redirected request is + opened, including origin/port and embedded-credential rejection, and fixed + bounded pagination for segmentation result lists. +- Fixed segmentation readiness and section status: configured production + models now require a real tile manifest before queueing, fixture mode is + visibly test-only, and queued/running NVIDIA work has an explicit live state. +- Localised known model registrations and availability states in the Dutch UI; + raw English backend placeholder copy no longer leaks into the primary model + selector or readiness guidance. + +### Verification + +- Frontend TypeScript check and production build passed. +- Complete frontend suite: 36 files / 151 tests passed. +- Relevant backend release set: 121 tests passed, covering async analysis jobs, + atomic claims, guest/resource isolation, redirect policy, segmentation + pagination, NVIDIA runtime enforcement and current AI-lab contracts. +- Ruff passed over every changed backend Python module and test. +- Browser evidence passed across three landing and three authenticated + workbench viewports with zero horizontal overflow, console errors or failed + API requests in `.codex-artifacts/sol-ultra-final-l/manifest.json`; focused + AI-workspace and segmentation screenshots are stored beside it. +- Production build passed. Initial landing CSS remains 38.38 kB (8.22 kB + gzip); the lazy workbench JS is 479.76 kB (129.25 kB gzip) and MapLibre stays + isolated in its own lazy chunk. + +### Boundaries + +- This pass improves runtime correctness and presentation; it does not invent a + new accuracy claim or promote a model checkpoint. Existing governed model + evidence and regional release gates remain authoritative. +- Physical touch-device and screen-reader certification remain human QA. No + commit or deployment was performed because the active execution brief + explicitly forbids committing unless requested. diff --git a/docs/TODO.md b/docs/TODO.md index df77f3c9..9b5a1cd1 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1109,6 +1109,32 @@ This file now starts with the current implementation status. Older preparation/b - [x] Loading-, empty-, unavailable- en errorstates plus toetsenbord- en dialogbediening. - [x] Zoekbare en bredere kaartthemalijst met volledig leesbare labels. - [x] Compacte analysecontextbalk en rustige desktop/tablet/mobiele hiërarchie. + +# Sprint 237 - Sol Ultra productupgrade (2026-08-23) + +- [x] Splits publieke landing-CSS en MapLibre van de lazy werkbankbundel. +- [x] Valideer landing en werkbank op 390, 1366 en 2560 px zonder overflow, + consolefouten of mislukte API-requests. +- [x] Herstel mobiele topbar/banner/kop- en procesrailbotsingen. +- [x] Voorkom afgekapte mobiele navigatielabels met korte zichtlabels en een + automatische clipping-gate. +- [x] Toon analysefouten vóór lege states en bied een herhaalactie. +- [x] Wis oude dekkingsdata zodra een nieuwe AOI wordt opgelost. +- [x] Sluit stale-response races in tijdvergelijking en AI-vragen. +- [x] Sluit late detectie-/segmentatiejobs, resultaten en QA na een + werkruimtewissel uit. +- [x] Voer productie-detectie uitsluitend via async NVIDIA/GPU-jobs uit en + verzoen het resultaat met de bewaarde AnalysisRun. +- [x] Voer productie-segmentatie uitsluitend via async serverjobs uit, eis een + tegelmanifest en laat de NVIDIA-runtime fail-closed valideren. +- [x] Bind gast-detecties, segmentaties en downloads aan het gesigneerde + demoproject op resourceniveau. +- [x] Valideer redirects vóór netwerktoegang en begrens segmentatieresultaten. +- [x] Maak pipeline-tabs en modeldialoog volledig toetsenbordbedienbaar. +- [x] Lokaliseer bekende modelnamen en beschikbaarheidsmeldingen in de primaire + Nederlandse AI-flow. +- [ ] Voer vóór formele toegankelijkheidscertificatie nog fysieke touch- en + screenreader-QA uit; browseremulatie en automatische naamcontrole zijn groen. - [x] Uitschuifbare inzichten behouden; analyse blijft uitsluitend expliciet na themakeuze. - [x] 51 frontendtests en productiebuild groen. - [ ] 19 verouderde broncode-stringtests herijken; meerdere eisen daarin (automatische analyse) conflicteren bewust met de actuele productbeslissing. diff --git a/frontend/README.md b/frontend/README.md index a0dee06c..3236de6e 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -160,7 +160,9 @@ font dependency. ## Current component boundaries -`App.tsx` remains the shared workspace orchestrator, while focused surfaces +`App.tsx` only decides which of the two shells to show: the landing page or the +workbench. `WorkbenchApp.tsx` is the shared workspace orchestrator and is loaded +lazily, so the sign-in screen does not pay for the map engine. Focused surfaces and pure map helpers are kept outside it: - `components/overview/OverviewWorkspace.tsx` owns status, source freshness, @@ -836,3 +838,45 @@ The audit covers 390x844, 1366x768 and 2560x1080, every top-level workspace, keyboard operation of the analysis period, skip-link focus, delayed bootstrap truthfulness and visible coverage timing. The enforced limits are documented in `docs/UX_PERFORMANCE_BUDGETS.md`. + +## Visual audit follow-through + +The workbench runs dark by default; the landing page stays light. A toggle in +the top bar switches between them and remembers the choice per browser. The +operating system preference is deliberately ignored: browsers report `light` by +default even when the user never chose, which would put almost everyone in the +wrong mode. + +`styles/geointel-system.css` is loaded last and leads. Thirteen tokens were +called on 87 lines without ever being defined, so those colour declarations fell +back to inheritance; they now resolve. Shape and elevation went back onto the +scale, and the weight scale has four steps that are each actually loaded — the +stylesheets previously declared nine weights while two faces were available, and +`font-synthesis-weight: none` meant everything from 650 to 850 rendered as 600. + +Real `!important` went from 47 to 1, verified by comparing computed styles +between production builds. The remaining one is documented where it stands. +Fifteen of the removed ones sat in the second-screen block: that window is +opened with `window.open('')` and never fetched the linked stylesheet, so they +were overriding something that never arrived. `SecondaryDisplay.tsx` now inlines +the rules and adopts the already-loaded font faces. + +Contrast is measured with a gradient-aware checker across six workspaces in both +modes; the threshold is WCAG AA. + +## Loading strategy + +The workbench is a separate chunk behind `React.lazy`. The sign-in screen loads +344 kB over the wire instead of 1.46 MB. + +`apiGet` shares concurrent and closely-following identical requests for 300 ms. +This collapses the start-up cascade, where two effects asked for the same areas +and datasets about ninety milliseconds apart. The table is cleared on sign-in, +sign-out and session expiry, because signing out does not reload the page. + +Each workspace loads its own data when it is first opened rather than everything +up front. Start-up went from 27 requests to 16. + +Source images for the web assets live in `design-assets/`, not in `public/`. +Everything in `public/` is copied verbatim into `dist/`, so 8.1 MB of unused PNG +masters shipped with every build. diff --git a/frontend/design-assets/README.md b/frontend/design-assets/README.md new file mode 100644 index 00000000..d745e109 --- /dev/null +++ b/frontend/design-assets/README.md @@ -0,0 +1,14 @@ +# Bronbestanden voor beeld + +Hier staan de originelen waaruit de webversies in `public/` gemaakt zijn. + +Ze stonden eerder in `public/portfolio/`. Alles in `public/` wordt door Vite +ongewijzigd naar `dist/` gekopieerd, dus die 8,1 MB aan PNG's werd bij elke +build meegedeployed terwijl geen enkele regel code ernaar verwijst — alleen de +webp-versies worden gebruikt. + +Een master hoort bewaard te blijven, maar niet in de map die de webserver +uitserveert. Wie een webversie opnieuw wil maken: + + python -c "from PIL import Image; im=Image.open('design-assets/portfolio/x.png'); \ + im.resize((1600, round(im.height*1600/im.width))).save('public/portfolio/x.webp','WEBP',quality=58,method=6)" diff --git a/frontend/public/portfolio/geointel-belgium-north-sea-hero.png b/frontend/design-assets/portfolio/geointel-belgium-north-sea-hero.png similarity index 100% rename from frontend/public/portfolio/geointel-belgium-north-sea-hero.png rename to frontend/design-assets/portfolio/geointel-belgium-north-sea-hero.png diff --git a/frontend/public/portfolio/geointel-building-qa.png b/frontend/design-assets/portfolio/geointel-building-qa.png similarity index 100% rename from frontend/public/portfolio/geointel-building-qa.png rename to frontend/design-assets/portfolio/geointel-building-qa.png diff --git a/frontend/public/portfolio/geointel-dark-case-study-cover.png b/frontend/design-assets/portfolio/geointel-dark-case-study-cover.png similarity index 100% rename from frontend/public/portfolio/geointel-dark-case-study-cover.png rename to frontend/design-assets/portfolio/geointel-dark-case-study-cover.png diff --git a/frontend/e2e/uxAudit.mjs b/frontend/e2e/uxAudit.mjs index 8a3202ae..02a5145d 100644 --- a/frontend/e2e/uxAudit.mjs +++ b/frontend/e2e/uxAudit.mjs @@ -63,9 +63,34 @@ async function auditInteractiveNames(page, label) { return unnamed.length } +async function prepareAuditSession(page, baseUrl) { + const sessionResponse = await page.request.get(`${baseUrl}/api/v1/auth/session`) + assert(sessionResponse.ok(), `Session preflight failed with HTTP ${sessionResponse.status()}`) + const sessionEnvelope = await sessionResponse.json() + const session = sessionEnvelope?.data + + if (!session?.authentication_required || session.authenticated) return session + assert.equal( + session.guest_access_enabled, + true, + 'UX audit needs an authenticated session or enabled guest access', + ) + + const guestResponse = await page.request.post(`${baseUrl}/api/v1/auth/guest`) + assert(guestResponse.ok(), `Guest audit session failed with HTTP ${guestResponse.status()}`) + const guestEnvelope = await guestResponse.json() + return guestEnvelope?.data +} + async function layoutEvidence(page) { return page.evaluate(() => { const root = document.documentElement + const rect = (selector) => { + const bounds = document.querySelector(selector)?.getBoundingClientRect() + return bounds + ? { top: bounds.top, bottom: bounds.bottom, left: bounds.left, right: bounds.right, width: bounds.width, height: bounds.height } + : null + } const main = document.querySelector('.workbench-main')?.getBoundingClientRect() const map = document.querySelector('.geo-map-stage')?.getBoundingClientRect() const theme = document.querySelector('.geo-theme-panel')?.getBoundingClientRect() @@ -75,6 +100,11 @@ async function layoutEvidence(page) { document_width: root.scrollWidth, body_width: document.body.scrollWidth, horizontal_overflow_px: Math.max(0, root.scrollWidth - root.clientWidth), + shell_navigation: rect('.workbench-sidebar'), + topbar: rect('.workbench-topbar'), + guest_banner: rect('.guest-mode-banner'), + explorer_header: rect('.geo-explorer-header'), + live_analysis_journey: rect('.live-analysis-journey'), main: main ? { left: main.left, right: main.right, width: main.width } : null, map: map ? { left: map.left, right: map.right, width: map.width, height: map.height } : null, theme: theme ? { left: theme.left, right: theme.right, width: theme.width } : null, @@ -82,6 +112,57 @@ async function layoutEvidence(page) { }) } +async function runLandingViewport(browser, baseUrl, outputDir, viewport) { + const page = await browser.newPage({ viewport }) + const consoleErrors = [] + const failedRequests = [] + page.on('console', (message) => { + if (message.type() === 'error') consoleErrors.push(message.text()) + }) + page.on('pageerror', (error) => consoleErrors.push(error.message)) + page.on('requestfailed', (request) => { + if (request.url().startsWith(baseUrl)) { + failedRequests.push(`${request.method()} ${request.url()}: ${request.failure()?.errorText}`) + } + }) + try { + await page.goto(baseUrl, { waitUntil: 'networkidle', timeout: 60_000 }) + await page.locator('.landing-page').waitFor({ state: 'visible', timeout: 15_000 }) + await auditInteractiveNames(page, `${viewport.width}px landing`) + const horizontalOverflow = await page.evaluate(() => ( + Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth) + )) + assert.equal(horizontalOverflow, 0, `${viewport.width}px landing overflows horizontally`) + assert.equal( + await page.getByRole('heading', { level: 1 }).count(), + 1, + `${viewport.width}px landing needs one clear primary heading`, + ) + + if (viewport.width <= 760) { + const menu = page.locator('.landing-menu-toggle') + assert.equal(await menu.getAttribute('aria-label'), 'Navigatie openen') + await menu.click() + assert.equal(await menu.getAttribute('aria-expanded'), 'true') + await page.getByRole('navigation', { name: 'Landingspagina' }).waitFor({ state: 'visible' }) + await page.getByRole('button', { name: 'Navigatie sluiten' }).click() + } + + await page.screenshot({ + path: path.join(outputDir, `landing-${viewport.width}x${viewport.height}.png`), + fullPage: true, + }) + return { + viewport, + horizontal_overflow_px: horizontalOverflow, + console_errors: consoleErrors, + failed_requests: failedRequests, + } + } finally { + await page.close() + } +} + async function runViewport(browser, baseUrl, outputDir, viewport) { const page = await browser.newPage({ viewport }) const consoleErrors = [] @@ -96,14 +177,41 @@ async function runViewport(browser, baseUrl, outputDir, viewport) { } }) try { + await prepareAuditSession(page, baseUrl) const startedAt = Date.now() await page.goto(baseUrl, { waitUntil: 'networkidle', timeout: 60_000 }) await page.getByTestId('map-workspace').waitFor({ state: 'visible', timeout: 30_000 }) const readyMs = Date.now() - startedAt await auditInteractiveNames(page, `${viewport.width}px map explorer`) const layout = await layoutEvidence(page) + const clippedNavigationLabels = await page.locator('.nav-item span').evaluateAll((labels) => labels + .filter((label) => label.getClientRects().length > 0 && label.scrollWidth > label.clientWidth + 1) + .map((label) => label.textContent?.trim() || '')) assert.equal(layout.horizontal_overflow_px, 0, `${viewport.width}px layout overflows horizontally`) + assert.deepEqual(clippedNavigationLabels, [], `${viewport.width}px navigation clips visible labels`) assert(layout.map && layout.map.width >= Math.min(320, viewport.width - 32), `${viewport.width}px map is too narrow`) + if (layout.topbar && layout.guest_banner) { + assert( + layout.topbar.bottom <= layout.guest_banner.top + 1, + `${viewport.width}px topbar overlaps the guest access banner`, + ) + } + if (layout.guest_banner && layout.explorer_header) { + assert( + layout.guest_banner.bottom <= layout.explorer_header.top + 1, + `${viewport.width}px guest access banner overlaps the explorer heading`, + ) + } + if (layout.shell_navigation && layout.live_analysis_journey) { + const verticalOverlap = Math.min(layout.shell_navigation.bottom, layout.live_analysis_journey.bottom) + - Math.max(layout.shell_navigation.top, layout.live_analysis_journey.top) + const horizontalOverlap = Math.min(layout.shell_navigation.right, layout.live_analysis_journey.right) + - Math.max(layout.shell_navigation.left, layout.live_analysis_journey.left) + assert( + verticalOverlap <= 1 || horizontalOverlap <= 1, + `${viewport.width}px navigation overlaps the live analysis journey`, + ) + } const currentTab = page.getByRole('tab', { name: 'Laatste toestand' }) const evolutionTab = page.getByRole('tab', { name: 'Evolutie' }) @@ -127,6 +235,7 @@ async function runViewport(browser, baseUrl, outputDir, viewport) { viewport, ready_ms: readyMs, layout, + clipped_navigation_labels: clippedNavigationLabels, console_errors: consoleErrors, failed_requests: failedRequests, } @@ -144,6 +253,7 @@ async function runLoadingAndAdvancedAudit(browser, baseUrl, outputDir) { await route.continue() }) try { + const auditSession = await prepareAuditSession(page, baseUrl) await page.goto(baseUrl, { waitUntil: 'domcontentloaded', timeout: 60_000 }) const loadingStatus = page.getByRole('status', { name: '' }).filter({ hasText: 'Databronnen worden gecontroleerd', @@ -175,10 +285,30 @@ async function runLoadingAndAdvancedAudit(browser, baseUrl, outputDir) { await page.screenshot({ path: path.join(outputDir, 'advanced-coverage-budget.png') }) const auditedWorkspaces = [] - for (const workspace of ['data', 'assistant', 'analysis', 'ai', 'exports', 'overview', 'system']) { + const workspaceKeys = ['data', 'assistant', 'analysis', 'ai', 'exports', 'overview'] + if (auditSession?.role === 'guest') { + assert.equal( + await page.getByTestId('workspace-nav-system').count(), + 0, + 'Guest session exposes operator-only system settings', + ) + } else { + workspaceKeys.push('system') + } + for (const workspace of workspaceKeys) { await page.getByTestId(`workspace-nav-${workspace}`).click() await page.waitForTimeout(100) await auditInteractiveNames(page, `${workspace} workspace`) + if (workspace === 'ai') { + await page.screenshot({ path: path.join(outputDir, 'ai-workspace.png'), fullPage: true }) + const segmentationDisclosure = page.locator('.segmentation-disclosure') + await segmentationDisclosure.scrollIntoViewIfNeeded() + await segmentationDisclosure.locator('summary').first().click() + await page.waitForTimeout(150) + await auditInteractiveNames(page, 'open segmentation lab') + await segmentationDisclosure.locator('.ai-lab-run-surface').scrollIntoViewIfNeeded() + await page.screenshot({ path: path.join(outputDir, 'ai-segmentation.png') }) + } auditedWorkspaces.push(workspace) } @@ -203,21 +333,27 @@ async function main() { schema_version: 1, base_url: args.baseUrl, started_at: new Date().toISOString(), + landing_viewports: [], viewports: [], bootstrap: null, status: 'running', } try { - for (const viewport of [ + const viewports = [ { width: 390, height: 844 }, { width: 1366, height: 768 }, { width: 2560, height: 1080 }, - ]) { + ] + for (const viewport of viewports) { + evidence.landing_viewports.push(await runLandingViewport(browser, args.baseUrl, outputDir, viewport)) + } + for (const viewport of viewports) { evidence.viewports.push(await runViewport(browser, args.baseUrl, outputDir, viewport)) } evidence.bootstrap = await runLoadingAndAdvancedAudit(browser, args.baseUrl, outputDir) - const unexpectedConsoleErrors = evidence.viewports.flatMap((item) => item.console_errors) - const unexpectedFailedRequests = evidence.viewports.flatMap((item) => item.failed_requests) + const auditedPages = [...evidence.landing_viewports, ...evidence.viewports] + const unexpectedConsoleErrors = auditedPages.flatMap((item) => item.console_errors) + const unexpectedFailedRequests = auditedPages.flatMap((item) => item.failed_requests) assert.deepEqual(unexpectedConsoleErrors, [], 'UX audit captured console errors') assert.deepEqual(unexpectedFailedRequests, [], 'UX audit captured failed API requests') evidence.status = 'passed' diff --git a/frontend/public/geointel-icon.png b/frontend/public/geointel-icon.png deleted file mode 100644 index 71258cf0..00000000 Binary files a/frontend/public/geointel-icon.png and /dev/null differ diff --git a/frontend/public/itworx-wordmark.png b/frontend/public/itworx-wordmark.png deleted file mode 100644 index d69f7b4e..00000000 Binary files a/frontend/public/itworx-wordmark.png and /dev/null differ diff --git a/frontend/public/itworx-wordmark.webp b/frontend/public/itworx-wordmark.webp new file mode 100644 index 00000000..88b39d75 Binary files /dev/null and b/frontend/public/itworx-wordmark.webp differ diff --git a/frontend/public/landing-hero-belgium.webp b/frontend/public/landing-hero-belgium.webp index b62701ec..c9fe2371 100644 Binary files a/frontend/public/landing-hero-belgium.webp and b/frontend/public/landing-hero-belgium.webp differ diff --git a/frontend/public/portfolio/geointel-belgium-north-sea-hero.webp b/frontend/public/portfolio/geointel-belgium-north-sea-hero.webp index 4394b9b9..93410b37 100644 Binary files a/frontend/public/portfolio/geointel-belgium-north-sea-hero.webp and b/frontend/public/portfolio/geointel-belgium-north-sea-hero.webp differ diff --git a/frontend/public/portfolio/geointel-building-qa.webp b/frontend/public/portfolio/geointel-building-qa.webp index 84a27052..947b5adc 100644 Binary files a/frontend/public/portfolio/geointel-building-qa.webp and b/frontend/public/portfolio/geointel-building-qa.webp differ diff --git a/frontend/public/portfolio/geointel-dark-case-study-cover.webp b/frontend/public/portfolio/geointel-dark-case-study-cover.webp deleted file mode 100644 index ce5d8f4c..00000000 Binary files a/frontend/public/portfolio/geointel-dark-case-study-cover.webp and /dev/null differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3f255793..1d93f3b5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,1455 +1,15 @@ -import { useEffect, useMemo, useRef, useState } from 'react' -import { CircleAlert, LogOut, MonitorUp, ShieldCheck, UserRound } from 'lucide-react' -import '@fontsource/manrope/latin-500.css' -import '@fontsource/manrope/latin-600.css' -import '@fontsource/manrope/latin-700.css' -import '@fontsource/public-sans/latin-400.css' -import '@fontsource/public-sans/latin-500.css' -import '@fontsource/public-sans/latin-600.css' -import './styles/app.css' -import './styles/premium.css' -import './styles/atlas-workbench.css' -import './styles/atlas-premium-v2.css' -import './styles/professionalization.css' -// Laatst geladen en leidend: het geconsolideerde designsysteem. -// Zie de kop van geointel-system.css voor de reden. -import './styles/geointel-system.css' +import { Suspense, lazy } from 'react' import { LandingPage } from './components/auth/LandingPage' -import { ChangeDetectionPanel } from './components/analysis/ChangeDetectionPanel' -import { GeoAssistantPanel } from './components/assistant/GeoAssistantPanel' -import { DatasetPanel } from './components/datasets/DatasetPanel' -import { SourceCatalogPanel } from './components/datasets/SourceCatalogPanel' -import { DetectionLab } from './components/detection/DetectionLab' -import { ExportCenter } from './components/exports/ExportCenter' -import { ExportPreview } from './components/exports/ExportPreview' -import { WorkbenchInspector } from './components/inspector/WorkbenchInspector' -import { MapWorkspace } from './components/map/MapWorkspace' -import { OverviewWorkspace, type WorkspaceKey } from './components/overview/OverviewWorkspace' -import { AreaPanel } from './components/project/AreaPanel' -import { ProjectPanel } from './components/project/ProjectPanel' -import { QualityResultsPanel } from './components/quality/QualityResultsPanel' -import type { DatasetCreateResponse } from './types' -import { ProviderPanel } from './components/providers/ProviderPanel' -import { SegmentationLab } from './components/segmentation/SegmentationLab' -import { - WorkbenchNavigation, - type WorkspaceNavigationGroup, - type WorkspaceNavigationItem, -} from './components/shell/WorkbenchNavigation' -import { WorkspaceSignal } from './components/shell/WorkspaceSignal' -import { useSecondaryDisplay } from './components/shell/SecondaryDisplay' -import { useChangeDetectionWorkflow } from './hooks/useChangeDetectionWorkflow' -import { useDemoWorkflow } from './hooks/useDemoWorkflow' -import { useCoverageResolver } from './hooks/useCoverageResolver' -import { useDetectionWorkflow } from './hooks/useDetectionWorkflow' -import { useDatasetWorkflow } from './hooks/useDatasetWorkflow' -import { useExportWorkflow } from './hooks/useExportWorkflow' -import { useMapSelectionDataset } from './hooks/useMapSelectionDataset' -import { useMapSelectionQa } from './hooks/useMapSelectionQa' -import { useMapWorkspaceState } from './hooks/useMapWorkspaceState' import { useOperatorSession } from './hooks/useOperatorSession' -import { useMapSelectionExtract } from './hooks/useMapSelectionExtract' -import { useMapOrthophotoAnalysis } from './hooks/useMapOrthophotoAnalysis' -import { isDetectionImageryDataset, isMapRasterDataset } from './lib/datasetCapabilities' -import { useProviderCapabilities } from './hooks/useProviderCapabilities' -import { useProjectWorkspace } from './hooks/useProjectWorkspace' -import { useQualityWorkflow } from './hooks/useQualityWorkflow' -import { useSegmentationWorkflow } from './hooks/useSegmentationWorkflow' -import { useSourceFreshness } from './hooks/useSourceFreshness' -import { useWorkbenchBootstrap } from './hooks/useWorkbenchBootstrap' -import { useViewportVectorLayer } from './hooks/useViewportVectorLayer' -import { getDatasetDisplayName } from './lib/datasetDisplay' -import { - FLANDERS_WORKSPACE_LABEL, - FLANDERS_WORKSPACE_PROJECT_NAME, - NATIONAL_WORKSPACE_LABEL, - NATIONAL_WORKSPACE_PROJECT_NAME, - REGIONAL_WORKSPACE_LABEL, - REGIONAL_WORKSPACE_PROJECT_NAME, -} from './config/primaryFocus' -function isVectorDatasetType(datasetType: string): boolean { - return datasetType === 'vector' || datasetType === 'geojson' -} - -const workspaceNavItems: WorkspaceNavigationItem[] = [ - { key: 'overview', label: 'Status', description: 'Beschikbaarheid en aandachtspunten' }, - { key: 'data', label: 'Bronnen', description: 'Gebieden en ingeladen gegevens' }, - { key: 'map', label: 'Kaart', description: 'Selecteren, uitlezen en vergelijken' }, - { key: 'assistant', label: 'AI-vragen', description: 'Vraag de lokale assistent over het actieve gebied' }, - { key: 'analysis', label: 'Kwaliteit', description: 'Resultaten controleren' }, - { key: 'ai', label: 'Beeldanalyse', description: 'Gebouwen herkennen op luchtbeelden' }, - { key: 'exports', label: 'Downloads', description: 'Resultaten bewaren en delen' }, - { key: 'system', label: 'Systeem', description: 'Bronkoppelingen en operationele status' }, -] - -const workspaceNavGroups: WorkspaceNavigationGroup[] = [ - { label: 'Verkennen', keys: ['map', 'data'] }, - { label: 'Vragen', keys: ['assistant'] }, - { label: 'Analyseren', keys: ['analysis', 'ai'] }, - { label: 'Afronden', keys: ['exports'] }, - { label: 'Beheer', keys: ['overview', 'system'] }, -] - -const guestWorkspaceKeys = new Set([ - 'overview', - 'data', - 'map', - 'assistant', - 'analysis', - 'ai', - 'exports', -]) -const guestWorkspaceGroups: WorkspaceNavigationGroup[] = [ - { label: 'Verkennen', keys: ['map', 'data'] }, - { label: 'Vragen', keys: ['assistant'] }, - { label: 'Analyseren', keys: ['analysis', 'ai'] }, - { label: 'Afronden', keys: ['exports'] }, - { label: 'Demo', keys: ['overview'] }, -] - -interface WorkbenchAppProps { - username: string | null - accessMode: 'open' | 'operator' | 'guest' - loggingOut: boolean - onLogout: () => void -} - -function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchAppProps): JSX.Element { - const isGuest = accessMode === 'guest' - const [activeWorkspace, setActiveWorkspace] = useState('map') - const [inspectorOpen, setInspectorOpen] = useState(false) - const [guestDemoReady, setGuestDemoReady] = useState(!isGuest) - const secondaryDisplay = useSecondaryDisplay() - const guestDemoStartedRef = useRef(false) - const visibleWorkspaceItems = useMemo( - () => isGuest ? workspaceNavItems.filter((item) => guestWorkspaceKeys.has(item.key)) : workspaceNavItems, - [isGuest], - ) - const visibleWorkspaceGroups = isGuest ? guestWorkspaceGroups : workspaceNavGroups - const [mapContentMode, setMapContentMode] = useState<'dataset' | 'analysis'>('dataset') - const [mapContextSourceLabel, setMapContextSourceLabel] = useState(null) - const [mapContextLayerLabel, setMapContextLayerLabel] = useState(null) - const workbenchMainRef = useRef(null) - const previousWorkspaceRef = useRef(activeWorkspace) - useEffect(() => { - if (isGuest && !guestWorkspaceKeys.has(activeWorkspace)) { - setActiveWorkspace('map') - } - }, [activeWorkspace, isGuest]) - useEffect(() => { - workbenchMainRef.current?.scrollTo({ top: 0, left: 0 }) - if (previousWorkspaceRef.current !== activeWorkspace) { - workbenchMainRef.current?.focus({ preventScroll: true }) - } - previousWorkspaceRef.current = activeWorkspace - setInspectorOpen(false) - }, [activeWorkspace]) - const { - projects, - selectedProject, - selectedProjectId, - areas, - datasets, - loadingProjects, - loadingAreas, - loadingDatasets, - archivingProjectId, - errorMessage, - projectForm, - areaForm, - loadProjects, - loadProjectData, - createProject, - createArea, - activateMunicipality, - archiveProject, - resetProjectData, - setSelectedProjectId, - setErrorMessage, - setProjectForm, - setAreaForm, - } = useProjectWorkspace() - const sourceFreshness = useSourceFreshness(selectedProjectId) - const selectProject = (projectId: string) => { - setMapContentMode('dataset') - setSelectedProjectId(projectId) - } - const { - selectedDatasetId, - selectedDataset, - selectedDatasetSummary, - selectedRasterMetadata, - selectedRasterStats, - datasetContent, - jobs, - rasterPreview, - latestRasterTileManifestPath, - latestRasterTileManifest, - selectedIntersectTargetId, - selectedClipAreaId, - rasterTileSize, - rasterTileOverlap, - rasterTileOutputName, - rasterReprojectCrs, - rasterReprojectResampling, - ndviNirBand, - ndviRedBand, - ndwiGreenBand, - ndwiNirBand, - ndbiSwirBand, - ndbiNirBand, - loadingDatasetDetails, - datasetDetailError, - datasetForm, - isRasterTileInputValid, - loadDatasetJobs, - loadDatasetDetails, - uploadDataset, - runVectorClip, - runVectorBuffer, - runVectorIntersect, - runRasterInspect, - runRasterPreview, - runRasterStats, - runRasterReproject, - runRasterClip, - runRasterTile, - runRasterNdvi, - runRasterNdwi, - runRasterNdbi, - pickDerivedDataset, - refreshMetadata, - resetDatasetForProject, - setSelectedDatasetId, - setSelectedIntersectTargetId, - setSelectedClipAreaId, - setRasterTileSize, - setRasterTileOverlap, - setRasterTileOutputName, - setRasterReprojectCrs, - setRasterReprojectResampling, - setNdviNirBand, - setNdviRedBand, - setNdwiGreenBand, - setNdwiNirBand, - setNdbiSwirBand, - setNdbiNirBand, - setDatasetForm, - } = useDatasetWorkflow({ - selectedProjectId, - areas, - datasets, - loadProjectData, - setErrorMessage, - isVectorDatasetType, - }) - const rasterUnavailableMessage = useMemo(() => { - if (!selectedDataset || selectedDataset.dataset_type !== 'raster' || !selectedDataset.metadata_json) { - return null - } - const metadata = selectedDataset.metadata_json as Record - const processingCode = metadata['processing_code'] - if (processingCode === 'RASTER_PROCESSING_UNAVAILABLE') { - return String(metadata['processing_error'] ?? 'Raster processing unavailable.') - } - return null - }, [selectedDataset]) - - const availableVectorTargets = useMemo( - () => datasets.filter((item) => item.id !== selectedDatasetId && isVectorDatasetType(item.dataset_type)), - [datasets, selectedDatasetId], - ) - const availableVectorDatasets = useMemo(() => datasets.filter((item) => isVectorDatasetType(item.dataset_type)), [datasets]) - const availableMapDatasets = useMemo( - () => { - const vectors = datasets.filter((dataset) => isVectorDatasetType(dataset.dataset_type) && dataset.status === 'ready') - const rasters = datasets.filter(isMapRasterDataset) - return [...vectors, ...rasters] - }, - [datasets], - ) - const referenceDatasets = useMemo( - () => availableVectorDatasets.filter((item) => item.dataset_role === 'reference'), - [availableVectorDatasets], - ) - const candidateDatasets = useMemo( - () => availableVectorDatasets.filter((item) => item.dataset_role !== 'reference'), - [availableVectorDatasets], - ) - const rasterDatasets = useMemo(() => datasets.filter((item) => item.dataset_type === 'raster'), [datasets]) - const detectionRasterDatasets = useMemo( - () => rasterDatasets.filter(isDetectionImageryDataset), - [rasterDatasets], - ) - const { - providers, - loadingCapabilities, - capabilitiesError, - loadCapabilities, - } = useProviderCapabilities() - const { - changeSourceDatasetId, - changeTargetDatasetId, - changeIouThreshold, - changeIncludeUnchanged, - runningChangeDetection, - changeDetectionResult, - changeDetectionError, - runChangeDetection, - setChangeSourceDatasetId, - setChangeTargetDatasetId, - setChangeIouThreshold, - setChangeIncludeUnchanged, - } = useChangeDetectionWorkflow({ - selectedProjectId, - availableVectorDatasets, - loadDatasetJobs, - // Bind the comparison to whatever the operator has selected on the map. - getSelection: () => ({ bbox: mapSelectionBbox ?? null, areaId: selectedMapAreaId ?? null }), - }) - const { - qaCandidateDatasetId, - qaReferenceDatasetId, - qaAreaId, - qaIouThreshold, - qaRunning, - qaResult, - qaError, - qualityChecks, - qualityChecksError, - qualityEvidenceGeoJson, - qualityEvidenceLoading, - qualityEvidenceError, - loadQualityChecks, - loadQualityEvidenceGeoJson, - clearQualityEvidenceGeoJson, - runQaComparison, - setQaCandidateDatasetId, - setQaReferenceDatasetId, - setQaAreaId, - setQaIouThreshold, - } = useQualityWorkflow({ - selectedProjectId, - loadProjectData, - }) - const { - detectionModels, - modelAssets, - loadingDetectionModels, - detectionModelError, - modelAssetError, - selectedDetectionDatasetId, - selectedDetectionModelId, - selectedModelAssetId, - detectionTileManifestPath, - detectionConfidenceThreshold, - runningDetection, - detectionRunResult, - detectionRunError, - detectionRuns, - selectedDetectionRunId, - detectionItems, - detectionTotal, - detectionTruncated, - detectionGeoJson, - detectionClassFilter, - detectionMinConfidenceFilter, - loadingDetectionResults, - detectionReferenceDatasetId, - detectionQaResult, - detectionQaError, - runningDetectionQa, - yoloPreflight, - loadingYoloPreflight, - yoloPreflightError, - calibrationThresholdText, - runningDetectionCalibration, - detectionCalibrationRows, - detectionCalibrationError, - detectionWorkflowStage, - loadDetectionModels, - loadYoloPreflight, - loadDetectionRuns, - loadDetectionResults, - runDetection, - uploadDetectionRaster, - prepareAndRunDetection, - compareDetectionRunWithReference, - runDetectionQa, - runDetectionCalibration, - applyDetectionOperatorProfile, - resetDetectionForProject, - setSelectedDetectionDatasetId, - setSelectedDetectionModelId, - setSelectedModelAssetId, - setDetectionTileManifestPath, - setDetectionConfidenceThreshold, - setSelectedDetectionRunId, - setDetectionClassFilter, - setDetectionMinConfidenceFilter, - setDetectionReferenceDatasetId, - setCalibrationThresholdText, - } = useDetectionWorkflow({ - selectedProjectId, - rasterDatasets: detectionRasterDatasets, - qaIouThreshold, - loadProjectData, - loadQualityChecks, - }) - const useRasterTileManifestForDetection = () => { - const manifestPath = latestRasterTileManifestPath.trim() - if (!manifestPath || !selectedDataset || !isDetectionImageryDataset(selectedDataset)) { - return - } - setDetectionTileManifestPath(manifestPath) - setSelectedDetectionDatasetId(selectedDataset.id) - setSelectedDetectionModelId('yolo-configured') - setDetectionConfidenceThreshold(0.25) - loadYoloPreflight(manifestPath).catch(() => null) - setActiveWorkspace('ai') - } - const { - segmentationModels, - loadingSegmentationModels, - segmentationModelError, - selectedSegmentationDatasetId, - selectedSegmentationModelId, - selectedSegmentationModel, - segmentationTileManifestPath, - segmentationConfidenceThreshold, - runningSegmentation, - segmentationRunResult, - segmentationRunError, - segmentationRuns, - selectedSegmentationRunId, - segmentationItems, - segmentationGeoJson, - segmentationClassFilter, - segmentationMinConfidenceFilter, - loadingSegmentationResults, - segmentationReferenceDatasetId, - segmentationQaResult, - segmentationQaError, - runningSegmentationQa, - loadSegmentationModels, - loadSegmentationRuns, - loadSegmentationResults, - runSegmentation, - runSegmentationQa, - resetSegmentationForProject, - setSelectedSegmentationDatasetId, - setSelectedSegmentationModelId, - setSegmentationTileManifestPath, - setSegmentationConfidenceThreshold, - setSelectedSegmentationRunId, - setSegmentationClassFilter, - setSegmentationMinConfidenceFilter, - setSegmentationReferenceDatasetId, - } = useSegmentationWorkflow({ - selectedProjectId, - rasterDatasets, - qaIouThreshold, - loadProjectData, - loadQualityChecks, - }) - const viewportVectorLayer = useViewportVectorLayer({ - selectedProjectId, - selectedDataset, - featureCount: selectedDatasetSummary?.feature_count ?? selectedDataset?.feature_count ?? null, - isVectorDatasetType, - }) - const analysisMapLayerAvailable = Boolean(changeDetectionResult?.geojson || segmentationGeoJson || detectionGeoJson) - const analysisMapLayerActive = mapContentMode === 'analysis' && analysisMapLayerAvailable - const viewportVectorLayerActive = viewportVectorLayer.enabled && !analysisMapLayerActive - const datasetMapContent = viewportVectorLayer.enabled ? viewportVectorLayer.data : datasetContent - const useRasterTileManifestForSegmentation = () => { - const manifestPath = latestRasterTileManifestPath.trim() - if (!manifestPath || !selectedDataset || selectedDataset.dataset_type !== 'raster') { - return - } - setSegmentationTileManifestPath(manifestPath) - setSelectedSegmentationDatasetId(selectedDataset.id) - setActiveWorkspace('ai') - } - const { - exports, - latestExport, - exportError, - loadingExports, - exporting, - selectionExporting, - selectionExportError, - latestSelectionExport, - exportPreview, - loadExports, - exportSelectedDatasetGeoJson, - exportSelectedDetectionRunGeoJson, - exportSelectedSegmentationRunGeoJson, - exportMapSelectionGeoJson, - persistMapResult, - exportProjectMetadata, - exportProjectReport, - previewExportContent, - downloadExportArtifact, - resetExportsForProject, - } = useExportWorkflow({ - selectedProjectId, - selectedDataset, - selectedDetectionRunId, - selectedSegmentationRunId, - isVectorDatasetType, - }) - const { - mapLayerVisible, - mapLayerOpacity, - selectedMapAreaId, - areaLayerVisible, - areaLayerOpacity, - selectedMapFeature, - areaFeatureCollection, - mapFeatureCollection, - mapLayerLabel, - mapFeatureCount, - areaFeatureCount, - setMapLayerVisible, - setMapLayerOpacity, - setSelectedMapAreaId, - setAreaLayerVisible, - setAreaLayerOpacity, - setSelectedMapFeature, - } = useMapWorkspaceState({ - areas, - changeDetectionGeoJson: analysisMapLayerActive ? changeDetectionResult?.geojson ?? null : null, - segmentationGeoJson: analysisMapLayerActive ? segmentationGeoJson : null, - detectionGeoJson: analysisMapLayerActive ? detectionGeoJson : null, - datasetContent: datasetMapContent, - selectedDataset, - datasetLayerActive: Boolean(selectedDataset && isVectorDatasetType(selectedDataset.dataset_type)), - }) - const { - mapSelectionBbox, - mapSelectionResult, - mapSelectionLoading, - mapSelectionError, - runMapSelectionExtract, - resetMapSelectionExtract, - setMapSelectionBbox, - } = useMapSelectionExtract({ - selectedProjectId, - selectedDataset, - isVectorDatasetType, - }) - const { - coverage: mapCoverage, - loadingCoverage: mapCoverageLoading, - coverageError: mapCoverageError, - coverageDurationMs: mapCoverageDurationMs, - coverageBudgetExceeded: mapCoverageBudgetExceeded, - } = useCoverageResolver({ - projectId: selectedProjectId, - bbox: mapSelectionBbox, - }) - const { - selectionDatasetSaving, - selectionDatasetError, - latestSelectionDataset, - deriveMapSelectionDataset, - } = useMapSelectionDataset({ - selectedProjectId, - selectedDataset, - isVectorDatasetType, - loadProjectData, - loadDatasetDetails, - setMapLayerVisible, - }) - const { - selectedMapQaReferenceDatasetId, - mapSelectionQaRunning, - mapSelectionQaError, - mapSelectionQaResult, - latestMapSelectionQualityCheckId, - runMapSelectionQa, - setSelectedMapQaReferenceDatasetId, - } = useMapSelectionQa({ - selectedProjectId, - latestSelectionDataset, - loadQualityChecks, - loadProjectData, - }) - const mapOrthophotoAnalysis = useMapOrthophotoAnalysis({ - selectedProjectId, - selectedAreaId: areas.find((area) => /vervoerregio|operationele grens/i.test(area.name))?.id ?? selectedMapAreaId, - selectionBbox: mapSelectionBbox, - datasets, - loadProjectData, - prepareAndRunDetection: (datasetId) => prepareAndRunDetection(datasetId, 'yolo-configured'), - compareDetectionRunWithReference: (analysisRunId, referenceDatasetId, iouThreshold) => - compareDetectionRunWithReference(analysisRunId, referenceDatasetId, false, iouThreshold), - loadQualityEvidence: (qualityCheckId) => loadQualityEvidenceGeoJson(qualityCheckId), - onAnalysisReady: () => { - setMapContentMode('analysis') - setMapLayerVisible(true) - setActiveWorkspace('map') - }, - }) - const openMapSelectionQualityEvidence = () => { - setActiveWorkspace('analysis') - } - const openQualityEvidenceOnMap = async (qualityCheckId: string) => { - const result = await loadQualityEvidenceGeoJson(qualityCheckId) - if (result) { - setMapLayerVisible(true) - setActiveWorkspace('map') - } - } - const { - loadingDemoWorkflow, - demoWorkflowMessage, - loadDemoWorkflow, - } = useDemoWorkflow({ - restrictedMode: false, - loadProjects, - loadProjectData, - loadDatasetDetails, - loadDetectionRuns, - loadSegmentationRuns, - loadQualityChecks, - loadExports, - setSelectedProjectId: selectProject, - setSelectedDatasetId, - setSelectedMapAreaId, - setQaCandidateDatasetId, - setQaReferenceDatasetId, - setQaAreaId, - setSelectedDetectionDatasetId, - setDetectionReferenceDatasetId, - setSelectedSegmentationDatasetId, - setSegmentationReferenceDatasetId, - setErrorMessage, - }) - - useEffect(() => { - if (!isGuest || guestDemoStartedRef.current) return - guestDemoStartedRef.current = true - setGuestDemoReady(false) - void loadDemoWorkflow().finally(() => setGuestDemoReady(true)) - }, [isGuest, loadDemoWorkflow]) - - useWorkbenchBootstrap({ - restrictedMode: false, - selectedProjectId, - selectedDetectionRunId, - detectionClassFilter, - detectionMinConfidenceFilter, - selectedSegmentationRunId, - segmentationClassFilter, - segmentationMinConfidenceFilter, - loadProjects, - loadCapabilities, - loadDetectionModels, - loadSegmentationModels, - loadProjectData, - loadDetectionRuns, - loadSegmentationRuns, - loadQualityChecks, - loadExports, - loadDetectionResults, - loadSegmentationResults, - resetProjectData, - resetDatasetForProject, - resetDetectionForProject, - resetSegmentationForProject, - resetExportsForProject, - }) - - const selectedArea = areas.find((area) => area.id === selectedMapAreaId) ?? null - const activeWorkspaceItem = visibleWorkspaceItems.find((item) => item.key === activeWorkspace) ?? visibleWorkspaceItems[0] - const mapLayerSourceLabel = useMemo(() => { - if (analysisMapLayerActive && changeDetectionResult?.geojson) { - return 'Veranderingsanalyse' - } - if (analysisMapLayerActive && segmentationGeoJson) { - return 'Segmentatierun' - } - if (analysisMapLayerActive && detectionGeoJson) { - return 'Detectierun' - } - if ((datasetMapContent || viewportVectorLayer.enabled) && selectedDataset) { - return selectedDataset.dataset_type === 'raster' ? 'Rasterdatabron' : 'Vectordatabron' - } - return 'Geen actieve gegevens- of analyselaag' - }, [analysisMapLayerActive, changeDetectionResult?.geojson, datasetMapContent, detectionGeoJson, segmentationGeoJson, selectedDataset, viewportVectorLayer.enabled]) - const mapLayerProvenance = useMemo(() => { - if (analysisMapLayerActive && changeDetectionResult?.geojson) { - return 'Vergelijking van twee bewaarde kaartlagen' - } - if (analysisMapLayerActive && segmentationGeoJson) { - return selectedSegmentationRunId ? 'Bewaarde segmentatieronde' : 'Segmentatieresultaten geladen' - } - if (analysisMapLayerActive && detectionGeoJson) { - return selectedDetectionRunId ? 'Bewaarde detectieronde' : 'Detectieresultaten geladen' - } - if ((datasetMapContent || viewportVectorLayer.enabled) && selectedDataset) { - return `Bewaarde ${selectedDataset.dataset_role === 'reference' ? 'referentielaag' : 'databron'}` - } - return 'Open een databron, detectieronde, segmentatieronde of veranderingsresultaat om het hier te tekenen.' - }, [ - analysisMapLayerActive, - changeDetectionResult?.geojson, - changeSourceDatasetId, - changeTargetDatasetId, - datasetMapContent, - detectionGeoJson, - segmentationGeoJson, - selectedDataset, - selectedDetectionRunId, - selectedSegmentationRunId, - viewportVectorLayer.enabled, - ]) - const openDatasetInMap = (dataset: DatasetCreateResponse) => { - if (selectedProjectId) { - loadDatasetDetails(selectedProjectId, dataset) - } - setMapContentMode('dataset') - setMapLayerVisible(true) - setActiveWorkspace('map') - } - const runGuidedDetection = async () => { - const completed = await prepareAndRunDetection() - if (completed) { - setMapContentMode('analysis') - setMapLayerVisible(true) - setActiveWorkspace('map') - } - } - const openDetectionResultsOnMap = () => { - if (!detectionGeoJson) { - return - } - setMapContentMode('analysis') - setMapLayerVisible(true) - setActiveWorkspace('map') - } - const openDatasetExport = (dataset: DatasetCreateResponse) => { - if (selectedProjectId) { - loadDatasetDetails(selectedProjectId, dataset) - } - setActiveWorkspace('exports') - } - const openWorkflowGuidanceStep = (target: WorkspaceKey) => { - if (isGuest && !guestWorkspaceKeys.has(target)) { - setActiveWorkspace('map') - return - } - if (target === 'map' && availableMapDatasets.length > 0 && !mapFeatureCollection) { - openDatasetInMap(availableMapDatasets[0]) - return - } - if (target === 'exports' && availableMapDatasets.length > 0 && !selectedDataset) { - openDatasetExport(availableMapDatasets[0]) - return - } - setActiveWorkspace(target) - } - const hasAnalysisOutput = - qualityChecks.length > 0 - || Boolean(changeDetectionResult) - || detectionItems.length > 0 - || segmentationItems.length > 0 - const projectContextLabel = selectedProject?.name === NATIONAL_WORKSPACE_PROJECT_NAME - ? NATIONAL_WORKSPACE_LABEL - : selectedProject?.name === REGIONAL_WORKSPACE_PROJECT_NAME - ? REGIONAL_WORKSPACE_LABEL - : selectedProject?.name === FLANDERS_WORKSPACE_PROJECT_NAME - ? FLANDERS_WORKSPACE_LABEL - : selectedProject?.name ?? 'Geen werkruimte' - const areaContextLabel = selectedArea?.name ?? (areas.length > 0 ? 'Kies een gebied' : 'Geen gebied') - const bathymetryProfileContextActive = Boolean( - activeWorkspace === 'map' - && selectedDataset?.source_name === 'vmm_vha_bathymetry_profiles', - ) - const bathymetryRasterContextActive = Boolean( - activeWorkspace === 'map' - && selectedDataset?.source_name === 'spw_bathymetry', - ) - const bathymetryContextActive = bathymetryProfileContextActive || bathymetryRasterContextActive - const regionalBathymetryContextActive = Boolean( - bathymetryProfileContextActive - && selectedDataset?.source_metadata?.regional_partitions_complete === true - && selectedArea - && !/^Gemeente\s/i.test(selectedArea.name), - ) - const regionalBathymetryManifest = String( - selectedDataset?.source_metadata?.partition_manifest_sha256 ?? '', - ) - const regionalBathymetryPartitions = regionalBathymetryContextActive - ? availableMapDatasets.filter( - (dataset) => - dataset.source_name === 'vmm_vha_bathymetry_profiles' - && dataset.source_metadata?.regional_partitions_complete === true - && String(dataset.source_metadata?.partition_manifest_sha256 ?? '') === regionalBathymetryManifest, - ) - : [] - const regionalBathymetryProfileCount = regionalBathymetryPartitions.reduce( - (total, dataset) => total + (dataset.feature_count ?? dataset.vector_summary?.feature_count ?? 0), - 0, - ) - const bathymetryProfileCount = regionalBathymetryContextActive - ? regionalBathymetryProfileCount - : selectedDataset?.feature_count ?? selectedDataset?.vector_summary?.feature_count ?? 0 - const workspaceDataLoading = loadingProjects || loadingAreas || loadingDatasets - const workspaceStatusMessage = errorMessage - ? `Werkruimtefout: ${errorMessage}` - : workspaceDataLoading - ? 'Werkruimte en databronnen worden geladen.' - : selectedProject - ? `${projectContextLabel} is geladen met ${datasets.length} databronnen en ${areas.length} gebieden.` - : 'Geen werkruimte geselecteerd.' - const datasetContextLabel = activeWorkspace === 'map' && mapContextSourceLabel - ? mapContextSourceLabel - : analysisMapLayerActive && mapFeatureCollection - ? `${mapLayerLabel} · controle vereist` - : selectedDataset - ? getDatasetDisplayName(selectedDataset) - : workspaceDataLoading - ? 'Bronnen worden geladen' - : datasets.length > 0 - ? 'Kies een bron' - : 'Geen bron' - const layerContextLabel = activeWorkspace === 'map' && mapContextLayerLabel - ? mapContextLayerLabel - : bathymetryContextActive - ? bathymetryRasterContextActive - ? `${Number(selectedDataset?.source_metadata?.analysis_resolution_m ?? 0.5).toLocaleString('nl-BE')} m raster · mDNG · ${String(selectedDataset?.source_metadata?.survey_period ?? '2019-2022')}` - : regionalBathymetryContextActive - ? `${bathymetryProfileCount.toLocaleString('nl-BE')} profielen · ${regionalBathymetryPartitions.length} gemeenten` - : `${bathymetryProfileCount.toLocaleString('nl-BE')} profielen` - : mapFeatureCollection - ? `${mapFeatureCount.toLocaleString('nl-BE')} objecten` - : workspaceDataLoading - ? 'Kaart wordt voorbereid' - : viewportVectorLayerActive - ? 'Kaartlaag gekozen' - : activeWorkspace === 'map' && selectedDataset?.dataset_type === 'raster' - ? 'Rasterlaag actief' - : 'Geen actieve laag' +// De werkbank wordt pas opgehaald wanneer iemand is aangemeld. Daardoor betaalt +// het inlogscherm niet voor de kaartmotor en de paneelopmaak. +const WorkbenchApp = lazy(() => import('./WorkbenchApp')) +function Voorbereiden({ tekst }: { tekst: string }): JSX.Element { return ( -
- { - window.requestAnimationFrame(() => workbenchMainRef.current?.focus({ preventScroll: true })) - }} - > - Ga naar de werkruimte - -

{workspaceStatusMessage}

- {errorMessage && activeWorkspace !== 'map' ? ( -
-
- ) : null} - -
- - -
-
-
- - GeoIntel -
-
-
- Werkruimte - {projectContextLabel} -
-
- Gebied - {areaContextLabel} -
-
- Bron - {datasetContextLabel} -
-
- Kaartlaag - {layerContextLabel} -
-
-
-
- - {username || isGuest ? ( -
- {isGuest ?
- ) : null} -
- - {secondaryDisplay.error ? ( - - ) : null} - - {isGuest ? ( -
-
- ) : null} - - {isGuest && (!guestDemoReady || loadingDemoWorkflow) ? ( -
-
- ) : null} - -
- -
- {activeWorkspace !== 'map' ?
-
-

{selectedProject?.region ?? 'Belgie en Belgische Noordzee'}

-

{activeWorkspaceItem.label}

-
- -
-

{activeWorkspaceItem.description}

- {activeWorkspace !== 'overview' ? ( - - ) : null} -
-
: null} - {activeWorkspace === 'overview' ? ( - - ) : null} - - {activeWorkspace === 'data' ? ( -
- - {isGuest ? ( -
-
- ) : <> - - - - - setInspectorOpen(true)} - onRefreshMetadata={refreshMetadata} - onOpenDatasetInMap={openDatasetInMap} - onOpenDatasetExport={openDatasetExport} - /> - } -
- ) : null} - - - - {activeWorkspace === 'analysis' ? ( -
- loadQualityChecks()} - onOpenEvidenceMap={openQualityEvidenceOnMap} - evidenceLoading={qualityEvidenceLoading} - evidenceError={qualityEvidenceError} - onOpenMapWorkspace={() => setActiveWorkspace('map')} - onOpenAnalysisWorkspace={() => setActiveWorkspace('ai')} - /> -
- - Historische vectorlagen vergelijken - Geavanceerd - - -
-
- ) : null} - - {activeWorkspace === 'assistant' ? ( -
- -
- ) : null} - - {activeWorkspace === 'ai' ? ( -
- loadYoloPreflight()} - onSelectDataset={setSelectedDetectionDatasetId} - onSelectModel={setSelectedDetectionModelId} - onSelectModelAsset={setSelectedModelAssetId} - onSetConfidenceThreshold={setDetectionConfidenceThreshold} - onSetTileManifestPath={setDetectionTileManifestPath} - onRunDetection={runDetection} - onUploadRaster={uploadDetectionRaster} - onPrepareAndRunDetection={runGuidedDetection} - onOpenResultsOnMap={openDetectionResultsOnMap} - onLoadRuns={() => loadDetectionRuns()} - onSelectRun={setSelectedDetectionRunId} - onSetClassFilter={setDetectionClassFilter} - onSetMinConfidenceFilter={setDetectionMinConfidenceFilter} - onLoadResults={() => loadDetectionResults()} - onSelectReferenceDataset={setDetectionReferenceDatasetId} - onRunQa={runDetectionQa} - onSetCalibrationThresholdText={setCalibrationThresholdText} - onRunCalibration={runDetectionCalibration} - onOpenCalibrationEvidence={openQualityEvidenceOnMap} - onApplyOperatorProfile={applyDetectionOperatorProfile} - /> - -
- - Segmentatie van beeldvlakken - Nog niet geconfigureerd - - loadSegmentationRuns()} - onSelectRun={setSelectedSegmentationRunId} - onSetClassFilter={setSegmentationClassFilter} - onSetMinConfidenceFilter={setSegmentationMinConfidenceFilter} - onLoadResults={() => loadSegmentationResults()} - onSelectReferenceDataset={setSegmentationReferenceDatasetId} - onRunQa={runSegmentationQa} - /> -
-
- ) : null} - - {activeWorkspace === 'exports' ? ( -
- loadExports()} - onExportDataset={exportSelectedDatasetGeoJson} - onExportDetectionRun={exportSelectedDetectionRunGeoJson} - onExportSegmentationRun={exportSelectedSegmentationRunGeoJson} - onExportProjectMetadata={exportProjectMetadata} - onExportProjectReport={exportProjectReport} - onPreviewContent={previewExportContent} - onDownload={downloadExportArtifact} - /> - -
- ) : null} - - {activeWorkspace === 'system' ? ( - setActiveWorkspace('data')} - onOpenStatus={() => setActiveWorkspace('overview')} - onOpenMap={() => setActiveWorkspace('map')} - /> - ) : null} -
- - {inspectorOpen ? ( - - ) : null} -
-
-
+
+
) } @@ -1458,11 +18,7 @@ function App(): JSX.Element { const { session, sessionError, loggingOut, handleAuthenticated, handleLogout } = useOperatorSession() if (session === null) { - return ( -
-
-
- ) + return } if (!session.authenticated) { @@ -1476,12 +32,14 @@ function App(): JSX.Element { } return ( - + }> + + ) } diff --git a/frontend/src/WorkbenchApp.tsx b/frontend/src/WorkbenchApp.tsx new file mode 100644 index 00000000..438ae313 --- /dev/null +++ b/frontend/src/WorkbenchApp.tsx @@ -0,0 +1,1528 @@ +/** + * De werkbank, apart van App. + * + * Dit stond in App.tsx, waardoor de kaartmotor (maplibre, 213 kB over de lijn) + * en alle paneelopmaak in de eerste bundel zaten en dus door iedereen werden + * gedownload op het inlogscherm, waar geen kaart staat. Als eigen module wordt + * het een aparte brok die pas na aanmelden geladen wordt. + * + * Om dezelfde reden staan de stylesheets van de werkbank hier en niet in + * main.tsx: ook die horen bij deze brok. + */ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { CircleAlert, LogOut, MonitorUp, Moon, ShieldCheck, Sun, UserRound } from 'lucide-react' +import './styles/app.css' +import './styles/premium.css' +import './styles/atlas-workbench.css' +import './styles/atlas-premium-v2.css' +import './styles/professionalization.css' +// Laatst geladen en leidend: het geconsolideerde designsysteem. +// Zie de kop van geointel-system.css voor de reden. +import './styles/geointel-system.css' +import { ChangeDetectionPanel } from './components/analysis/ChangeDetectionPanel' +import { GeoAssistantPanel } from './components/assistant/GeoAssistantPanel' +import { DatasetPanel } from './components/datasets/DatasetPanel' +import { SourceCatalogPanel } from './components/datasets/SourceCatalogPanel' +import { DetectionLab } from './components/detection/DetectionLab' +import { ExportCenter } from './components/exports/ExportCenter' +import { ExportPreview } from './components/exports/ExportPreview' +import { WorkbenchInspector } from './components/inspector/WorkbenchInspector' +import { MapWorkspace } from './components/map/MapWorkspace' +import { OverviewWorkspace, type WorkspaceKey } from './components/overview/OverviewWorkspace' +import { AreaPanel } from './components/project/AreaPanel' +import { ProjectPanel } from './components/project/ProjectPanel' +import { QualityResultsPanel } from './components/quality/QualityResultsPanel' +import type { DatasetCreateResponse } from './types' +import { ProviderPanel } from './components/providers/ProviderPanel' +import { SegmentationLab } from './components/segmentation/SegmentationLab' +import { + WorkbenchNavigation, + type WorkspaceNavigationGroup, + type WorkspaceNavigationItem, +} from './components/shell/WorkbenchNavigation' +import { WorkspaceSignal } from './components/shell/WorkspaceSignal' +import { WorkspaceErrorBoundary } from './components/shell/WorkspaceErrorBoundary' +import { useSecondaryDisplay } from './components/shell/SecondaryDisplay' +import { useChangeDetectionWorkflow } from './hooks/useChangeDetectionWorkflow' +import { useDemoWorkflow } from './hooks/useDemoWorkflow' +import { useCoverageResolver } from './hooks/useCoverageResolver' +import { useDetectionWorkflow } from './hooks/useDetectionWorkflow' +import { useDatasetWorkflow } from './hooks/useDatasetWorkflow' +import { useExportWorkflow } from './hooks/useExportWorkflow' +import { useMapSelectionDataset } from './hooks/useMapSelectionDataset' +import { useMapSelectionQa } from './hooks/useMapSelectionQa' +import { useMapWorkspaceState } from './hooks/useMapWorkspaceState' +import { useOperatorSession } from './hooks/useOperatorSession' +import { useMapSelectionExtract } from './hooks/useMapSelectionExtract' +import { useMapOrthophotoAnalysis } from './hooks/useMapOrthophotoAnalysis' +import { isDetectionImageryDataset, isMapRasterDataset } from './lib/datasetCapabilities' +import { useProviderCapabilities } from './hooks/useProviderCapabilities' +import { useProjectWorkspace } from './hooks/useProjectWorkspace' +import { useQualityWorkflow } from './hooks/useQualityWorkflow' +import { useSegmentationWorkflow } from './hooks/useSegmentationWorkflow' +import { useSourceFreshness } from './hooks/useSourceFreshness' +import { useWorkbenchBootstrap } from './hooks/useWorkbenchBootstrap' +import { useWorkbenchTheme } from './hooks/useWorkbenchTheme' +import { useViewportVectorLayer } from './hooks/useViewportVectorLayer' +import { getDatasetDisplayName } from './lib/datasetDisplay' +import { + FLANDERS_WORKSPACE_LABEL, + FLANDERS_WORKSPACE_PROJECT_NAME, + NATIONAL_WORKSPACE_LABEL, + NATIONAL_WORKSPACE_PROJECT_NAME, + REGIONAL_WORKSPACE_LABEL, + REGIONAL_WORKSPACE_PROJECT_NAME, +} from './config/primaryFocus' + +function isVectorDatasetType(datasetType: string): boolean { + return datasetType === 'vector' || datasetType === 'geojson' +} + +const workspaceNavItems: WorkspaceNavigationItem[] = [ + { key: 'overview', label: 'Status', description: 'Beschikbaarheid en aandachtspunten' }, + { key: 'data', label: 'Bronnen', description: 'Gebieden en ingeladen gegevens' }, + { key: 'map', label: 'Kaart', description: 'Selecteren, uitlezen en vergelijken' }, + { key: 'assistant', label: 'AI-vragen', description: 'Vraag de lokale assistent over het actieve gebied' }, + { key: 'analysis', label: 'Kwaliteit', description: 'Resultaten controleren' }, + { key: 'ai', label: 'Beeldanalyse', navigationLabel: 'AI-beeld', description: 'Gebouwen herkennen op luchtbeelden' }, + { key: 'exports', label: 'Downloads', navigationLabel: 'Export', description: 'Resultaten bewaren en delen' }, + { key: 'system', label: 'Systeem', description: 'Bronkoppelingen en operationele status' }, +] + +const workspaceNavGroups: WorkspaceNavigationGroup[] = [ + { label: 'Verkennen', keys: ['map', 'data'] }, + { label: 'Vragen', keys: ['assistant'] }, + { label: 'Analyseren', keys: ['analysis', 'ai'] }, + { label: 'Afronden', keys: ['exports'] }, + { label: 'Beheer', keys: ['overview', 'system'] }, +] + +const guestWorkspaceKeys = new Set([ + 'overview', + 'data', + 'map', + 'assistant', + 'analysis', + 'ai', + 'exports', +]) +const guestWorkspaceGroups: WorkspaceNavigationGroup[] = [ + { label: 'Verkennen', keys: ['map', 'data'] }, + { label: 'Vragen', keys: ['assistant'] }, + { label: 'Analyseren', keys: ['analysis', 'ai'] }, + { label: 'Afronden', keys: ['exports'] }, + { label: 'Demo', keys: ['overview'] }, +] + +interface WorkbenchAppProps { + username: string | null + accessMode: 'open' | 'operator' | 'guest' + loggingOut: boolean + onLogout: () => void +} + +function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchAppProps): JSX.Element { + const isGuest = accessMode === 'guest' + const [activeWorkspace, setActiveWorkspace] = useState('map') + const [inspectorOpen, setInspectorOpen] = useState(false) + const [guestDemoReady, setGuestDemoReady] = useState(!isGuest) + const secondaryDisplay = useSecondaryDisplay() + const { theme, toggleTheme } = useWorkbenchTheme() + // Welke onderdelen niet geladen konden worden. Zonder dit zag de gebruiker + // een leeg paneel, ongeacht of er niets is of de verbinding wegviel. + const [laadfouten, setLaadfouten] = useState([]) + const meldLaadfout = useCallback((onderdeel: string) => { + setLaadfouten((huidig) => (huidig.includes(onderdeel) ? huidig : [...huidig, onderdeel])) + }, []) + const guestDemoStartedRef = useRef(false) + const visibleWorkspaceItems = useMemo( + () => isGuest ? workspaceNavItems.filter((item) => guestWorkspaceKeys.has(item.key)) : workspaceNavItems, + [isGuest], + ) + const visibleWorkspaceGroups = isGuest ? guestWorkspaceGroups : workspaceNavGroups + const [mapContentMode, setMapContentMode] = useState<'dataset' | 'analysis'>('dataset') + const [mapContextSourceLabel, setMapContextSourceLabel] = useState(null) + const [mapContextLayerLabel, setMapContextLayerLabel] = useState(null) + const workbenchMainRef = useRef(null) + const previousWorkspaceRef = useRef(activeWorkspace) + // De werkbank draait donker, de landingspagina licht. Zie de sectie + // "Operationeel donker" in geointel-system.css. + useEffect(() => { + document.body.classList.add('workbench-body') + return () => document.body.classList.remove('workbench-body') + }, []) + useEffect(() => { + if (isGuest && !guestWorkspaceKeys.has(activeWorkspace)) { + setActiveWorkspace('map') + } + }, [activeWorkspace, isGuest]) + useEffect(() => { + workbenchMainRef.current?.scrollTo({ top: 0, left: 0 }) + if (previousWorkspaceRef.current !== activeWorkspace) { + workbenchMainRef.current?.focus({ preventScroll: true }) + } + previousWorkspaceRef.current = activeWorkspace + setInspectorOpen(false) + }, [activeWorkspace]) + const { + projects, + selectedProject, + selectedProjectId, + areas, + datasets, + loadingProjects, + loadingAreas, + loadingDatasets, + archivingProjectId, + errorMessage, + projectForm, + areaForm, + loadProjects, + loadProjectData, + createProject, + createArea, + activateMunicipality, + archiveProject, + resetProjectData, + setSelectedProjectId, + setErrorMessage, + setProjectForm, + setAreaForm, + } = useProjectWorkspace() + const sourceFreshness = useSourceFreshness(selectedProjectId) + const selectProject = (projectId: string) => { + setMapContentMode('dataset') + setSelectedProjectId(projectId) + } + const { + selectedDatasetId, + selectedDataset, + selectedDatasetSummary, + selectedRasterMetadata, + selectedRasterStats, + datasetContent, + jobs, + rasterPreview, + latestRasterTileManifestPath, + latestRasterTileManifest, + selectedIntersectTargetId, + selectedClipAreaId, + rasterTileSize, + rasterTileOverlap, + rasterTileOutputName, + rasterReprojectCrs, + rasterReprojectResampling, + ndviNirBand, + ndviRedBand, + ndwiGreenBand, + ndwiNirBand, + ndbiSwirBand, + ndbiNirBand, + loadingDatasetDetails, + datasetDetailError, + datasetForm, + isRasterTileInputValid, + loadDatasetJobs, + loadDatasetDetails, + uploadDataset, + runVectorClip, + runVectorBuffer, + runVectorIntersect, + runRasterInspect, + runRasterPreview, + runRasterStats, + runRasterReproject, + runRasterClip, + runRasterTile, + runRasterNdvi, + runRasterNdwi, + runRasterNdbi, + pickDerivedDataset, + refreshMetadata, + resetDatasetForProject, + setSelectedDatasetId, + setSelectedIntersectTargetId, + setSelectedClipAreaId, + setRasterTileSize, + setRasterTileOverlap, + setRasterTileOutputName, + setRasterReprojectCrs, + setRasterReprojectResampling, + setNdviNirBand, + setNdviRedBand, + setNdwiGreenBand, + setNdwiNirBand, + setNdbiSwirBand, + setNdbiNirBand, + setDatasetForm, + } = useDatasetWorkflow({ + selectedProjectId, + areas, + datasets, + loadProjectData, + setErrorMessage, + isVectorDatasetType, + }) + const rasterUnavailableMessage = useMemo(() => { + if (!selectedDataset || selectedDataset.dataset_type !== 'raster' || !selectedDataset.metadata_json) { + return null + } + const metadata = selectedDataset.metadata_json as Record + const processingCode = metadata['processing_code'] + if (processingCode === 'RASTER_PROCESSING_UNAVAILABLE') { + return String(metadata['processing_error'] ?? 'Raster processing unavailable.') + } + return null + }, [selectedDataset]) + + const availableVectorTargets = useMemo( + () => datasets.filter((item) => item.id !== selectedDatasetId && isVectorDatasetType(item.dataset_type)), + [datasets, selectedDatasetId], + ) + const availableVectorDatasets = useMemo(() => datasets.filter((item) => isVectorDatasetType(item.dataset_type)), [datasets]) + const availableMapDatasets = useMemo( + () => { + const vectors = datasets.filter((dataset) => isVectorDatasetType(dataset.dataset_type) && dataset.status === 'ready') + const rasters = datasets.filter(isMapRasterDataset) + return [...vectors, ...rasters] + }, + [datasets], + ) + const referenceDatasets = useMemo( + () => availableVectorDatasets.filter((item) => item.dataset_role === 'reference'), + [availableVectorDatasets], + ) + const candidateDatasets = useMemo( + () => availableVectorDatasets.filter((item) => item.dataset_role !== 'reference'), + [availableVectorDatasets], + ) + const rasterDatasets = useMemo(() => datasets.filter((item) => item.dataset_type === 'raster'), [datasets]) + const detectionRasterDatasets = useMemo( + () => rasterDatasets.filter(isDetectionImageryDataset), + [rasterDatasets], + ) + const { + providers, + loadingCapabilities, + capabilitiesError, + loadCapabilities, + } = useProviderCapabilities() + const { + changeSourceDatasetId, + changeTargetDatasetId, + changeIouThreshold, + changeIncludeUnchanged, + runningChangeDetection, + changeDetectionResult, + changeDetectionError, + runChangeDetection, + setChangeSourceDatasetId, + setChangeTargetDatasetId, + setChangeIouThreshold, + setChangeIncludeUnchanged, + } = useChangeDetectionWorkflow({ + selectedProjectId, + availableVectorDatasets, + loadDatasetJobs, + // Bind the comparison to whatever the operator has selected on the map. + getSelection: () => ({ bbox: mapSelectionBbox ?? null, areaId: selectedMapAreaId ?? null }), + }) + const { + qaCandidateDatasetId, + qaReferenceDatasetId, + qaAreaId, + qaIouThreshold, + qaRunning, + qaResult, + qaError, + qualityChecks, + qualityChecksError, + qualityEvidenceGeoJson, + qualityEvidenceLoading, + qualityEvidenceError, + loadQualityChecks, + loadQualityEvidenceGeoJson, + clearQualityEvidenceGeoJson, + runQaComparison, + setQaCandidateDatasetId, + setQaReferenceDatasetId, + setQaAreaId, + setQaIouThreshold, + } = useQualityWorkflow({ + selectedProjectId, + loadProjectData, + }) + const { + detectionModels, + modelAssets, + loadingDetectionModels, + detectionModelError, + modelAssetError, + selectedDetectionDatasetId, + selectedDetectionModelId, + selectedModelAssetId, + detectionTileManifestPath, + detectionConfidenceThreshold, + runningDetection, + detectionJob, + detectionRunResult, + detectionRunError, + detectionRuns, + selectedDetectionRunId, + detectionItems, + detectionTotal, + detectionTruncated, + detectionGeoJson, + detectionClassFilter, + detectionMinConfidenceFilter, + loadingDetectionResults, + detectionReferenceDatasetId, + detectionQaResult, + detectionQaError, + runningDetectionQa, + yoloPreflight, + loadingYoloPreflight, + yoloPreflightError, + calibrationThresholdText, + runningDetectionCalibration, + detectionCalibrationRows, + detectionCalibrationError, + detectionWorkflowStage, + loadDetectionModels, + loadYoloPreflight, + loadDetectionRuns, + loadDetectionResults, + runDetection, + uploadDetectionRaster, + prepareAndRunDetection, + compareDetectionRunWithReference, + runDetectionQa, + runDetectionCalibration, + applyDetectionOperatorProfile, + resetDetectionForProject, + setSelectedDetectionDatasetId, + setSelectedDetectionModelId, + setSelectedModelAssetId, + setDetectionTileManifestPath, + setDetectionConfidenceThreshold, + setSelectedDetectionRunId, + setDetectionClassFilter, + setDetectionMinConfidenceFilter, + setDetectionReferenceDatasetId, + setCalibrationThresholdText, + } = useDetectionWorkflow({ + selectedProjectId, + rasterDatasets: detectionRasterDatasets, + qaIouThreshold, + loadProjectData, + loadQualityChecks, + }) + const useRasterTileManifestForDetection = () => { + const manifestPath = latestRasterTileManifestPath.trim() + if (!manifestPath || !selectedDataset || !isDetectionImageryDataset(selectedDataset)) { + return + } + setDetectionTileManifestPath(manifestPath) + setSelectedDetectionDatasetId(selectedDataset.id) + setSelectedDetectionModelId('yolo-configured') + setDetectionConfidenceThreshold(0.25) + loadYoloPreflight(manifestPath).catch(() => meldLaadfout('modelcontrole')) + setActiveWorkspace('ai') + } + const { + segmentationModels, + loadingSegmentationModels, + segmentationModelError, + selectedSegmentationDatasetId, + selectedSegmentationModelId, + selectedSegmentationModel, + segmentationTileManifestPath, + segmentationConfidenceThreshold, + runningSegmentation, + segmentationJob, + segmentationRunResult, + segmentationRunError, + segmentationRuns, + selectedSegmentationRunId, + segmentationItems, + segmentationTotal, + segmentationTruncated, + segmentationGeoJson, + segmentationClassFilter, + segmentationMinConfidenceFilter, + loadingSegmentationResults, + segmentationReferenceDatasetId, + segmentationQaResult, + segmentationQaError, + runningSegmentationQa, + loadSegmentationModels, + loadSegmentationRuns, + loadSegmentationResults, + runSegmentation, + runSegmentationQa, + resetSegmentationForProject, + setSelectedSegmentationDatasetId, + setSelectedSegmentationModelId, + setSegmentationTileManifestPath, + setSegmentationConfidenceThreshold, + setSelectedSegmentationRunId, + setSegmentationClassFilter, + setSegmentationMinConfidenceFilter, + setSegmentationReferenceDatasetId, + } = useSegmentationWorkflow({ + selectedProjectId, + rasterDatasets, + qaIouThreshold, + loadProjectData, + loadQualityChecks, + }) + const viewportVectorLayer = useViewportVectorLayer({ + selectedProjectId, + selectedDataset, + featureCount: selectedDatasetSummary?.feature_count ?? selectedDataset?.feature_count ?? null, + isVectorDatasetType, + }) + const analysisMapLayerAvailable = Boolean(changeDetectionResult?.geojson || segmentationGeoJson || detectionGeoJson) + const analysisMapLayerActive = mapContentMode === 'analysis' && analysisMapLayerAvailable + const viewportVectorLayerActive = viewportVectorLayer.enabled && !analysisMapLayerActive + const datasetMapContent = viewportVectorLayer.enabled ? viewportVectorLayer.data : datasetContent + const useRasterTileManifestForSegmentation = () => { + const manifestPath = latestRasterTileManifestPath.trim() + if (!manifestPath || !selectedDataset || selectedDataset.dataset_type !== 'raster') { + return + } + setSegmentationTileManifestPath(manifestPath) + setSelectedSegmentationDatasetId(selectedDataset.id) + setActiveWorkspace('ai') + } + const { + exports, + latestExport, + exportError, + loadingExports, + exporting, + selectionExporting, + selectionExportError, + latestSelectionExport, + exportPreview, + loadExports, + exportSelectedDatasetGeoJson, + exportSelectedDetectionRunGeoJson, + exportSelectedSegmentationRunGeoJson, + exportMapSelectionGeoJson, + persistMapResult, + exportProjectMetadata, + exportProjectReport, + previewExportContent, + downloadExportArtifact, + resetExportsForProject, + } = useExportWorkflow({ + selectedProjectId, + selectedDataset, + selectedDetectionRunId, + selectedSegmentationRunId, + isVectorDatasetType, + }) + const { + mapLayerVisible, + mapLayerOpacity, + selectedMapAreaId, + areaLayerVisible, + areaLayerOpacity, + selectedMapFeature, + areaFeatureCollection, + mapFeatureCollection, + mapLayerLabel, + mapFeatureCount, + areaFeatureCount, + setMapLayerVisible, + setMapLayerOpacity, + setSelectedMapAreaId, + setAreaLayerVisible, + setAreaLayerOpacity, + setSelectedMapFeature, + } = useMapWorkspaceState({ + areas, + changeDetectionGeoJson: analysisMapLayerActive ? changeDetectionResult?.geojson ?? null : null, + segmentationGeoJson: analysisMapLayerActive ? segmentationGeoJson : null, + detectionGeoJson: analysisMapLayerActive ? detectionGeoJson : null, + datasetContent: datasetMapContent, + selectedDataset, + datasetLayerActive: Boolean(selectedDataset && isVectorDatasetType(selectedDataset.dataset_type)), + }) + const { + mapSelectionBbox, + mapSelectionResult, + mapSelectionLoading, + mapSelectionError, + runMapSelectionExtract, + resetMapSelectionExtract, + setMapSelectionBbox, + } = useMapSelectionExtract({ + selectedProjectId, + selectedDataset, + isVectorDatasetType, + }) + const { + coverage: mapCoverage, + loadingCoverage: mapCoverageLoading, + coverageError: mapCoverageError, + coverageDurationMs: mapCoverageDurationMs, + coverageBudgetExceeded: mapCoverageBudgetExceeded, + } = useCoverageResolver({ + projectId: selectedProjectId, + bbox: mapSelectionBbox, + }) + const { + selectionDatasetSaving, + selectionDatasetError, + latestSelectionDataset, + deriveMapSelectionDataset, + } = useMapSelectionDataset({ + selectedProjectId, + selectedDataset, + isVectorDatasetType, + loadProjectData, + loadDatasetDetails, + setMapLayerVisible, + }) + const { + selectedMapQaReferenceDatasetId, + mapSelectionQaRunning, + mapSelectionQaError, + mapSelectionQaResult, + latestMapSelectionQualityCheckId, + runMapSelectionQa, + setSelectedMapQaReferenceDatasetId, + } = useMapSelectionQa({ + selectedProjectId, + latestSelectionDataset, + loadQualityChecks, + loadProjectData, + }) + const mapOrthophotoAnalysis = useMapOrthophotoAnalysis({ + selectedProjectId, + selectedAreaId: areas.find((area) => /vervoerregio|operationele grens/i.test(area.name))?.id ?? selectedMapAreaId, + selectionBbox: mapSelectionBbox, + datasets, + loadProjectData, + prepareAndRunDetection: (datasetId) => prepareAndRunDetection(datasetId, 'yolo-configured'), + compareDetectionRunWithReference: (analysisRunId, referenceDatasetId, iouThreshold) => + compareDetectionRunWithReference(analysisRunId, referenceDatasetId, false, iouThreshold), + loadQualityEvidence: (qualityCheckId) => loadQualityEvidenceGeoJson(qualityCheckId), + onAnalysisReady: () => { + setMapContentMode('analysis') + setMapLayerVisible(true) + setActiveWorkspace('map') + }, + }) + const openMapSelectionQualityEvidence = () => { + setActiveWorkspace('analysis') + } + const openQualityEvidenceOnMap = async (qualityCheckId: string) => { + const result = await loadQualityEvidenceGeoJson(qualityCheckId) + if (result) { + setMapLayerVisible(true) + setActiveWorkspace('map') + } + } + const { + loadingDemoWorkflow, + demoWorkflowMessage, + loadDemoWorkflow, + } = useDemoWorkflow({ + restrictedMode: false, + loadProjects, + loadProjectData, + loadDatasetDetails, + loadDetectionRuns, + loadSegmentationRuns, + loadQualityChecks, + loadExports, + setSelectedProjectId: selectProject, + setSelectedDatasetId, + setSelectedMapAreaId, + setQaCandidateDatasetId, + setQaReferenceDatasetId, + setQaAreaId, + setSelectedDetectionDatasetId, + setDetectionReferenceDatasetId, + setSelectedSegmentationDatasetId, + setSegmentationReferenceDatasetId, + setErrorMessage, + }) + + useEffect(() => { + if (!isGuest || guestDemoStartedRef.current) return + guestDemoStartedRef.current = true + setGuestDemoReady(false) + void loadDemoWorkflow().finally(() => setGuestDemoReady(true)) + }, [isGuest, loadDemoWorkflow]) + + useWorkbenchBootstrap({ + restrictedMode: false, + activeWorkspace, + onLoadError: meldLaadfout, + selectedProjectId, + selectedDetectionRunId, + detectionClassFilter, + detectionMinConfidenceFilter, + selectedSegmentationRunId, + segmentationClassFilter, + segmentationMinConfidenceFilter, + loadProjects, + loadCapabilities, + loadDetectionModels, + loadSegmentationModels, + loadProjectData, + loadDetectionRuns, + loadSegmentationRuns, + loadQualityChecks, + loadExports, + loadDetectionResults, + loadSegmentationResults, + resetProjectData, + resetDatasetForProject, + resetDetectionForProject, + resetSegmentationForProject, + resetExportsForProject, + }) + + const selectedArea = areas.find((area) => area.id === selectedMapAreaId) ?? null + const activeWorkspaceItem = visibleWorkspaceItems.find((item) => item.key === activeWorkspace) ?? visibleWorkspaceItems[0] + const mapLayerSourceLabel = useMemo(() => { + if (analysisMapLayerActive && changeDetectionResult?.geojson) { + return 'Veranderingsanalyse' + } + if (analysisMapLayerActive && segmentationGeoJson) { + return 'Segmentatierun' + } + if (analysisMapLayerActive && detectionGeoJson) { + return 'Detectierun' + } + if ((datasetMapContent || viewportVectorLayer.enabled) && selectedDataset) { + return selectedDataset.dataset_type === 'raster' ? 'Rasterdatabron' : 'Vectordatabron' + } + return 'Geen actieve gegevens- of analyselaag' + }, [analysisMapLayerActive, changeDetectionResult?.geojson, datasetMapContent, detectionGeoJson, segmentationGeoJson, selectedDataset, viewportVectorLayer.enabled]) + const mapLayerProvenance = useMemo(() => { + if (analysisMapLayerActive && changeDetectionResult?.geojson) { + return 'Vergelijking van twee bewaarde kaartlagen' + } + if (analysisMapLayerActive && segmentationGeoJson) { + return selectedSegmentationRunId ? 'Bewaarde segmentatieronde' : 'Segmentatieresultaten geladen' + } + if (analysisMapLayerActive && detectionGeoJson) { + return selectedDetectionRunId ? 'Bewaarde detectieronde' : 'Detectieresultaten geladen' + } + if ((datasetMapContent || viewportVectorLayer.enabled) && selectedDataset) { + return `Bewaarde ${selectedDataset.dataset_role === 'reference' ? 'referentielaag' : 'databron'}` + } + return 'Open een databron, detectieronde, segmentatieronde of veranderingsresultaat om het hier te tekenen.' + }, [ + analysisMapLayerActive, + changeDetectionResult?.geojson, + changeSourceDatasetId, + changeTargetDatasetId, + datasetMapContent, + detectionGeoJson, + segmentationGeoJson, + selectedDataset, + selectedDetectionRunId, + selectedSegmentationRunId, + viewportVectorLayer.enabled, + ]) + const openDatasetInMap = (dataset: DatasetCreateResponse) => { + if (selectedProjectId) { + loadDatasetDetails(selectedProjectId, dataset) + } + setMapContentMode('dataset') + setMapLayerVisible(true) + setActiveWorkspace('map') + } + const runGuidedDetection = async () => { + const completed = await prepareAndRunDetection() + if (completed) { + setMapContentMode('analysis') + setMapLayerVisible(true) + setActiveWorkspace('map') + } + } + const openDetectionResultsOnMap = () => { + if (!detectionGeoJson) { + return + } + setMapContentMode('analysis') + setMapLayerVisible(true) + setActiveWorkspace('map') + } + const openDatasetExport = (dataset: DatasetCreateResponse) => { + if (selectedProjectId) { + loadDatasetDetails(selectedProjectId, dataset) + } + setActiveWorkspace('exports') + } + const openWorkflowGuidanceStep = (target: WorkspaceKey) => { + if (isGuest && !guestWorkspaceKeys.has(target)) { + setActiveWorkspace('map') + return + } + if (target === 'map' && availableMapDatasets.length > 0 && !mapFeatureCollection) { + openDatasetInMap(availableMapDatasets[0]) + return + } + if (target === 'exports' && availableMapDatasets.length > 0 && !selectedDataset) { + openDatasetExport(availableMapDatasets[0]) + return + } + setActiveWorkspace(target) + } + const hasAnalysisOutput = + qualityChecks.length > 0 + || Boolean(changeDetectionResult) + || detectionItems.length > 0 + || segmentationItems.length > 0 + const projectContextLabel = selectedProject?.name === NATIONAL_WORKSPACE_PROJECT_NAME + ? NATIONAL_WORKSPACE_LABEL + : selectedProject?.name === REGIONAL_WORKSPACE_PROJECT_NAME + ? REGIONAL_WORKSPACE_LABEL + : selectedProject?.name === FLANDERS_WORKSPACE_PROJECT_NAME + ? FLANDERS_WORKSPACE_LABEL + : selectedProject?.name ?? 'Geen werkruimte' + const areaContextLabel = selectedArea?.name ?? (areas.length > 0 ? 'Kies een gebied' : 'Geen gebied') + const bathymetryProfileContextActive = Boolean( + activeWorkspace === 'map' + && selectedDataset?.source_name === 'vmm_vha_bathymetry_profiles', + ) + const bathymetryRasterContextActive = Boolean( + activeWorkspace === 'map' + && selectedDataset?.source_name === 'spw_bathymetry', + ) + const bathymetryContextActive = bathymetryProfileContextActive || bathymetryRasterContextActive + const regionalBathymetryContextActive = Boolean( + bathymetryProfileContextActive + && selectedDataset?.source_metadata?.regional_partitions_complete === true + && selectedArea + && !/^Gemeente\s/i.test(selectedArea.name), + ) + const regionalBathymetryManifest = String( + selectedDataset?.source_metadata?.partition_manifest_sha256 ?? '', + ) + const regionalBathymetryPartitions = regionalBathymetryContextActive + ? availableMapDatasets.filter( + (dataset) => + dataset.source_name === 'vmm_vha_bathymetry_profiles' + && dataset.source_metadata?.regional_partitions_complete === true + && String(dataset.source_metadata?.partition_manifest_sha256 ?? '') === regionalBathymetryManifest, + ) + : [] + const regionalBathymetryProfileCount = regionalBathymetryPartitions.reduce( + (total, dataset) => total + (dataset.feature_count ?? dataset.vector_summary?.feature_count ?? 0), + 0, + ) + const bathymetryProfileCount = regionalBathymetryContextActive + ? regionalBathymetryProfileCount + : selectedDataset?.feature_count ?? selectedDataset?.vector_summary?.feature_count ?? 0 + const workspaceDataLoading = loadingProjects || loadingAreas || loadingDatasets + const workspaceStatusMessage = errorMessage + ? `Werkruimtefout: ${errorMessage}` + : workspaceDataLoading + ? 'Werkruimte en databronnen worden geladen.' + : selectedProject + ? `${projectContextLabel} is geladen met ${datasets.length} databronnen en ${areas.length} gebieden.` + : 'Geen werkruimte geselecteerd.' + const datasetContextLabel = activeWorkspace === 'map' && mapContextSourceLabel + ? mapContextSourceLabel + : analysisMapLayerActive && mapFeatureCollection + ? `${mapLayerLabel} · controle vereist` + : selectedDataset + ? getDatasetDisplayName(selectedDataset) + : workspaceDataLoading + ? 'Bronnen worden geladen' + : datasets.length > 0 + ? 'Kies een bron' + : 'Geen bron' + const layerContextLabel = activeWorkspace === 'map' && mapContextLayerLabel + ? mapContextLayerLabel + : bathymetryContextActive + ? bathymetryRasterContextActive + ? `${Number(selectedDataset?.source_metadata?.analysis_resolution_m ?? 0.5).toLocaleString('nl-BE')} m raster · mDNG · ${String(selectedDataset?.source_metadata?.survey_period ?? '2019-2022')}` + : regionalBathymetryContextActive + ? `${bathymetryProfileCount.toLocaleString('nl-BE')} profielen · ${regionalBathymetryPartitions.length} gemeenten` + : `${bathymetryProfileCount.toLocaleString('nl-BE')} profielen` + : mapFeatureCollection + ? `${mapFeatureCount.toLocaleString('nl-BE')} objecten` + : workspaceDataLoading + ? 'Kaart wordt voorbereid' + : viewportVectorLayerActive + ? 'Kaartlaag gekozen' + : activeWorkspace === 'map' && selectedDataset?.dataset_type === 'raster' + ? 'Rasterlaag actief' + : 'Geen actieve laag' + + return ( +
+ { + window.requestAnimationFrame(() => workbenchMainRef.current?.focus({ preventScroll: true })) + }} + > + Ga naar de werkruimte + + {/* De werkbank begon bij h2, dus schermlezers kregen geen paginatitel en + geen ankerpunt om vanaf te navigeren. Deze kop noemt waar je bent en + verandert mee met het werkblad. */} +

GeoIntel — {activeWorkspaceItem.label}

+

{workspaceStatusMessage}

+ {laadfouten.length > 0 ? ( +
+
+ ) : null} + {errorMessage && activeWorkspace !== 'map' ? ( +
+
+ ) : null} + +
+ + +
+
+
+ + GeoIntel +
+
+
+ Werkruimte + {projectContextLabel} +
+
+ Gebied + {areaContextLabel} +
+
+ Bron + {datasetContextLabel} +
+
+ Kaartlaag + {layerContextLabel} +
+
+
+
+ + + {username || isGuest ? ( +
+ {isGuest ?
+ ) : null} +
+ + {secondaryDisplay.error ? ( + + ) : null} + + {isGuest ? ( +
+
+ ) : null} + + {isGuest && (!guestDemoReady || loadingDemoWorkflow) ? ( +
+
+ ) : null} + +
+ + {/* Eén werkblad dat gooit mag niet de hele werkbank meenemen. */} + +
+ {activeWorkspace !== 'map' ?
+
+

{selectedProject?.region ?? 'Belgie en Belgische Noordzee'}

+

{activeWorkspaceItem.label}

+
+ +
+

{activeWorkspaceItem.description}

+ {activeWorkspace !== 'overview' ? ( + + ) : null} +
+
: null} + {activeWorkspace === 'overview' ? ( + + ) : null} + + {activeWorkspace === 'data' ? ( +
+ + {isGuest ? ( +
+
+ ) : <> + + + + + setInspectorOpen(true)} + onRefreshMetadata={refreshMetadata} + onOpenDatasetInMap={openDatasetInMap} + onOpenDatasetExport={openDatasetExport} + /> + } +
+ ) : null} + + + + {activeWorkspace === 'analysis' ? ( +
+ loadQualityChecks()} + onOpenEvidenceMap={openQualityEvidenceOnMap} + evidenceLoading={qualityEvidenceLoading} + evidenceError={qualityEvidenceError} + onOpenMapWorkspace={() => setActiveWorkspace('map')} + onOpenAnalysisWorkspace={() => setActiveWorkspace('ai')} + /> +
+ + Historische vectorlagen vergelijken + Geavanceerd + + +
+
+ ) : null} + + {activeWorkspace === 'assistant' ? ( +
+ +
+ ) : null} + + {activeWorkspace === 'ai' ? ( +
+ loadYoloPreflight()} + onSelectDataset={setSelectedDetectionDatasetId} + onSelectModel={setSelectedDetectionModelId} + onSelectModelAsset={setSelectedModelAssetId} + onSetConfidenceThreshold={setDetectionConfidenceThreshold} + onSetTileManifestPath={setDetectionTileManifestPath} + onRunDetection={runDetection} + onUploadRaster={uploadDetectionRaster} + onPrepareAndRunDetection={runGuidedDetection} + onOpenResultsOnMap={openDetectionResultsOnMap} + onLoadRuns={() => loadDetectionRuns()} + onSelectRun={setSelectedDetectionRunId} + onSetClassFilter={setDetectionClassFilter} + onSetMinConfidenceFilter={setDetectionMinConfidenceFilter} + onLoadResults={() => loadDetectionResults()} + onSelectReferenceDataset={setDetectionReferenceDatasetId} + onRunQa={runDetectionQa} + onSetCalibrationThresholdText={setCalibrationThresholdText} + onRunCalibration={runDetectionCalibration} + onOpenCalibrationEvidence={openQualityEvidenceOnMap} + onApplyOperatorProfile={applyDetectionOperatorProfile} + /> + +
+ + Segmentatie van beeldvlakken + + {runningSegmentation + ? 'In uitvoering' + : selectedSegmentationModelId === 'fixture-segmenter' + ? 'Alleen test' + : selectedSegmentationModel?.configured + ? 'Beschikbaar' + : 'Niet geconfigureerd'} + + + loadSegmentationRuns()} + onSelectRun={setSelectedSegmentationRunId} + onSetClassFilter={setSegmentationClassFilter} + onSetMinConfidenceFilter={setSegmentationMinConfidenceFilter} + onLoadResults={() => loadSegmentationResults()} + onSelectReferenceDataset={setSegmentationReferenceDatasetId} + onRunQa={runSegmentationQa} + /> +
+
+ ) : null} + + {activeWorkspace === 'exports' ? ( +
+ loadExports()} + onExportDataset={exportSelectedDatasetGeoJson} + onExportDetectionRun={exportSelectedDetectionRunGeoJson} + onExportSegmentationRun={exportSelectedSegmentationRunGeoJson} + onExportProjectMetadata={exportProjectMetadata} + onExportProjectReport={exportProjectReport} + onPreviewContent={previewExportContent} + onDownload={downloadExportArtifact} + /> + +
+ ) : null} + + {activeWorkspace === 'system' ? ( + setActiveWorkspace('data')} + onOpenStatus={() => setActiveWorkspace('overview')} + onOpenMap={() => setActiveWorkspace('map')} + /> + ) : null} +
+
+ + {inspectorOpen ? ( + + ) : null} +
+
+
+
+ ) +} + +export default WorkbenchApp diff --git a/frontend/src/components/GeoMap.tsx b/frontend/src/components/GeoMap.tsx index 867064ef..3712be7c 100644 --- a/frontend/src/components/GeoMap.tsx +++ b/frontend/src/components/GeoMap.tsx @@ -5,6 +5,7 @@ import 'maplibre-gl/dist/maplibre-gl.css' import { NATIONAL_MAP_CENTER, NATIONAL_MAP_ZOOM } from '../config/primaryFocus' import { featureCollectionBounds } from '../lib/geojsonBounds' import type { MapImageOverlay, MapViewportState, VectorSelectionBBox } from '../types' +import { basemapGround, basemapPaint, huidigeWerkstand, mapSymbology } from './map/mapSymbology' interface GeoMapProps { data: GeoJSON.FeatureCollection | null @@ -46,10 +47,23 @@ const DEFAULT_ROAD_BASEMAP_STYLE: maplibregl.StyleSpecification = { }, }, layers: [ + { + // Grondtoon onder de tegels. Zonder deze laag flitst er wit tussen + // tegels die nog niet geladen zijn. + id: 'basemap-ground', + type: 'background', + paint: { 'background-color': basemapGround(huidigeWerkstand()) }, + }, { id: 'osm-standard', type: 'raster', source: 'osm-standard', + // De tegel wordt ontkleurd en gedempt tot een operationele ondergrond, + // zodat alleen de eigen data nog kleur draagt. Geen andere tegelbron en + // geen sleutel nodig; wie een echte vectorstijl heeft zet die via + // VITE_MAP_STYLE_URL en omzeilt dit blok volledig. Zie basemapPaint voor + // het verschil tussen de twee werkstanden. + paint: basemapPaint(huidigeWerkstand()), }, ], } @@ -58,18 +72,18 @@ function datasetFillColor(fallbackColor: string): ExpressionSpecification { return [ 'case', ['==', ['get', 'layer_type'], 'municipality_boundary'], - '#0f766e', + mapSymbology.boundary, [ 'match', ['get', 'change_type'], 'added', - '#16a34a', + mapSymbology.added, 'removed', - '#dc2626', + mapSymbology.removed, 'modified', - '#d97706', + mapSymbology.modified, 'unchanged', - '#2563eb', + mapSymbology.unchanged, fallbackColor, ], ] @@ -79,18 +93,18 @@ function datasetLineColor(fallbackColor: string): ExpressionSpecification { return [ 'case', ['==', ['get', 'layer_type'], 'municipality_boundary'], - '#0f5f59', + mapSymbology.boundaryStrong, [ 'match', ['get', 'change_type'], 'added', - '#15803d', + mapSymbology.added, 'removed', - '#b91c1c', + mapSymbology.removed, 'modified', - '#b45309', + mapSymbology.modified, 'unchanged', - '#1d4ed8', + mapSymbology.unchanged, fallbackColor, ], ] @@ -144,8 +158,8 @@ function bboxToFeatureCollection( function GeoMap({ data, - dataFillColor = '#f97316', - dataLineColor = '#ea580c', + dataFillColor = mapSymbology.dataFill, + dataLineColor = mapSymbology.dataLine, areaData = null, selectedFeature = null, selectionData = null, @@ -236,6 +250,27 @@ function GeoMap({ } }, [bboxSelectionMode]) + // De kaart wordt eenmalig opgebouwd, dus bij het wisselen van werkstand moet + // alleen de verf van de ondergrond mee. setPaintProperty laat alle datalagen + // ongemoeid; een volledige setStyle zou ze opnieuw moeten opbouwen. + useEffect(() => { + const pasAan = () => { + const map = mapRef.current + if (!map || !map.isStyleLoaded()) return + const werkstand = huidigeWerkstand() + if (!map.getLayer('osm-standard')) return + for (const [naam, waarde] of Object.entries(basemapPaint(werkstand))) { + map.setPaintProperty('osm-standard', naam as never, waarde as never) + } + if (map.getLayer('basemap-ground')) { + map.setPaintProperty('basemap-ground', 'background-color', basemapGround(werkstand)) + } + } + const waarnemer = new MutationObserver(pasAan) + waarnemer.observe(document.body, { attributes: true, attributeFilter: ['data-theme'] }) + return () => waarnemer.disconnect() + }, []) + useEffect(() => { if (!containerRef.current || mapRef.current) { return @@ -491,7 +526,7 @@ function GeoMap({ type: 'fill', source: 'area', paint: { - 'fill-color': '#0f766e', + 'fill-color': mapSymbology.boundary, 'fill-opacity': 0.18, }, }, @@ -503,7 +538,7 @@ function GeoMap({ type: 'line', source: 'area', paint: { - 'line-color': '#0f766e', + 'line-color': mapSymbology.boundary, 'line-width': 3, 'line-dasharray': [2, 1], }, @@ -576,7 +611,7 @@ function GeoMap({ source: 'selected-feature', filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon'], true, false], paint: { - 'fill-color': '#fde047', + 'fill-color': mapSymbology.selectionFill, 'fill-opacity': 0.32, }, }) @@ -586,7 +621,7 @@ function GeoMap({ source: 'selected-feature', filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon', 'LineString', 'MultiLineString'], true, false], paint: { - 'line-color': '#854d0e', + 'line-color': mapSymbology.selectionLine, 'line-width': 4, }, }) @@ -596,9 +631,9 @@ function GeoMap({ source: 'selected-feature', filter: ['match', ['geometry-type'], ['Point', 'MultiPoint'], true, false], paint: { - 'circle-color': '#fde047', + 'circle-color': mapSymbology.selectionFill, 'circle-radius': 7, - 'circle-stroke-color': '#854d0e', + 'circle-stroke-color': mapSymbology.selectionLine, 'circle-stroke-width': 2, }, }) @@ -620,7 +655,7 @@ function GeoMap({ type: 'fill', source: 'selection-bbox', paint: { - 'fill-color': '#38bdf8', + 'fill-color': mapSymbology.waterFill, 'fill-opacity': 0.12, }, }) @@ -629,7 +664,7 @@ function GeoMap({ type: 'line', source: 'selection-bbox', paint: { - 'line-color': '#0369a1', + 'line-color': mapSymbology.waterLine, 'line-width': 2, 'line-dasharray': [2, 1], }, @@ -656,7 +691,7 @@ function GeoMap({ source: 'selection-result', filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon'], true, false], paint: { - 'fill-color': '#7c3aed', + 'fill-color': mapSymbology.detectionFill, 'fill-opacity': 0.24, }, }) @@ -666,7 +701,7 @@ function GeoMap({ source: 'selection-result', filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon', 'LineString', 'MultiLineString'], true, false], paint: { - 'line-color': '#5b21b6', + 'line-color': mapSymbology.detectionLine, 'line-width': 3, }, }) @@ -676,9 +711,9 @@ function GeoMap({ source: 'selection-result', filter: ['match', ['geometry-type'], ['Point', 'MultiPoint'], true, false], paint: { - 'circle-color': '#7c3aed', + 'circle-color': mapSymbology.detectionFill, 'circle-radius': 6, - 'circle-stroke-color': '#ffffff', + 'circle-stroke-color': mapSymbology.pointStroke, 'circle-stroke-width': 2, }, }) @@ -701,14 +736,14 @@ function GeoMap({ 'match', ['get', 'qa_evidence_role'], 'match_candidate', - '#2563eb', + mapSymbology.unchanged, 'match_reference', - '#0f766e', + mapSymbology.boundary, 'false_positive', - '#dc2626', + mapSymbology.removed, 'false_negative', - '#d97706', - '#475569', + mapSymbology.modified, + mapSymbology.fallback, ] as ExpressionSpecification map.addLayer({ id: 'qa-evidence-fill', @@ -746,7 +781,7 @@ function GeoMap({ paint: { 'circle-color': evidenceColor, 'circle-radius': 7, - 'circle-stroke-color': '#ffffff', + 'circle-stroke-color': mapSymbology.pointStroke, 'circle-stroke-width': 2, }, }) @@ -762,4 +797,8 @@ function GeoMap({ ) } +// Geen React.memo hier. Het is geprobeerd en het scheelde niets: van de +// negentien props worden er te veel per render opnieuw gemaakt, dus de +// vergelijking slaat nooit over. Zinvol wordt dat pas wanneer die props +// gestabiliseerd zijn; tot die tijd is het schijnzekerheid. export default GeoMap diff --git a/frontend/src/components/analysis/ChangeDetectionPanel.tsx b/frontend/src/components/analysis/ChangeDetectionPanel.tsx index b057c41d..c6054876 100644 --- a/frontend/src/components/analysis/ChangeDetectionPanel.tsx +++ b/frontend/src/components/analysis/ChangeDetectionPanel.tsx @@ -136,7 +136,7 @@ export function ChangeDetectionPanel({
) : null} - {result?.warnings.length ? ( + {result?.warnings?.length ? (
Aandachtspunten
    diff --git a/frontend/src/components/auth/LandingPage.tsx b/frontend/src/components/auth/LandingPage.tsx index cba221e0..1f16014f 100644 --- a/frontend/src/components/auth/LandingPage.tsx +++ b/frontend/src/components/auth/LandingPage.tsx @@ -33,21 +33,18 @@ interface LandingPageProps { const capabilityItems = [ { icon: MapPinned, - number: '01', title: 'Eén kaartgerichte werkruimte', description: 'Selecteer een gebied in België of de Belgische Noordzee en werk verder vanuit dezelfde ruimtelijke context.', }, { icon: Database, - number: '02', title: 'Bronnen blijven herkenbaar', description: 'Autoriteit, meetmoment, dekking, CRS en beperkingen blijven zichtbaar in plaats van achter één generieke kaartlaag te verdwijnen.', }, { icon: ShieldCheck, - number: '03', title: 'Kwaliteit vóór resultaat', description: 'Vergelijk referentie- en kandidaatgegevens, controleer bewijs en exporteer pas wanneer de context klopt.', @@ -70,6 +67,7 @@ export function LandingPage({ const [password, setPassword] = useState('') const [pendingAction, setPendingAction] = useState<'operator' | 'guest' | null>(null) const [authError, setAuthError] = useState(null) + const [attempted, setAttempted] = useState(false) const [showPassword, setShowPassword] = useState(false) const [menuOpen, setMenuOpen] = useState(false) const usernameRef = useRef(null) @@ -81,9 +79,19 @@ export function LandingPage({ return () => document.body.classList.remove('landing-body') }, []) + const scrollAccessPanelIntoView = () => { + if (typeof accessPanelRef.current?.scrollIntoView !== 'function') return + const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false + accessPanelRef.current.scrollIntoView({ + behavior: reducedMotion ? 'auto' : 'smooth', + block: 'center', + }) + } + const submitLogin = async (event: FormEvent) => { event.preventDefault() setPendingAction('operator') + setAttempted(true) setAuthError(null) try { const session = await login(username.trim(), password) @@ -98,10 +106,9 @@ export function LandingPage({ const submitGuestLogin = async () => { setMenuOpen(false) setPendingAction('guest') + setAttempted(true) setAuthError(null) - if (typeof accessPanelRef.current?.scrollIntoView === 'function') { - accessPanelRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' }) - } + scrollAccessPanelIntoView() try { const session = await loginAsGuest() onAuthenticated(session) @@ -114,9 +121,7 @@ export function LandingPage({ const focusLogin = () => { setMenuOpen(false) - if (typeof accessPanelRef.current?.scrollIntoView === 'function') { - accessPanelRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' }) - } + scrollAccessPanelIntoView() window.requestAnimationFrame(() => usernameRef.current?.focus()) } @@ -278,7 +283,7 @@ export function LandingPage({
- {authError || serviceError ? ( + {authError || (serviceError && attempted) ? (

{authError ?? serviceError}

) : null} @@ -290,6 +295,13 @@ export function LandingPage({