Merge visual audit and GPU workflow upgrade
GeoIntel release gates / Compile, test, contracts and builds (push) Failing after 22s
GeoIntel release gates / Python and npm vulnerability policy (push) Failing after 19s
GeoIntel release gates / GIS image, SBOM and container scan (push) Failing after 1m40s

This commit is contained in:
Jens
2026-08-23 21:50:31 +02:00
118 changed files with 8918 additions and 3075 deletions
+21
View File
@@ -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"
}
}
}
}
+7
View File
@@ -142,6 +142,7 @@ bash scripts/live_migration_smoke.sh
- `GET /api/v1/detection/models` - `GET /api/v1/detection/models`
- `GET /api/v1/detection/model-assets` - `GET /api/v1/detection/model-assets`
- `POST /api/v1/detection/run` - `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}`
- `GET /api/v1/detection/runs/{analysis_run_id}/detections` - `GET /api/v1/detection/runs/{analysis_run_id}/detections`
- YOLO/PyTorch real inference is not enabled in Sprint 8. - YOLO/PyTorch real inference is not enabled in Sprint 8.
@@ -184,6 +185,7 @@ bash scripts/live_migration_smoke.sh
- Added segmentation endpoints: - Added segmentation endpoints:
- `GET /api/v1/segmentation/models` - `GET /api/v1/segmentation/models`
- `POST /api/v1/segmentation/run` - `POST /api/v1/segmentation/run`
- `POST /api/v1/segmentation/run-async` (production browser path)
- `GET /api/v1/segmentation/runs` - `GET /api/v1/segmentation/runs`
- `GET /api/v1/segmentation/runs/{analysis_run_id}` - `GET /api/v1/segmentation/runs/{analysis_run_id}`
- `GET /api/v1/segmentation/runs/{analysis_run_id}/segmentations` - `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` - `POST /api/v1/segmentation/runs/{analysis_run_id}/qa/reference`
- Real SAM and YOLO-seg inference are not enabled in Sprint 9. - 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. - 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 ## Sprint 17 additions
- Added export foundation backed by the existing `exports` table. - Added export foundation backed by the existing `exports` table.
+43
View File
@@ -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
+57 -7
View File
@@ -2,9 +2,14 @@ from __future__ import annotations
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.orm import Session 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.db.session import get_db
from app.schemas import ( from app.schemas import (
AnalysisQaResponse, AnalysisQaResponse,
@@ -25,6 +30,7 @@ from app.schemas import (
YoloPreflightResponse, YoloPreflightResponse,
) )
from app.services.detection_comparison_service import DetectionComparisonService 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.detection_service import DetectionService
from app.services.model_asset_catalog_service import ModelAssetCatalogService from app.services.model_asset_catalog_service import ModelAssetCatalogService
from app.services.model_registry_service import ModelRegistryService from app.services.model_registry_service import ModelRegistryService
@@ -60,7 +66,12 @@ def get_yolo_preflight(
@router.post("/run", response_model=Envelope[DetectionRunResponse]) @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( result = DetectionService.run_detection(
db=db, db=db,
project_id=payload.project_id, 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]) @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. """Queue a detection run for the background worker.
Tiled GPU inference takes minutes; ``POST /detection/run`` performs it 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. ``GET /jobs/{id}`` for the queued run instead.
""" """
assert_guest_project_scope(request, payload.project_id)
job = DetectionService.enqueue_detection( job = DetectionService.enqueue_detection(
db=db, db=db,
project_id=payload.project_id, 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]) @router.get("/runs", response_model=Envelope[DetectionRunListResponse])
def list_detection_runs( def list_detection_runs(
request: Request,
project_id: UUID | None = None, project_id: UUID | None = None,
dataset_id: UUID | None = None, dataset_id: UUID | None = None,
limit: int = Query(default=DetectionService.DEFAULT_RUN_LIST_LIMIT, ge=0, le=5_000), limit: int = Query(default=DetectionService.DEFAULT_RUN_LIST_LIMIT, ge=0, le=5_000),
offset: int = Query(default=0, ge=0), offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
project_id = guest_scoped_project_filter(request, project_id)
return envelope( return envelope(
DetectionService.list_runs( DetectionService.list_runs(
db, project_id=project_id, dataset_id=dataset_id, limit=limit, offset=offset 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]) @router.get("/runs/{analysis_run_id}", response_model=Envelope[DetectionRunRead])
def get_detection_run(analysis_run_id: UUID, db: Session = Depends(get_db)) -> dict: def get_detection_run(
return envelope(DetectionService.get_run(db, analysis_run_id).model_dump()) 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( @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( def list_detection_run_detections(
analysis_run_id: UUID, analysis_run_id: UUID,
request: Request,
dataset_id: UUID | None = None, dataset_id: UUID | None = None,
class_name: str | None = None, class_name: str | None = None,
min_confidence: float | None = None, min_confidence: float | None = None,
@@ -136,6 +161,9 @@ def list_detection_run_detections(
offset: int = Query(default=0, ge=0), offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> 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( return envelope(
DetectionService.list_detections( DetectionService.list_detections(
db, db,
@@ -155,6 +183,7 @@ def list_detection_run_detections(
) )
def list_dataset_detections( def list_dataset_detections(
dataset_id: UUID, dataset_id: UUID,
request: Request,
analysis_run_id: UUID | None = None, analysis_run_id: UUID | None = None,
class_name: str | None = None, class_name: str | None = None,
min_confidence: float | None = None, min_confidence: float | None = None,
@@ -167,6 +196,9 @@ def list_dataset_detections(
offset: int = Query(default=0, ge=0), offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> 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( return envelope(
DetectionService.list_detections( DetectionService.list_detections(
db, db,
@@ -181,8 +213,14 @@ def list_dataset_detections(
@router.get("/detections/{detection_id}", response_model=Envelope[DetectionRead]) @router.get("/detections/{detection_id}", response_model=Envelope[DetectionRead])
def get_detection(detection_id: UUID, db: Session = Depends(get_db)) -> dict: def get_detection(
return envelope(DetectionService.get_detection(db, detection_id).model_dump()) 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( @router.get(
@@ -191,6 +229,7 @@ def get_detection(detection_id: UUID, db: Session = Depends(get_db)) -> dict:
) )
def get_detection_run_geojson( def get_detection_run_geojson(
analysis_run_id: UUID, analysis_run_id: UUID,
request: Request,
class_name: str | None = None, class_name: str | None = None,
min_confidence: float | None = None, min_confidence: float | None = None,
limit: int = Query( limit: int = Query(
@@ -201,6 +240,9 @@ def get_detection_run_geojson(
), ),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> 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( return envelope(
DetectionService.detections_to_geojson( DetectionService.detections_to_geojson(
db, db,
@@ -218,6 +260,7 @@ def get_detection_run_geojson(
) )
def get_dataset_detection_geojson( def get_dataset_detection_geojson(
dataset_id: UUID, dataset_id: UUID,
request: Request,
analysis_run_id: UUID | None = None, analysis_run_id: UUID | None = None,
class_name: str | None = None, class_name: str | None = None,
min_confidence: float | None = None, min_confidence: float | None = None,
@@ -229,6 +272,9 @@ def get_dataset_detection_geojson(
), ),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> 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( return envelope(
DetectionService.detections_to_geojson( DetectionService.detections_to_geojson(
db, db,
@@ -269,8 +315,12 @@ def compare_detection_runs(payload: DetectionComparisonRequest, db: Session = De
def compare_detection_run_with_reference( def compare_detection_run_with_reference(
analysis_run_id: UUID, analysis_run_id: UUID,
payload: DetectionQaRequest, payload: DetectionQaRequest,
request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> 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( return envelope(
DetectionService.compare_detections_with_reference( DetectionService.compare_detections_with_reference(
db=db, db=db,
+52 -9
View File
@@ -2,10 +2,11 @@ from __future__ import annotations
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query, Request
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from sqlalchemy.orm import Session 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.core.errors import AppError
from app.db.session import get_db from app.db.session import get_db
from app.schemas import Envelope from app.schemas import Envelope
@@ -20,13 +21,30 @@ from app.schemas.export import (
ReportExportRequest, ReportExportRequest,
) )
from app.services.export_service import ExportService 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 from app.utils.response import envelope
router = APIRouter(prefix="/exports", tags=["exports"]) router = APIRouter(prefix="/exports", tags=["exports"])
@router.post("/geojson", response_model=Envelope[ExportCreateResponse]) @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: if payload.export_kind == "vector_selection" and payload.dataset_id is not None and payload.bbox is not None:
return envelope( return envelope(
ExportService.export_vector_selection_geojson( 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]) @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")) return envelope(ExportService.export_project_metadata(db, payload.project_id, payload.name).model_dump(mode="json"))
@router.post("/report", response_model=Envelope[ExportCreateResponse]) @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")) return envelope(ExportService.export_project_report(db, payload.project_id, payload.name).model_dump(mode="json"))
@router.post("/map-result", response_model=Envelope[ExportCreateResponse]) @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")) 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( def list_project_exports(
project_id: UUID, project_id: UUID,
request: Request,
limit: int = Query(default=50, ge=1, le=100), limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0), offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db), 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")) 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]) @router.get("/{export_id}", response_model=Envelope[ExportRead])
def get_export(export_id: UUID, db: Session = Depends(get_db)): def get_export(export_id: UUID, request: Request, db: Session = Depends(get_db)):
return envelope(ExportService.get_export(db, export_id).model_dump(mode="json")) 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") @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) path = ExportService.get_export_download_path(db, export_id)
media_type = "text/html" if path.suffix.lower() in {".html", ".htm"} else "application/json" media_type = "text/html" if path.suffix.lower() in {".html", ".htm"} else "application/json"
return FileResponse(path, filename=path.name, media_type=media_type) return FileResponse(path, filename=path.name, media_type=media_type)
@router.get("/{export_id}/content", response_model=Envelope[ExportContentResponse]) @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")) return envelope(ExportService.get_export_content(db, export_id).model_dump(mode="json"))
+57 -7
View File
@@ -2,9 +2,14 @@ from __future__ import annotations
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.orm import Session 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.db.session import get_db
from app.schemas import ( from app.schemas import (
AnalysisQaResponse, AnalysisQaResponse,
@@ -21,6 +26,7 @@ from app.schemas import (
SegmentationRunResponse, SegmentationRunResponse,
) )
from app.services.model_registry_service import ModelRegistryService 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.detection_service import DetectionService
from app.services.segmentation_service import SegmentationService from app.services.segmentation_service import SegmentationService
from app.utils.response import envelope from app.utils.response import envelope
@@ -34,7 +40,12 @@ def list_segmentation_models() -> dict:
@router.post("/run", response_model=Envelope[SegmentationRunResponse]) @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( result = SegmentationService.run_segmentation(
db=db, db=db,
project_id=payload.project_id, 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]) @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. """Queue a segmentation run for the background worker.
Configured segmentation walks the same tile manifest as detection and is Configured segmentation walks the same tile manifest as detection and is
just as unsuited to running inside the request. Poll ``GET /jobs/{id}``. just as unsuited to running inside the request. Poll ``GET /jobs/{id}``.
""" """
assert_guest_project_scope(request, payload.project_id)
job = SegmentationService.enqueue_segmentation( job = SegmentationService.enqueue_segmentation(
db=db, db=db,
project_id=payload.project_id, 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]) @router.get("/runs", response_model=Envelope[SegmentationRunListResponse])
def list_segmentation_runs( def list_segmentation_runs(
request: Request,
project_id: UUID | None = None, project_id: UUID | None = None,
dataset_id: UUID | None = None, dataset_id: UUID | None = None,
limit: int = Query(default=DetectionService.DEFAULT_RUN_LIST_LIMIT, ge=0, le=5_000), limit: int = Query(default=DetectionService.DEFAULT_RUN_LIST_LIMIT, ge=0, le=5_000),
offset: int = Query(default=0, ge=0), offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
project_id = guest_scoped_project_filter(request, project_id)
return envelope( return envelope(
SegmentationService.list_runs( SegmentationService.list_runs(
db, project_id=project_id, dataset_id=dataset_id, limit=limit, offset=offset 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]) @router.get("/runs/{analysis_run_id}", response_model=Envelope[SegmentationRunRead])
def get_segmentation_run(analysis_run_id: UUID, db: Session = Depends(get_db)) -> dict: def get_segmentation_run(
return envelope(SegmentationService.get_run(db, analysis_run_id).model_dump()) 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( @router.get(
@@ -95,6 +119,7 @@ def get_segmentation_run(analysis_run_id: UUID, db: Session = Depends(get_db)) -
) )
def list_segmentation_run_outputs( def list_segmentation_run_outputs(
analysis_run_id: UUID, analysis_run_id: UUID,
request: Request,
dataset_id: UUID | None = None, dataset_id: UUID | None = None,
class_name: str | None = None, class_name: str | None = None,
min_confidence: float | None = None, min_confidence: float | None = None,
@@ -107,6 +132,9 @@ def list_segmentation_run_outputs(
offset: int = Query(default=0, ge=0), offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> 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( return envelope(
SegmentationService.list_segmentations( SegmentationService.list_segmentations(
db, db,
@@ -126,6 +154,7 @@ def list_segmentation_run_outputs(
) )
def list_dataset_segmentations( def list_dataset_segmentations(
dataset_id: UUID, dataset_id: UUID,
request: Request,
analysis_run_id: UUID | None = None, analysis_run_id: UUID | None = None,
class_name: str | None = None, class_name: str | None = None,
min_confidence: float | None = None, min_confidence: float | None = None,
@@ -138,6 +167,9 @@ def list_dataset_segmentations(
offset: int = Query(default=0, ge=0), offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> 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( return envelope(
SegmentationService.list_segmentations( SegmentationService.list_segmentations(
db, db,
@@ -152,8 +184,14 @@ def list_dataset_segmentations(
@router.get("/segmentations/{segmentation_id}", response_model=Envelope[SegmentationRead]) @router.get("/segmentations/{segmentation_id}", response_model=Envelope[SegmentationRead])
def get_segmentation(segmentation_id: UUID, db: Session = Depends(get_db)) -> dict: def get_segmentation(
return envelope(SegmentationService.get_segmentation(db, segmentation_id).model_dump()) 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( @router.get(
@@ -162,6 +200,7 @@ def get_segmentation(segmentation_id: UUID, db: Session = Depends(get_db)) -> di
) )
def get_segmentation_run_geojson( def get_segmentation_run_geojson(
analysis_run_id: UUID, analysis_run_id: UUID,
request: Request,
class_name: str | None = None, class_name: str | None = None,
min_confidence: float | None = None, min_confidence: float | None = None,
limit: int = Query( limit: int = Query(
@@ -172,6 +211,9 @@ def get_segmentation_run_geojson(
), ),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> 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( return envelope(
SegmentationService.segmentations_to_geojson( SegmentationService.segmentations_to_geojson(
db, db,
@@ -189,6 +231,7 @@ def get_segmentation_run_geojson(
) )
def get_dataset_segmentation_geojson( def get_dataset_segmentation_geojson(
dataset_id: UUID, dataset_id: UUID,
request: Request,
analysis_run_id: UUID | None = None, analysis_run_id: UUID | None = None,
class_name: str | None = None, class_name: str | None = None,
min_confidence: float | None = None, min_confidence: float | None = None,
@@ -200,6 +243,9 @@ def get_dataset_segmentation_geojson(
), ),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> 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( return envelope(
SegmentationService.segmentations_to_geojson( SegmentationService.segmentations_to_geojson(
db, db,
@@ -219,8 +265,12 @@ def get_dataset_segmentation_geojson(
def compare_segmentation_run_with_reference( def compare_segmentation_run_with_reference(
analysis_run_id: UUID, analysis_run_id: UUID,
payload: SegmentationQaRequest, payload: SegmentationQaRequest,
request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> 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( return envelope(
SegmentationService.compare_segmentations_with_reference( SegmentationService.compare_segmentations_with_reference(
db=db, db=db,
+2
View File
@@ -264,7 +264,9 @@ def create_app() -> FastAPI:
} }
guest_scoped_analysis_post_paths = { guest_scoped_analysis_post_paths = {
f"{settings.api_prefix}/detection/run", 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",
f"{settings.api_prefix}/segmentation/run-async",
f"{settings.api_prefix}/qa/detections-vs-reference", f"{settings.api_prefix}/qa/detections-vs-reference",
f"{settings.api_prefix}/exports/geojson", f"{settings.api_prefix}/exports/geojson",
f"{settings.api_prefix}/exports/metadata", f"{settings.api_prefix}/exports/metadata",
+5
View File
@@ -102,4 +102,9 @@ class SegmentationRead(BaseModel):
class SegmentationListResponse(BaseModel): class SegmentationListResponse(BaseModel):
items: list[SegmentationRead] items: list[SegmentationRead]
# ``total`` describes the complete filtered population; ``items`` is one
# stable confidence-ranked page of it.
total: int total: int
limit: int | None = None
offset: int = 0
truncated: bool = False
+55 -6
View File
@@ -18,7 +18,7 @@ import socket
from collections.abc import Callable from collections.abc import Callable
from typing import Any from typing import Any
from urllib.parse import urlparse 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 from app.core.errors import AppError
@@ -38,12 +38,30 @@ class _RejectRedirects(HTTPRedirectHandler):
return None 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(): def no_redirect_opener():
"""An opener that will not follow a redirect anywhere.""" """An opener that will not follow a redirect anywhere."""
return build_opener(_RejectRedirects()) 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: def _reject(code: str, message: str, **details: Any) -> AppError:
return AppError(code=code, message=message, details=details or None, status_code=502) 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 host = parsed.hostname
if not host: if not host:
raise _reject("OUTBOUND_URL_NOT_ALLOWED", "Outbound request has no host.", url=url) 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("[]") literal = host.strip("[]")
candidates = [literal] if _looks_like_ip(literal) else _resolved_addresses(host) 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.", "The official endpoint redirected from HTTPS to an unprotected scheme.",
redirect_scheme=final.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) 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]: 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, Redirect targets are validated by the handler *before* urllib opens the
so the check is on ``response.url``: the body is still unread, and raising next connection. The final response URL is checked again as a defensive
here means nothing off-origin is ever parsed or persisted. invariant for injected/custom transports.
``allow_redirect=False`` refuses any redirect at all, which is what the ``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 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) 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: def _open(request: Any, *args: Any, _transport: Callable[..., Any] | None = None, **kwargs: Any) -> Any:
response = (_transport or default_transport)(request, *args, **kwargs) response = (_transport or default_transport)(request, *args, **kwargs)
@@ -61,6 +61,41 @@ class _UltralyticsSegmentationAdapterBase:
message="Segmentation dependencies are not installed. Install backend optional extras with geointel-backend[ai].", message="Segmentation dependencies are not installed. Install backend optional extras with geointel-backend[ai].",
status_code=503, 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]: def _predict(self, model, tile_path: Path, confidence_threshold: float) -> list[Any]:
if not tile_path.exists() or not tile_path.is_file(): if not tile_path.exists() or not tile_path.is_file():
+15 -3
View File
@@ -272,6 +272,8 @@ class SegmentationService:
dataset_id: uuid.UUID | None = None, dataset_id: uuid.UUID | None = None,
class_name: str | None = None, class_name: str | None = None,
min_confidence: float | None = None, min_confidence: float | None = None,
limit: int | None = None,
offset: int = 0,
) -> SegmentationListResponse: ) -> SegmentationListResponse:
if analysis_run_id is not None: if analysis_run_id is not None:
run = db.get(AnalysisRun, analysis_run_id) run = db.get(AnalysisRun, analysis_run_id)
@@ -284,8 +286,19 @@ class SegmentationService:
class_name=class_name, class_name=class_name,
min_confidence=min_confidence, min_confidence=min_confidence,
) )
items = [SegmentationRead.model_validate(row) for row in rows] resolved_limit = DetectionService.DEFAULT_RESULT_LIMIT if limit is None else int(limit)
return SegmentationListResponse(items=items, total=len(items)) 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 @staticmethod
def get_segmentation(db, segmentation_id: uuid.UUID) -> SegmentationRead: def get_segmentation(db, segmentation_id: uuid.UUID) -> SegmentationRead:
@@ -847,7 +860,6 @@ class SegmentationService:
"suppressed_segmentation_count": len(candidates) - len(filtered_candidates), "suppressed_segmentation_count": len(candidates) - len(filtered_candidates),
"duplicate_iou_threshold": float(settings.segmentation_duplicate_iou_threshold), "duplicate_iou_threshold": float(settings.segmentation_duplicate_iou_threshold),
"containment_suppression_threshold": float(settings.segmentation_containment_nms_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()), "tile_manifest_path": str(Path(tile_manifest_path or "").expanduser()),
"runtime_model_provenance": runtime_model_provenance.as_dict(), "runtime_model_provenance": runtime_model_provenance.as_dict(),
} }
+1
View File
@@ -93,6 +93,7 @@ FEATURE_SOURCES: dict[str, tuple[str, ...]] = {
), ),
"shell": ( "shell": (
"App.tsx", "App.tsx",
"WorkbenchApp.tsx",
"components/shell/WorkbenchNavigation.tsx", "components/shell/WorkbenchNavigation.tsx",
"components/shell/SecondaryDisplay.tsx", "components/shell/SecondaryDisplay.tsx",
"components/inspector/WorkbenchInspector.tsx", "components/inspector/WorkbenchInspector.tsx",
+347
View File
@@ -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
@@ -18,8 +18,10 @@ import pytest
from app.core.errors import AppError from app.core.errors import AppError
from app.services.outbound_request_guard import ( from app.services.outbound_request_guard import (
_ValidatedRedirects,
assert_public_http_url, assert_public_http_url,
assert_same_origin_redirect, assert_same_origin_redirect,
validated_redirect_opener,
) )
@@ -89,6 +91,30 @@ class TestRedirects:
def test_an_upgrade_to_https_stays_allowed(self) -> None: def test_an_upgrade_to_https_stays_allowed(self) -> None:
assert_same_origin_redirect("http://geo.example.be/wcs", "https://geo.example.be/wcs") 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: def test_the_guard_opener_refuses_a_cross_host_redirect() -> None:
"""The opener is what the acquisition services actually call.""" """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 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: def test_the_rejecting_handler_returns_no_new_request() -> None:
from app.services.outbound_request_guard import _RejectRedirects from app.services.outbound_request_guard import _RejectRedirects
@@ -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()
@@ -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,
}
]
@@ -15,7 +15,8 @@ def test_detection_lab_distinguishes_configured_model_from_ui_runnable_action()
assert "detectionRunBlockedReason" in lab assert "detectionRunBlockedReason" in lab
assert "Het fixturemodel is alleen bedoeld voor expliciete tests" in lab assert "Het fixturemodel is alleen bedoeld voor expliciete tests" in lab
assert "Klaar om gebouwen te zoeken" 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: 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 "segmentationRunBlockedReason" in lab
assert "Het fixturemodel is alleen bedoeld voor expliciete tests" in lab assert "Het fixturemodel is alleen bedoeld voor expliciete tests" in lab
assert "Analyse" 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: def test_ai_lab_guardrail_styles_remain_compact() -> None:
@@ -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: 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( lab = "\n".join(
( (
(ROOT / "frontend" / "src" / "components" / "detection" / "DetectionLab.tsx").read_text(encoding="utf-8"), (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 "setSelectedDetectionDatasetId(selectedDataset.id)" in app
assert "setSelectedDetectionModelId('yolo-configured')" in app assert "setSelectedDetectionModelId('yolo-configured')" in app
assert "setDetectionConfidenceThreshold(0.25)" 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 "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
assert "Gekoppelde beeldtegels" in lab assert "Gekoppelde beeldtegels" in lab
@@ -26,8 +26,10 @@ def test_guided_detection_reuses_canonical_raster_and_detection_apis() -> None:
assert "effectiveModelId" in hook assert "effectiveModelId" in hook
assert "effectiveModelAssetId" in hook assert "effectiveModelAssetId" in hook
assert "await loadDetectionResults(result.analysis_run_id)" in hook assert "await loadDetectionResults(result.analysis_run_id)" in hook
assert "model_id: selectedDetectionModelId" in hook assert "model_id: modelId" in hook
assert "model_asset_id: selectedModelAssetId || null" 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: 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 'aria-label="Kwaliteitscontrole gebouwdetectie"' in lab
assert "als kwaliteitscontrole in de database bewaard" in lab assert "als kwaliteitscontrole in de database bewaard" in lab
assert "detectionApi.compareWithReference" in hook 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 "Minimale IoU voor een match" in lab
assert "detectionQaResult.iou_threshold.toFixed(2)" in lab assert "detectionQaResult.iou_threshold.toFixed(2)" in lab
+44 -8
View File
@@ -1528,7 +1528,10 @@ Response:
## Detection Lab ## 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 ### 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; 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; 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; 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; 4. `POST /api/v1/detection/run-async` only after successful preflight;
5. persisted run, Detection list and Detection GeoJSON reads; 5. project-bound polling through
6. optional persisted reference QA through the existing detection QA endpoint. `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` ### 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 Queued jobs are executed by the background analysis worker
(`GEOINTEL_ANALYSIS_WORKER_ENABLED`, poll interval (`GEOINTEL_ANALYSIS_WORKER_ENABLED`, poll interval
`GEOINTEL_ANALYSIS_WORKER_POLL_SECONDS`), which claims a job before dispatching `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 it so the same run is never started twice. Poll the project-bound
progress. `POST /api/v1/segmentation/run-async` behaves identically. `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: Unavailable model response:
@@ -2032,7 +2045,12 @@ Same pattern as object detection, but output includes masks and polygonized geom
## Segmentation Lab ## 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` ### GET `/api/v1/segmentation/models`
@@ -2043,6 +2061,9 @@ Returns segmentation model capability descriptors:
- `yolo-seg-configured`: `not_configured` - `yolo-seg-configured`: `not_configured`
- `sam-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` ### 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`. 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. 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: Validation errors:
- `INVALID_DATASET_TYPE` when the dataset is not raster. - `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` - `dataset_id`
- `class_name` - `class_name`
- `min_confidence` - `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` ### GET `/api/v1/segmentation/datasets/{dataset_id}/segmentations`
+68
View File
@@ -12867,3 +12867,71 @@ Open:
- Browser emulation covers responsive layout and interaction; certification on - Browser emulation covers responsive layout and interaction; certification on
physical touch hardware and with a screen reader remains a separate human QA physical touch hardware and with a screen reader remains a separate human QA
activity. 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.
+26
View File
@@ -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] Loading-, empty-, unavailable- en errorstates plus toetsenbord- en dialogbediening.
- [x] Zoekbare en bredere kaartthemalijst met volledig leesbare labels. - [x] Zoekbare en bredere kaartthemalijst met volledig leesbare labels.
- [x] Compacte analysecontextbalk en rustige desktop/tablet/mobiele hiërarchie. - [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] Uitschuifbare inzichten behouden; analyse blijft uitsluitend expliciet na themakeuze.
- [x] 51 frontendtests en productiebuild groen. - [x] 51 frontendtests en productiebuild groen.
- [ ] 19 verouderde broncode-stringtests herijken; meerdere eisen daarin (automatische analyse) conflicteren bewust met de actuele productbeslissing. - [ ] 19 verouderde broncode-stringtests herijken; meerdere eisen daarin (automatische analyse) conflicteren bewust met de actuele productbeslissing.
+45 -1
View File
@@ -160,7 +160,9 @@ font dependency.
## Current component boundaries ## 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: and pure map helpers are kept outside it:
- `components/overview/OverviewWorkspace.tsx` owns status, source freshness, - `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 keyboard operation of the analysis period, skip-link focus, delayed bootstrap
truthfulness and visible coverage timing. The enforced limits are documented truthfulness and visible coverage timing. The enforced limits are documented
in `docs/UX_PERFORMANCE_BUDGETS.md`. 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.
+14
View File
@@ -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)"

Before

Width:  |  Height:  |  Size: 2.5 MiB

After

Width:  |  Height:  |  Size: 2.5 MiB

Before

Width:  |  Height:  |  Size: 3.1 MiB

After

Width:  |  Height:  |  Size: 3.1 MiB

Before

Width:  |  Height:  |  Size: 2.3 MiB

After

Width:  |  Height:  |  Size: 2.3 MiB

+141 -5
View File
@@ -63,9 +63,34 @@ async function auditInteractiveNames(page, label) {
return unnamed.length 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) { async function layoutEvidence(page) {
return page.evaluate(() => { return page.evaluate(() => {
const root = document.documentElement 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 main = document.querySelector('.workbench-main')?.getBoundingClientRect()
const map = document.querySelector('.geo-map-stage')?.getBoundingClientRect() const map = document.querySelector('.geo-map-stage')?.getBoundingClientRect()
const theme = document.querySelector('.geo-theme-panel')?.getBoundingClientRect() const theme = document.querySelector('.geo-theme-panel')?.getBoundingClientRect()
@@ -75,6 +100,11 @@ async function layoutEvidence(page) {
document_width: root.scrollWidth, document_width: root.scrollWidth,
body_width: document.body.scrollWidth, body_width: document.body.scrollWidth,
horizontal_overflow_px: Math.max(0, root.scrollWidth - root.clientWidth), 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, 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, 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, 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) { async function runViewport(browser, baseUrl, outputDir, viewport) {
const page = await browser.newPage({ viewport }) const page = await browser.newPage({ viewport })
const consoleErrors = [] const consoleErrors = []
@@ -96,14 +177,41 @@ async function runViewport(browser, baseUrl, outputDir, viewport) {
} }
}) })
try { try {
await prepareAuditSession(page, baseUrl)
const startedAt = Date.now() const startedAt = Date.now()
await page.goto(baseUrl, { waitUntil: 'networkidle', timeout: 60_000 }) await page.goto(baseUrl, { waitUntil: 'networkidle', timeout: 60_000 })
await page.getByTestId('map-workspace').waitFor({ state: 'visible', timeout: 30_000 }) await page.getByTestId('map-workspace').waitFor({ state: 'visible', timeout: 30_000 })
const readyMs = Date.now() - startedAt const readyMs = Date.now() - startedAt
await auditInteractiveNames(page, `${viewport.width}px map explorer`) await auditInteractiveNames(page, `${viewport.width}px map explorer`)
const layout = await layoutEvidence(page) 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.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`) 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 currentTab = page.getByRole('tab', { name: 'Laatste toestand' })
const evolutionTab = page.getByRole('tab', { name: 'Evolutie' }) const evolutionTab = page.getByRole('tab', { name: 'Evolutie' })
@@ -127,6 +235,7 @@ async function runViewport(browser, baseUrl, outputDir, viewport) {
viewport, viewport,
ready_ms: readyMs, ready_ms: readyMs,
layout, layout,
clipped_navigation_labels: clippedNavigationLabels,
console_errors: consoleErrors, console_errors: consoleErrors,
failed_requests: failedRequests, failed_requests: failedRequests,
} }
@@ -144,6 +253,7 @@ async function runLoadingAndAdvancedAudit(browser, baseUrl, outputDir) {
await route.continue() await route.continue()
}) })
try { try {
const auditSession = await prepareAuditSession(page, baseUrl)
await page.goto(baseUrl, { waitUntil: 'domcontentloaded', timeout: 60_000 }) await page.goto(baseUrl, { waitUntil: 'domcontentloaded', timeout: 60_000 })
const loadingStatus = page.getByRole('status', { name: '' }).filter({ const loadingStatus = page.getByRole('status', { name: '' }).filter({
hasText: 'Databronnen worden gecontroleerd', 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') }) await page.screenshot({ path: path.join(outputDir, 'advanced-coverage-budget.png') })
const auditedWorkspaces = [] 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.getByTestId(`workspace-nav-${workspace}`).click()
await page.waitForTimeout(100) await page.waitForTimeout(100)
await auditInteractiveNames(page, `${workspace} workspace`) 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) auditedWorkspaces.push(workspace)
} }
@@ -203,21 +333,27 @@ async function main() {
schema_version: 1, schema_version: 1,
base_url: args.baseUrl, base_url: args.baseUrl,
started_at: new Date().toISOString(), started_at: new Date().toISOString(),
landing_viewports: [],
viewports: [], viewports: [],
bootstrap: null, bootstrap: null,
status: 'running', status: 'running',
} }
try { try {
for (const viewport of [ const viewports = [
{ width: 390, height: 844 }, { width: 390, height: 844 },
{ width: 1366, height: 768 }, { width: 1366, height: 768 },
{ width: 2560, height: 1080 }, { 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.viewports.push(await runViewport(browser, args.baseUrl, outputDir, viewport))
} }
evidence.bootstrap = await runLoadingAndAdvancedAudit(browser, args.baseUrl, outputDir) evidence.bootstrap = await runLoadingAndAdvancedAudit(browser, args.baseUrl, outputDir)
const unexpectedConsoleErrors = evidence.viewports.flatMap((item) => item.console_errors) const auditedPages = [...evidence.landing_viewports, ...evidence.viewports]
const unexpectedFailedRequests = evidence.viewports.flatMap((item) => item.failed_requests) 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(unexpectedConsoleErrors, [], 'UX audit captured console errors')
assert.deepEqual(unexpectedFailedRequests, [], 'UX audit captured failed API requests') assert.deepEqual(unexpectedFailedRequests, [], 'UX audit captured failed API requests')
evidence.status = 'passed' evidence.status = 'passed'
Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 287 KiB

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 358 KiB

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

+10 -1452
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+69 -30
View File
@@ -5,6 +5,7 @@ import 'maplibre-gl/dist/maplibre-gl.css'
import { NATIONAL_MAP_CENTER, NATIONAL_MAP_ZOOM } from '../config/primaryFocus' import { NATIONAL_MAP_CENTER, NATIONAL_MAP_ZOOM } from '../config/primaryFocus'
import { featureCollectionBounds } from '../lib/geojsonBounds' import { featureCollectionBounds } from '../lib/geojsonBounds'
import type { MapImageOverlay, MapViewportState, VectorSelectionBBox } from '../types' import type { MapImageOverlay, MapViewportState, VectorSelectionBBox } from '../types'
import { basemapGround, basemapPaint, huidigeWerkstand, mapSymbology } from './map/mapSymbology'
interface GeoMapProps { interface GeoMapProps {
data: GeoJSON.FeatureCollection | null data: GeoJSON.FeatureCollection | null
@@ -46,10 +47,23 @@ const DEFAULT_ROAD_BASEMAP_STYLE: maplibregl.StyleSpecification = {
}, },
}, },
layers: [ 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', id: 'osm-standard',
type: 'raster', type: 'raster',
source: 'osm-standard', 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 [ return [
'case', 'case',
['==', ['get', 'layer_type'], 'municipality_boundary'], ['==', ['get', 'layer_type'], 'municipality_boundary'],
'#0f766e', mapSymbology.boundary,
[ [
'match', 'match',
['get', 'change_type'], ['get', 'change_type'],
'added', 'added',
'#16a34a', mapSymbology.added,
'removed', 'removed',
'#dc2626', mapSymbology.removed,
'modified', 'modified',
'#d97706', mapSymbology.modified,
'unchanged', 'unchanged',
'#2563eb', mapSymbology.unchanged,
fallbackColor, fallbackColor,
], ],
] ]
@@ -79,18 +93,18 @@ function datasetLineColor(fallbackColor: string): ExpressionSpecification {
return [ return [
'case', 'case',
['==', ['get', 'layer_type'], 'municipality_boundary'], ['==', ['get', 'layer_type'], 'municipality_boundary'],
'#0f5f59', mapSymbology.boundaryStrong,
[ [
'match', 'match',
['get', 'change_type'], ['get', 'change_type'],
'added', 'added',
'#15803d', mapSymbology.added,
'removed', 'removed',
'#b91c1c', mapSymbology.removed,
'modified', 'modified',
'#b45309', mapSymbology.modified,
'unchanged', 'unchanged',
'#1d4ed8', mapSymbology.unchanged,
fallbackColor, fallbackColor,
], ],
] ]
@@ -144,8 +158,8 @@ function bboxToFeatureCollection(
function GeoMap({ function GeoMap({
data, data,
dataFillColor = '#f97316', dataFillColor = mapSymbology.dataFill,
dataLineColor = '#ea580c', dataLineColor = mapSymbology.dataLine,
areaData = null, areaData = null,
selectedFeature = null, selectedFeature = null,
selectionData = null, selectionData = null,
@@ -236,6 +250,27 @@ function GeoMap({
} }
}, [bboxSelectionMode]) }, [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(() => { useEffect(() => {
if (!containerRef.current || mapRef.current) { if (!containerRef.current || mapRef.current) {
return return
@@ -491,7 +526,7 @@ function GeoMap({
type: 'fill', type: 'fill',
source: 'area', source: 'area',
paint: { paint: {
'fill-color': '#0f766e', 'fill-color': mapSymbology.boundary,
'fill-opacity': 0.18, 'fill-opacity': 0.18,
}, },
}, },
@@ -503,7 +538,7 @@ function GeoMap({
type: 'line', type: 'line',
source: 'area', source: 'area',
paint: { paint: {
'line-color': '#0f766e', 'line-color': mapSymbology.boundary,
'line-width': 3, 'line-width': 3,
'line-dasharray': [2, 1], 'line-dasharray': [2, 1],
}, },
@@ -576,7 +611,7 @@ function GeoMap({
source: 'selected-feature', source: 'selected-feature',
filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon'], true, false], filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon'], true, false],
paint: { paint: {
'fill-color': '#fde047', 'fill-color': mapSymbology.selectionFill,
'fill-opacity': 0.32, 'fill-opacity': 0.32,
}, },
}) })
@@ -586,7 +621,7 @@ function GeoMap({
source: 'selected-feature', source: 'selected-feature',
filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon', 'LineString', 'MultiLineString'], true, false], filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon', 'LineString', 'MultiLineString'], true, false],
paint: { paint: {
'line-color': '#854d0e', 'line-color': mapSymbology.selectionLine,
'line-width': 4, 'line-width': 4,
}, },
}) })
@@ -596,9 +631,9 @@ function GeoMap({
source: 'selected-feature', source: 'selected-feature',
filter: ['match', ['geometry-type'], ['Point', 'MultiPoint'], true, false], filter: ['match', ['geometry-type'], ['Point', 'MultiPoint'], true, false],
paint: { paint: {
'circle-color': '#fde047', 'circle-color': mapSymbology.selectionFill,
'circle-radius': 7, 'circle-radius': 7,
'circle-stroke-color': '#854d0e', 'circle-stroke-color': mapSymbology.selectionLine,
'circle-stroke-width': 2, 'circle-stroke-width': 2,
}, },
}) })
@@ -620,7 +655,7 @@ function GeoMap({
type: 'fill', type: 'fill',
source: 'selection-bbox', source: 'selection-bbox',
paint: { paint: {
'fill-color': '#38bdf8', 'fill-color': mapSymbology.waterFill,
'fill-opacity': 0.12, 'fill-opacity': 0.12,
}, },
}) })
@@ -629,7 +664,7 @@ function GeoMap({
type: 'line', type: 'line',
source: 'selection-bbox', source: 'selection-bbox',
paint: { paint: {
'line-color': '#0369a1', 'line-color': mapSymbology.waterLine,
'line-width': 2, 'line-width': 2,
'line-dasharray': [2, 1], 'line-dasharray': [2, 1],
}, },
@@ -656,7 +691,7 @@ function GeoMap({
source: 'selection-result', source: 'selection-result',
filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon'], true, false], filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon'], true, false],
paint: { paint: {
'fill-color': '#7c3aed', 'fill-color': mapSymbology.detectionFill,
'fill-opacity': 0.24, 'fill-opacity': 0.24,
}, },
}) })
@@ -666,7 +701,7 @@ function GeoMap({
source: 'selection-result', source: 'selection-result',
filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon', 'LineString', 'MultiLineString'], true, false], filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon', 'LineString', 'MultiLineString'], true, false],
paint: { paint: {
'line-color': '#5b21b6', 'line-color': mapSymbology.detectionLine,
'line-width': 3, 'line-width': 3,
}, },
}) })
@@ -676,9 +711,9 @@ function GeoMap({
source: 'selection-result', source: 'selection-result',
filter: ['match', ['geometry-type'], ['Point', 'MultiPoint'], true, false], filter: ['match', ['geometry-type'], ['Point', 'MultiPoint'], true, false],
paint: { paint: {
'circle-color': '#7c3aed', 'circle-color': mapSymbology.detectionFill,
'circle-radius': 6, 'circle-radius': 6,
'circle-stroke-color': '#ffffff', 'circle-stroke-color': mapSymbology.pointStroke,
'circle-stroke-width': 2, 'circle-stroke-width': 2,
}, },
}) })
@@ -701,14 +736,14 @@ function GeoMap({
'match', 'match',
['get', 'qa_evidence_role'], ['get', 'qa_evidence_role'],
'match_candidate', 'match_candidate',
'#2563eb', mapSymbology.unchanged,
'match_reference', 'match_reference',
'#0f766e', mapSymbology.boundary,
'false_positive', 'false_positive',
'#dc2626', mapSymbology.removed,
'false_negative', 'false_negative',
'#d97706', mapSymbology.modified,
'#475569', mapSymbology.fallback,
] as ExpressionSpecification ] as ExpressionSpecification
map.addLayer({ map.addLayer({
id: 'qa-evidence-fill', id: 'qa-evidence-fill',
@@ -746,7 +781,7 @@ function GeoMap({
paint: { paint: {
'circle-color': evidenceColor, 'circle-color': evidenceColor,
'circle-radius': 7, 'circle-radius': 7,
'circle-stroke-color': '#ffffff', 'circle-stroke-color': mapSymbology.pointStroke,
'circle-stroke-width': 2, '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 export default GeoMap
@@ -136,7 +136,7 @@ export function ChangeDetectionPanel({
</div> </div>
) : null} ) : null}
{result?.warnings.length ? ( {result?.warnings?.length ? (
<div className="change-detection-warning-surface"> <div className="change-detection-warning-surface">
<strong>Aandachtspunten</strong> <strong>Aandachtspunten</strong>
<ul className="compact-list"> <ul className="compact-list">
+23 -12
View File
@@ -33,21 +33,18 @@ interface LandingPageProps {
const capabilityItems = [ const capabilityItems = [
{ {
icon: MapPinned, icon: MapPinned,
number: '01',
title: 'Eén kaartgerichte werkruimte', title: 'Eén kaartgerichte werkruimte',
description: description:
'Selecteer een gebied in België of de Belgische Noordzee en werk verder vanuit dezelfde ruimtelijke context.', 'Selecteer een gebied in België of de Belgische Noordzee en werk verder vanuit dezelfde ruimtelijke context.',
}, },
{ {
icon: Database, icon: Database,
number: '02',
title: 'Bronnen blijven herkenbaar', title: 'Bronnen blijven herkenbaar',
description: description:
'Autoriteit, meetmoment, dekking, CRS en beperkingen blijven zichtbaar in plaats van achter één generieke kaartlaag te verdwijnen.', 'Autoriteit, meetmoment, dekking, CRS en beperkingen blijven zichtbaar in plaats van achter één generieke kaartlaag te verdwijnen.',
}, },
{ {
icon: ShieldCheck, icon: ShieldCheck,
number: '03',
title: 'Kwaliteit vóór resultaat', title: 'Kwaliteit vóór resultaat',
description: description:
'Vergelijk referentie- en kandidaatgegevens, controleer bewijs en exporteer pas wanneer de context klopt.', '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 [password, setPassword] = useState('')
const [pendingAction, setPendingAction] = useState<'operator' | 'guest' | null>(null) const [pendingAction, setPendingAction] = useState<'operator' | 'guest' | null>(null)
const [authError, setAuthError] = useState<string | null>(null) const [authError, setAuthError] = useState<string | null>(null)
const [attempted, setAttempted] = useState(false)
const [showPassword, setShowPassword] = useState(false) const [showPassword, setShowPassword] = useState(false)
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const usernameRef = useRef<HTMLInputElement | null>(null) const usernameRef = useRef<HTMLInputElement | null>(null)
@@ -81,9 +79,19 @@ export function LandingPage({
return () => document.body.classList.remove('landing-body') 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<HTMLFormElement>) => { const submitLogin = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault() event.preventDefault()
setPendingAction('operator') setPendingAction('operator')
setAttempted(true)
setAuthError(null) setAuthError(null)
try { try {
const session = await login(username.trim(), password) const session = await login(username.trim(), password)
@@ -98,10 +106,9 @@ export function LandingPage({
const submitGuestLogin = async () => { const submitGuestLogin = async () => {
setMenuOpen(false) setMenuOpen(false)
setPendingAction('guest') setPendingAction('guest')
setAttempted(true)
setAuthError(null) setAuthError(null)
if (typeof accessPanelRef.current?.scrollIntoView === 'function') { scrollAccessPanelIntoView()
accessPanelRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
try { try {
const session = await loginAsGuest() const session = await loginAsGuest()
onAuthenticated(session) onAuthenticated(session)
@@ -114,9 +121,7 @@ export function LandingPage({
const focusLogin = () => { const focusLogin = () => {
setMenuOpen(false) setMenuOpen(false)
if (typeof accessPanelRef.current?.scrollIntoView === 'function') { scrollAccessPanelIntoView()
accessPanelRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
window.requestAnimationFrame(() => usernameRef.current?.focus()) window.requestAnimationFrame(() => usernameRef.current?.focus())
} }
@@ -278,7 +283,7 @@ export function LandingPage({
</button> </button>
</div> </div>
{authError || serviceError ? ( {authError || (serviceError && attempted) ? (
<p className="landing-login-error" role="alert">{authError ?? serviceError}</p> <p className="landing-login-error" role="alert">{authError ?? serviceError}</p>
) : null} ) : null}
@@ -290,6 +295,13 @@ export function LandingPage({
<LogIn aria-hidden="true" /> <LogIn aria-hidden="true" />
{pendingAction === 'operator' ? 'Veilig aanmelden…' : 'Inloggen als operator'} {pendingAction === 'operator' ? 'Veilig aanmelden…' : 'Inloggen als operator'}
</button> </button>
{/* De storingsmelding stond als rood blok midden in de hero,
voordat de bezoeker iets gedaan had. Zolang er nog niets
geprobeerd is, is het een mededeling en geen fout. */}
{serviceError && !attempted && !authError ? (
<p className="landing-login-note">{serviceError}</p>
) : null}
</form> </form>
{guestAccessEnabled ? ( {guestAccessEnabled ? (
@@ -326,11 +338,10 @@ export function LandingPage({
</div> </div>
<div className="landing-capability-grid"> <div className="landing-capability-grid">
{capabilityItems.map(({ icon: Icon, number, title, description }) => ( {capabilityItems.map(({ icon: Icon, title, description }) => (
<article key={title} className="landing-capability"> <article key={title} className="landing-capability">
<div className="landing-capability-topline"> <div className="landing-capability-topline">
<span className="landing-capability-icon" aria-hidden="true"><Icon /></span> <span className="landing-capability-icon" aria-hidden="true"><Icon /></span>
<small>{number}</small>
</div> </div>
<h3>{title}</h3> <h3>{title}</h3>
<p>{description}</p> <p>{description}</p>
@@ -1,6 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { BadgeCheck, Database, Download, MapPinned, ScanSearch } from 'lucide-react' import { BadgeCheck, Database, Download, MapPinned, ScanSearch } from 'lucide-react'
import '../../styles/landing-project-story.css' import '../../styles/landing-project-story.css'
import { useDeferredBackground } from '../../hooks/useDeferredBackground'
const stages = [ const stages = [
{ {
@@ -46,6 +47,7 @@ const stages = [
] as const ] as const
export function LandingProjectStory(): JSX.Element { export function LandingProjectStory(): JSX.Element {
const kaartRef = useDeferredBackground<HTMLDivElement>()
const [activeIndex, setActiveIndex] = useState(0) const [activeIndex, setActiveIndex] = useState(0)
const activeStage = stages[activeIndex] const activeStage = stages[activeIndex]
@@ -104,7 +106,7 @@ export function LandingProjectStory(): JSX.Element {
</div> </div>
<div className={`landing-story-visual is-stage-${activeIndex + 1}`} aria-hidden="true"> <div className={`landing-story-visual is-stage-${activeIndex + 1}`} aria-hidden="true">
<div className="landing-story-map" /> <div className="landing-story-map" ref={kaartRef} />
<svg viewBox="0 0 720 610" role="presentation"> <svg viewBox="0 0 720 610" role="presentation">
<defs> <defs>
<linearGradient id="story-area" x1="0" y1="0" x2="1" y2="1"> <linearGradient id="story-area" x1="0" y1="0" x2="1" y2="1">
@@ -2,7 +2,7 @@ export function ItWorxSignature(): JSX.Element {
return ( return (
<div className="itworx-signature" aria-label="Ontwikkeld door Jens van ITWorx.tech"> <div className="itworx-signature" aria-label="Ontwikkeld door Jens van ITWorx.tech">
<span>Ontwikkeld door Jens</span> <span>Ontwikkeld door Jens</span>
<img src="/itworx-wordmark.png" alt="ITWorx.tech" /> <img src="/itworx-wordmark.webp" alt="ITWorx.tech" />
</div> </div>
) )
} }
@@ -172,28 +172,24 @@ export function DatasetPanel({
key: 'selected', key: 'selected',
label: 'Geselecteerd', label: 'Geselecteerd',
count: selectedDatasetId ? 1 : 0, count: selectedDatasetId ? 1 : 0,
hint: 'Actief',
className: 'dataset-role-selected', className: 'dataset-role-selected',
}, },
{ {
key: 'reference', key: 'reference',
label: 'Referentie', label: 'Referentie',
count: primaryDatasets.filter((dataset) => normalizeDatasetRole(dataset) === 'reference').length, count: primaryDatasets.filter((dataset) => normalizeDatasetRole(dataset) === 'reference').length,
hint: 'Officieel',
className: 'dataset-role-reference', className: 'dataset-role-reference',
}, },
{ {
key: 'candidate', key: 'candidate',
label: 'Resultaat', label: 'Resultaat',
count: primaryDatasets.filter((dataset) => normalizeDatasetRole(dataset) === 'candidate').length, count: primaryDatasets.filter((dataset) => normalizeDatasetRole(dataset) === 'candidate').length,
hint: 'Afgeleid',
className: 'dataset-role-candidate', className: 'dataset-role-candidate',
}, },
{ {
key: 'source', key: 'source',
label: 'Basisbron', label: 'Basisbron',
count: primaryDatasets.filter((dataset) => normalizeDatasetRole(dataset) === 'source').length, count: primaryDatasets.filter((dataset) => normalizeDatasetRole(dataset) === 'source').length,
hint: 'Ingeladen',
className: 'dataset-role-source', className: 'dataset-role-source',
}, },
] ]
@@ -274,12 +270,11 @@ export function DatasetPanel({
</form> </form>
</details> </details>
<div className="dataset-role-summary-grid" aria-label="Dataset role summary"> <div className="dataset-role-summary-grid" aria-label="Overzicht van bronrollen">
{roleSummaries.map((summary) => ( {roleSummaries.map((summary) => (
<div className={`dataset-role-summary ${summary.className}`} key={summary.key}> <div className={`dataset-role-summary ${summary.className}`} key={summary.key}>
<span>{summary.label}</span>
<strong>{summary.count}</strong> <strong>{summary.count}</strong>
<small>{summary.hint}</small> <span>{summary.label}</span>
</div> </div>
))} ))}
</div> </div>
@@ -17,10 +17,51 @@ describe('AiPipelineIllustration', () => {
/>, />,
) )
expect(screen.getByText('CUDA gereed')).toBeTruthy() expect(screen.getByText('GPU gereed')).toBeTruthy()
expect(screen.getByRole('tab', { name: /Detecties/ }).textContent).toContain('volgende stap') expect(screen.getByRole('tab', { name: /Detecties/ }).textContent).toContain('volgende stap')
fireEvent.click(screen.getByRole('tab', { name: /NVIDIA GPU/ })) fireEvent.click(screen.getByRole('tab', { name: /Berekening/ }))
expect(screen.getByRole('tabpanel').textContent).toContain('Lokale PyTorch-inferentie') expect(screen.getByRole('tabpanel').textContent).toContain('De herkenning draait lokaal')
})
it('moves selection and focus through the tablist with keyboard controls', () => {
render(
<AiPipelineIllustration
hasImagery
hasTiles
gpuReady
hasDetections={false}
hasQualityEvidence={false}
running={false}
/>,
)
const tabs = screen.getAllByRole('tab') as HTMLButtonElement[]
const selectedTab = screen.getByRole('tab', { name: /Detecties/ }) as HTMLButtonElement
const panel = screen.getByRole('tabpanel')
expect(selectedTab.tabIndex).toBe(0)
expect(tabs.filter((tab) => tab.tabIndex === 0)).toHaveLength(1)
expect(selectedTab.getAttribute('aria-controls')).toBe(panel.id)
expect(panel.getAttribute('aria-labelledby')).toBe(selectedTab.id)
selectedTab.focus()
fireEvent.keyDown(selectedTab, { key: 'ArrowRight' })
expect(screen.getByRole('tab', { name: /QA-bewijs/ }).getAttribute('aria-selected')).toBe('true')
expect(document.activeElement).toBe(screen.getByRole('tab', { name: /QA-bewijs/ }))
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'ArrowRight' })
expect(document.activeElement).toBe(screen.getByRole('tab', { name: /Orthofoto/ }))
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'End' })
expect(document.activeElement).toBe(screen.getByRole('tab', { name: /QA-bewijs/ }))
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'Home' })
expect(document.activeElement).toBe(screen.getByRole('tab', { name: /Orthofoto/ }))
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'ArrowLeft' })
const wrappedTab = screen.getByRole('tab', { name: /QA-bewijs/ })
expect(document.activeElement).toBe(wrappedTab)
expect(screen.getByRole('tabpanel').getAttribute('aria-labelledby')).toBe(wrappedTab.id)
}) })
}) })
@@ -1,4 +1,4 @@
import { useState } from 'react' import { useId, useRef, useState, type KeyboardEvent } from 'react'
import { BadgeCheck, Boxes, Cpu, Image, ScanSearch } from 'lucide-react' import { BadgeCheck, Boxes, Cpu, Image, ScanSearch } from 'lucide-react'
interface AiPipelineIllustrationProps { interface AiPipelineIllustrationProps {
@@ -12,8 +12,8 @@ interface AiPipelineIllustrationProps {
const pipelineStages = [ const pipelineStages = [
{ key: 'imagery', label: 'Orthofoto', title: 'Gegeorefereerd bronbeeld', detail: 'CRS, resolutie en ruimtelijke dekking blijven bij de dataset bewaard.', icon: Image }, { key: 'imagery', label: 'Orthofoto', title: 'Gegeorefereerd bronbeeld', detail: 'CRS, resolutie en ruimtelijke dekking blijven bij de dataset bewaard.', icon: Image },
{ key: 'tiles', label: 'Beeldtegels', title: 'Controleerbare tilevoorbereiding', detail: 'Overlap en tile-identiteit houden detecties herleidbaar naar hun bronpixel.', icon: Boxes }, { key: 'tiles', label: 'Beeldtegels', title: 'Beeld opgedeeld in controleerbare tegels', detail: 'Elke tegel houdt zijn overlap en herkomst bij, zodat elke detectie terug te voeren is op de bronpixel.', icon: Boxes },
{ key: 'gpu', label: 'NVIDIA GPU', title: 'Lokale PyTorch-inferentie', detail: 'GeoIntel gebruikt de server-GPU en faalt gesloten wanneer CUDA vereist maar niet beschikbaar is.', icon: Cpu }, { key: 'gpu', label: 'Berekening', title: 'De herkenning draait lokaal', detail: 'De analyse gebruikt de GPU van de server. Is die niet beschikbaar, dan stopt de analyse in plaats van een resultaat te maken waarop u niet kunt bouwen.', icon: Cpu },
{ key: 'detections', label: 'Detecties', title: 'Gegeorefereerde gebouwobjecten', detail: 'Confidence, modelversie, brontegel en geometrie worden als reproduceerbaar resultaat bewaard.', icon: ScanSearch }, { key: 'detections', label: 'Detecties', title: 'Gegeorefereerde gebouwobjecten', detail: 'Confidence, modelversie, brontegel en geometrie worden als reproduceerbaar resultaat bewaard.', icon: ScanSearch },
{ key: 'quality', label: 'QA-bewijs', title: 'Controle vóór vrijgave', detail: 'Precision, recall, IoU en foutbewijs bepalen of een resultaat alleen verkennend of operationeel bruikbaar is.', icon: BadgeCheck }, { key: 'quality', label: 'QA-bewijs', title: 'Controle vóór vrijgave', detail: 'Precision, recall, IoU en foutbewijs bepalen of een resultaat alleen verkennend of operationeel bruikbaar is.', icon: BadgeCheck },
] as const ] as const
@@ -29,33 +29,69 @@ export function AiPipelineIllustration({
const readiness = [hasImagery, hasTiles, gpuReady, hasDetections, hasQualityEvidence] const readiness = [hasImagery, hasTiles, gpuReady, hasDetections, hasQualityEvidence]
const firstIncomplete = readiness.findIndex((ready) => !ready) const firstIncomplete = readiness.findIndex((ready) => !ready)
const [selectedIndex, setSelectedIndex] = useState(firstIncomplete === -1 ? 4 : firstIncomplete) const [selectedIndex, setSelectedIndex] = useState(firstIncomplete === -1 ? 4 : firstIncomplete)
const tabRefs = useRef<Array<HTMLButtonElement | null>>([])
const componentId = useId()
const titleId = `${componentId}-title`
const panelId = `${componentId}-panel`
const selected = pipelineStages[selectedIndex] const selected = pipelineStages[selectedIndex]
const selectAndFocus = (index: number) => {
setSelectedIndex(index)
tabRefs.current[index]?.focus()
}
const handleTabKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
let nextIndex: number | null = null
switch (event.key) {
case 'ArrowRight':
nextIndex = (index + 1) % pipelineStages.length
break
case 'ArrowLeft':
nextIndex = (index - 1 + pipelineStages.length) % pipelineStages.length
break
case 'Home':
nextIndex = 0
break
case 'End':
nextIndex = pipelineStages.length - 1
break
default:
return
}
event.preventDefault()
selectAndFocus(nextIndex)
}
return ( return (
<section className={running ? 'ai-pipeline ai-pipeline-running' : 'ai-pipeline'} aria-labelledby="ai-pipeline-title"> <section className={running ? 'ai-pipeline ai-pipeline-running' : 'ai-pipeline'} aria-labelledby={titleId}>
<div className="ai-pipeline-heading"> <div className="ai-pipeline-heading">
<div> <div>
<p className="eyebrow">Van pixel naar bewijs</p> <p className="eyebrow">Van pixel naar bewijs</p>
<h3 id="ai-pipeline-title">PyTorch-keten op de NVIDIA-server</h3> <h3 id={titleId}>Van luchtbeeld naar controleerbare detectie</h3>
<p>Open een schakel om te zien welke technische context GeoIntel door de volledige analyse bewaart.</p> <p>Open een schakel om te zien welke technische context GeoIntel door de volledige analyse bewaart.</p>
</div> </div>
<span className={gpuReady ? 'ai-pipeline-gpu ai-pipeline-gpu-ready' : 'ai-pipeline-gpu'}> <span className={gpuReady ? 'ai-pipeline-gpu ai-pipeline-gpu-ready' : 'ai-pipeline-gpu'}>
<i /> {gpuReady ? 'CUDA gereed' : 'CUDA controleren'} <i /> {gpuReady ? 'GPU gereed' : 'GPU controleren'}
</span> </span>
</div> </div>
<div className="ai-pipeline-track" role="tablist" aria-label="PyTorch-analysekten"> <div className="ai-pipeline-track" role="tablist" aria-label="Stappen in de analyseketen">
<span className="ai-pipeline-flow" aria-hidden="true" /> <span className="ai-pipeline-flow" aria-hidden="true" />
{pipelineStages.map(({ key, label, icon: Icon }, index) => ( {pipelineStages.map(({ key, label, icon: Icon }, index) => (
<button <button
key={key} key={key}
id={`ai-pipeline-${key}`} id={`${componentId}-${key}`}
ref={(element) => { tabRefs.current[index] = element }}
type="button" type="button"
role="tab" role="tab"
aria-selected={selectedIndex === index} aria-selected={selectedIndex === index}
aria-controls="ai-pipeline-detail" aria-controls={panelId}
tabIndex={selectedIndex === index ? 0 : -1}
className={readiness[index] ? 'ai-pipeline-stage ai-pipeline-stage-ready' : 'ai-pipeline-stage'} className={readiness[index] ? 'ai-pipeline-stage ai-pipeline-stage-ready' : 'ai-pipeline-stage'}
onClick={() => setSelectedIndex(index)} onClick={() => setSelectedIndex(index)}
onKeyDown={(event) => handleTabKeyDown(event, index)}
> >
<span><Icon aria-hidden="true" /></span> <span><Icon aria-hidden="true" /></span>
<strong>{label}</strong> <strong>{label}</strong>
@@ -65,10 +101,11 @@ export function AiPipelineIllustration({
</div> </div>
<div <div
id="ai-pipeline-detail" id={panelId}
className="ai-pipeline-detail" className="ai-pipeline-detail"
role="tabpanel" role="tabpanel"
aria-labelledby={`ai-pipeline-${selected.key}`} aria-labelledby={`${componentId}-${selected.key}`}
tabIndex={0}
key={selected.key} key={selected.key}
> >
<span>{String(selectedIndex + 1).padStart(2, '0')}</span> <span>{String(selectedIndex + 1).padStart(2, '0')}</span>
@@ -6,6 +6,7 @@ import type {
DetectionRead, DetectionRead,
DetectionRunRead, DetectionRunRead,
DetectionRunResponse, DetectionRunResponse,
JobRead,
ModelAssetRead, ModelAssetRead,
QualityCheckRead, QualityCheckRead,
YoloPreflightResponse, YoloPreflightResponse,
@@ -15,7 +16,7 @@ import { DETECTION_OPERATOR_PROFILES, type DetectionOperatorProfile } from './de
import { DetectionModelManagement, detectionModelLabel } from './DetectionModelManagement' import { DetectionModelManagement, detectionModelLabel } from './DetectionModelManagement'
import { AiPipelineIllustration } from './AiPipelineIllustration' import { AiPipelineIllustration } from './AiPipelineIllustration'
import { ModelSelector } from '../models/ModelSelector' import { ModelSelector } from '../models/ModelSelector'
import { toAnalysisModelOption } from '../models/modelOptions' import { analysisModelAvailabilityMessage, toAnalysisModelOption } from '../models/modelOptions'
const DETECTION_PAGE_SIZE_OPTIONS = [25, 50, 100] as const const DETECTION_PAGE_SIZE_OPTIONS = [25, 50, 100] as const
const DEFAULT_DETECTION_PAGE_SIZE = 50 const DEFAULT_DETECTION_PAGE_SIZE = 50
@@ -87,6 +88,7 @@ interface DetectionLabProps {
detectionTileManifestPath: string detectionTileManifestPath: string
detectionConfidenceThreshold: number detectionConfidenceThreshold: number
runningDetection: boolean runningDetection: boolean
detectionJob: JobRead | null
detectionRunResult: DetectionRunResponse | null detectionRunResult: DetectionRunResponse | null
detectionRunError: string | null detectionRunError: string | null
detectionRuns: DetectionRunRead[] detectionRuns: DetectionRunRead[]
@@ -151,6 +153,7 @@ export function DetectionLab({
detectionTileManifestPath, detectionTileManifestPath,
detectionConfidenceThreshold, detectionConfidenceThreshold,
runningDetection, runningDetection,
detectionJob,
detectionRunResult, detectionRunResult,
detectionRunError, detectionRunError,
detectionRuns, detectionRuns,
@@ -206,14 +209,19 @@ export function DetectionLab({
(profile) => profile.modelAssetId === selectedModelAssetId, (profile) => profile.modelAssetId === selectedModelAssetId,
) ?? null ) ?? null
const yoloRuntimeReady = Boolean( const yoloRuntimeReady = Boolean(
yoloPreflight?.checks.enabled && yoloPreflight?.checks?.enabled &&
yoloPreflight.checks.dependencies_available && yoloPreflight.checks?.dependencies_available &&
yoloPreflight.checks.model_file_exists, yoloPreflight.checks?.accelerator_ready === true &&
yoloPreflight.checks?.model_file_exists,
) )
const detectionRequiresTileManifest = selectedDetectionModelId === 'yolo-configured' const detectionRequiresTileManifest = selectedDetectionModelId === 'yolo-configured'
const detectionJobActive = detectionJob?.status === 'queued' || detectionJob?.status === 'running'
const detectionHasDataset = selectedDetectionDatasetId.length > 0 const detectionHasDataset = selectedDetectionDatasetId.length > 0
const detectionHasModel = selectedDetectionModel !== null const detectionHasModel = selectedDetectionModel !== null
const detectionModelReady = Boolean(selectedDetectionModel?.configured) const detectionModelReady = Boolean(selectedDetectionModel?.configured)
const selectedDetectionModelAvailability = selectedDetectionModel
? analysisModelAvailabilityMessage(selectedDetectionModel)
: 'Het gekozen model is niet geconfigureerd'
const detectionModelUiRunnable = detectionModelReady && selectedDetectionModelId !== 'manual-fixture-detector' const detectionModelUiRunnable = detectionModelReady && selectedDetectionModelId !== 'manual-fixture-detector'
const detectionHasExplicitModelAsset = const detectionHasExplicitModelAsset =
selectedDetectionModelId !== 'yolo-configured' || modelAssets.length === 0 || selectedModelAssetId.length > 0 selectedDetectionModelId !== 'yolo-configured' || modelAssets.length === 0 || selectedModelAssetId.length > 0
@@ -259,7 +267,7 @@ export function DetectionLab({
: selectedDetectionModelId === 'manual-fixture-detector' : selectedDetectionModelId === 'manual-fixture-detector'
? 'Het fixturemodel is alleen bedoeld voor expliciete tests en demo\'s' ? 'Het fixturemodel is alleen bedoeld voor expliciete tests en demo\'s'
: !detectionModelReady : !detectionModelReady
? selectedDetectionModel?.limitation_message ?? 'Het gekozen model is niet geconfigureerd' ? selectedDetectionModelAvailability
: !detectionHasExplicitModelAsset : !detectionHasExplicitModelAsset
? 'Kies een lokaal modelbestand onder beheer' ? 'Kies een lokaal modelbestand onder beheer'
: !detectionHasTileManifest : !detectionHasTileManifest
@@ -275,7 +283,7 @@ export function DetectionLab({
: selectedDetectionModelId === 'manual-fixture-detector' : selectedDetectionModelId === 'manual-fixture-detector'
? 'Het fixturemodel is alleen bedoeld voor expliciete tests en demo\'s' ? 'Het fixturemodel is alleen bedoeld voor expliciete tests en demo\'s'
: !detectionModelReady : !detectionModelReady
? selectedDetectionModel?.limitation_message ?? 'Het gekozen model is niet geconfigureerd' ? selectedDetectionModelAvailability
: !detectionHasExplicitModelAsset : !detectionHasExplicitModelAsset
? 'Kies een lokaal modelbestand onder beheer' ? 'Kies een lokaal modelbestand onder beheer'
: null : null
@@ -308,7 +316,7 @@ export function DetectionLab({
<p>{selectedOperatorProfile?.displayName ?? selectedModelAsset?.display_name ?? 'Lokaal YOLO-model'}</p> <p>{selectedOperatorProfile?.displayName ?? selectedModelAsset?.display_name ?? 'Lokaal YOLO-model'}</p>
</div> </div>
<div className={yoloRuntimeReady ? 'ai-user-summary-card ai-user-summary-card-ready' : 'ai-user-summary-card'}> <div className={yoloRuntimeReady ? 'ai-user-summary-card ai-user-summary-card-ready' : 'ai-user-summary-card'}>
<span>PyTorch-runtime</span> <span>Rekenomgeving</span>
<strong>{yoloRuntimeReady ? 'Gereed' : loadingDetectionModels ? 'Controleren...' : 'Niet gereed'}</strong> <strong>{yoloRuntimeReady ? 'Gereed' : loadingDetectionModels ? 'Controleren...' : 'Niet gereed'}</strong>
<p>{yoloRuntimeReady ? `${yoloPreflight?.runtime.cuda_available ? 'GPU' : 'CPU'} · lokaal model gevonden` : 'Controleer de modelconfiguratie onder beheer.'}</p> <p>{yoloRuntimeReady ? `${yoloPreflight?.runtime.cuda_available ? 'GPU' : 'CPU'} · lokaal model gevonden` : 'Controleer de modelconfiguratie onder beheer.'}</p>
</div> </div>
@@ -499,8 +507,8 @@ export function DetectionLab({
<DetectionWorkflowStep label="3. Modelcontrole" complete={detectionWorkflowStage === 'detecting' || detectionWorkflowStage === 'loading' || detectionWorkflowStage === 'complete'} active={detectionWorkflowStage === 'validating'} /> <DetectionWorkflowStep label="3. Modelcontrole" complete={detectionWorkflowStage === 'detecting' || detectionWorkflowStage === 'loading' || detectionWorkflowStage === 'complete'} active={detectionWorkflowStage === 'validating'} />
<DetectionWorkflowStep label="4. Resultaat" complete={detectionWorkflowStage === 'complete'} active={detectionWorkflowStage === 'detecting' || detectionWorkflowStage === 'loading'} /> <DetectionWorkflowStep label="4. Resultaat" complete={detectionWorkflowStage === 'complete'} active={detectionWorkflowStage === 'detecting' || detectionWorkflowStage === 'loading'} />
</div> </div>
<button className="primary-action guided-detection-action" type="button" onClick={onPrepareAndRunDetection} disabled={runningDetection || !guidedDetectionReady}> <button className="primary-action guided-detection-action" type="button" onClick={onPrepareAndRunDetection} disabled={runningDetection || runningDetectionCalibration || detectionJobActive || !guidedDetectionReady}>
{detectionWorkflowActionLabel(detectionWorkflowStage)} {detectionWorkflowActionLabel(detectionWorkflowStage, detectionJob?.status)}
</button> </button>
{!managementLocked ? <details className="ai-lab-model-surface technical-manifest-surface" aria-label="Technische tegelinstellingen"> {!managementLocked ? <details className="ai-lab-model-surface technical-manifest-surface" aria-label="Technische tegelinstellingen">
@@ -528,7 +536,7 @@ export function DetectionLab({
<span>De technische controle wordt vernieuwd wanneer het model of tegelbestand wijzigt.</span> <span>De technische controle wordt vernieuwd wanneer het model of tegelbestand wijzigt.</span>
</div> </div>
) : null} ) : null}
<button className="secondary-action" type="button" onClick={onRunDetection} disabled={runningDetection || !detectionRunReady}> <button className="secondary-action" type="button" onClick={onRunDetection} disabled={runningDetection || runningDetectionCalibration || detectionJobActive || !detectionRunReady}>
Bestaande beeldtegels analyseren Bestaande beeldtegels analyseren
</button> </button>
</div> </div>
@@ -537,15 +545,26 @@ export function DetectionLab({
</div> </div>
<div className="ai-lab-state-stack"> <div className="ai-lab-state-stack">
{detectionJob && (detectionJob.status === 'queued' || detectionJob.status === 'running') ? (
<div className="result-state" role="status" aria-live="polite">
<strong>{detectionJob.status === 'queued' ? 'GPU-taak staat in de wachtrij.' : 'GPU-analyse wordt uitgevoerd.'}</strong>
<p>
{detectionJob.status === 'queued'
? 'De server heeft de aanvraag veilig bewaard en start ze zodra de NVIDIA-worker beschikbaar is.'
: 'Het model verwerkt de beeldtegels op de server. Dit scherm volgt de bewaarde taak automatisch.'}
</p>
<span className="muted">Taak-ID: {detectionJob.id}</span>
</div>
) : null}
{detectionRunError ? ( {detectionRunError ? (
<div className="result-state result-state-error"> <div className="result-state result-state-error" role="alert">
<strong>De beeldanalyse is mislukt.</strong> <strong>{detectionJobActive ? 'Het volgen van de servertaak is onderbroken.' : 'De beeldanalyse is mislukt.'}</strong>
<p>{detectionRunError}</p> <p>{detectionRunError}</p>
</div> </div>
) : null} ) : null}
{detectionRunResult ? ( {detectionRunResult ? (
<div className="result-summary-card"> <div className={detectionRunResult.detection_count === 0 ? 'result-state result-state-warning' : 'result-summary-card'} role="status">
<p>Status: {detectionRunResult.status === 'completed' ? 'afgerond' : detectionRunResult.status}</p> <p>Status: {detectionStatusLabel(detectionRunResult.status)}</p>
<p>{detectionRunResult.message}</p> <p>{detectionRunResult.message}</p>
<p>Gevonden objecten: {detectionRunResult.detection_count}</p> <p>Gevonden objecten: {detectionRunResult.detection_count}</p>
{detectionRunResult.error_code ? <p className="error">Code: {detectionRunResult.error_code}</p> : null} {detectionRunResult.error_code ? <p className="error">Code: {detectionRunResult.error_code}</p> : null}
@@ -602,7 +621,7 @@ export function DetectionLab({
className="primary-action" className="primary-action"
type="button" type="button"
onClick={onRunCalibration} onClick={onRunCalibration}
disabled={runningDetectionCalibration || !calibrationRunReady} disabled={runningDetectionCalibration || runningDetection || detectionJobActive || !calibrationRunReady}
> >
Drempels vergelijken Drempels vergelijken
</button> </button>
@@ -927,7 +946,7 @@ export function DetectionLab({
) : null} ) : null}
{detectionQaResult ? ( {detectionQaResult ? (
<div className="result-summary-card"> <div className="result-summary-card">
<p>Status: {detectionQaResult.status === 'completed' ? 'afgerond' : detectionQaResult.status}</p> <p>Status: {detectionStatusLabel(detectionQaResult.status)}</p>
<p>Precisie: {detectionQaResult.precision?.toFixed(3) ?? 'n.v.t.'}</p> <p>Precisie: {detectionQaResult.precision?.toFixed(3) ?? 'n.v.t.'}</p>
<p>Herkenningsgraad: {detectionQaResult.recall?.toFixed(3) ?? 'n.v.t.'}</p> <p>Herkenningsgraad: {detectionQaResult.recall?.toFixed(3) ?? 'n.v.t.'}</p>
<p>F1: {detectionQaResult.f1_score?.toFixed(3) ?? 'n.v.t.'}</p> <p>F1: {detectionQaResult.f1_score?.toFixed(3) ?? 'n.v.t.'}</p>
@@ -1042,10 +1061,11 @@ function DetectionWorkflowStep({
) )
} }
function detectionWorkflowActionLabel(stage: DetectionWorkflowStage): string { function detectionWorkflowActionLabel(stage: DetectionWorkflowStage, jobStatus?: string): string {
if (stage === 'tiling') return 'Beeldtegels voorbereiden...' if (stage === 'tiling') return 'Beeldtegels voorbereiden...'
if (stage === 'validating') return 'Model en beeld controleren...' if (stage === 'validating') return 'Model en beeld controleren...'
if (stage === 'detecting') return 'Gebouwen zoeken...' if (stage === 'detecting' && jobStatus === 'queued') return 'Wachten op NVIDIA GPU...'
if (stage === 'detecting') return 'Gebouwen zoeken op NVIDIA GPU...'
if (stage === 'loading') return 'Resultaat op kaart laden...' if (stage === 'loading') return 'Resultaat op kaart laden...'
if (stage === 'complete') return 'Analyse opnieuw uitvoeren' if (stage === 'complete') return 'Analyse opnieuw uitvoeren'
return 'Gebouwen zoeken en op kaart tonen' return 'Gebouwen zoeken en op kaart tonen'
@@ -4,6 +4,7 @@ import type {
YoloPreflightResponse, YoloPreflightResponse,
} from '../../types' } from '../../types'
import { DETECTION_OPERATOR_PROFILES, type DetectionOperatorProfile } from './detectionProfiles' import { DETECTION_OPERATOR_PROFILES, type DetectionOperatorProfile } from './detectionProfiles'
import { analysisModelAvailabilityMessage } from '../models/modelOptions'
interface DetectionModelManagementProps { interface DetectionModelManagementProps {
detectionModels: DetectionModelCapability[] detectionModels: DetectionModelCapability[]
@@ -39,6 +40,8 @@ function statusLabel(value: string): string {
if (value === 'configured' || value === 'ready') return 'gereed' if (value === 'configured' || value === 'ready') return 'gereed'
if (value === 'not_configured') return 'niet geconfigureerd' if (value === 'not_configured') return 'niet geconfigureerd'
if (value === 'dependency_unavailable') return 'software ontbreekt' if (value === 'dependency_unavailable') return 'software ontbreekt'
if (value === 'accelerator_unavailable') return 'GPU niet beschikbaar'
if (value === 'contract_incomplete') return 'provenance onvolledig'
return value.replace(/_/g, ' ') return value.replace(/_/g, ' ')
} }
@@ -65,6 +68,7 @@ export function DetectionModelManagement({
const yoloRuntimeReady = Boolean( const yoloRuntimeReady = Boolean(
yoloPreflight?.checks.enabled yoloPreflight?.checks.enabled
&& yoloPreflight.checks.dependencies_available && yoloPreflight.checks.dependencies_available
&& yoloPreflight.checks.accelerator_ready === true
&& yoloPreflight.checks.model_file_exists, && yoloPreflight.checks.model_file_exists,
) )
@@ -110,7 +114,7 @@ export function DetectionModelManagement({
{statusLabel(model.status)} {statusLabel(model.status)}
</span> </span>
<p className="muted">Ondersteunde klassen: {model.supported_classes.join(', ') || 'niet opgegeven'}</p> <p className="muted">Ondersteunde klassen: {model.supported_classes.join(', ') || 'niet opgegeven'}</p>
<p className="muted">{model.limitation_message}</p> <p className="muted">{analysisModelAvailabilityMessage(model)}</p>
<details className="technical-inline-details"> <details className="technical-inline-details">
<summary>Technische identificatie</summary> <summary>Technische identificatie</summary>
<div className="entity-meta"> <div className="entity-meta">
@@ -193,7 +193,7 @@ export function ExportCenter({
<span className="count-pill">{exports.length} bestanden</span> <span className="count-pill">{exports.length} bestanden</span>
</div> </div>
<div className="export-summary-surface" aria-label="Export summary"> <div className="export-summary-surface" aria-label="Samenvatting van de downloads">
<div className="quality-summary-grid"> <div className="quality-summary-grid">
<div> <div>
<span>GeoJSON</span> <span>GeoJSON</span>
@@ -210,7 +210,7 @@ export function ExportCenter({
</div> </div>
</div> </div>
<div className="export-handoff-surface" aria-label="Export handoff readiness"> <div className="export-handoff-surface" aria-label="Gereedheid voor overdracht">
<div className="handoff-summary-card"> <div className="handoff-summary-card">
<div className="panel-title-row"> <div className="panel-title-row">
<div> <div>
@@ -275,14 +275,14 @@ export function ExportCenter({
})} })}
{availableLatestArtifacts.length === 0 ? ( {availableLatestArtifacts.length === 0 ? (
<div className="result-state result-state-empty latest-download-empty"> <div className="result-state result-state-empty latest-download-empty">
<strong>Nog geen downloads gemaakt</strong> <strong>Nog geen recente downloads</strong>
<p>Kies hieronder een rapport of de actieve kaartlaag.</p> <p>De laatst bewaarde bestanden verschijnen hier.</p>
</div> </div>
) : null} ) : null}
</div> </div>
</div> </div>
<div className="export-actions-surface" aria-label="Export artifact actions"> <div className="export-actions-surface" aria-label="Acties om resultaten te bewaren">
<div className="panel-title-row"> <div className="panel-title-row">
<div> <div>
<h3>Wat wil je bewaren?</h3> <h3>Wat wil je bewaren?</h3>
@@ -377,13 +377,13 @@ export function ExportCenter({
) : null} ) : null}
{exports.length === 0 ? ( {exports.length === 0 ? (
<div className="result-state result-state-empty"> <div className="result-state result-state-empty">
<strong>Nog geen downloads gemaakt.</strong> <strong>Nog niets bewaard</strong>
<p>Kies hierboven een leesbaar rapport, projectoverzicht of kaartlaag.</p> <p>Maak een leesbaar rapport, een projectoverzicht, of bewaar de actieve kaartlaag.</p>
</div> </div>
) : null} ) : null}
</div> </div>
<details className="export-history-surface export-history-disclosure" aria-label="Export history"> <details className="export-history-surface export-history-disclosure" aria-label="Downloadgeschiedenis">
<summary> <summary>
<span>Downloadgeschiedenis</span> <span>Downloadgeschiedenis</span>
<strong>{exports.length} bestanden</strong> <strong>{exports.length} bestanden</strong>
@@ -394,7 +394,7 @@ export function ExportCenter({
<p className="muted">Bekijk persistente bestanden en open beschikbare JSON-voorbeelden.</p> <p className="muted">Bekijk persistente bestanden en open beschikbare JSON-voorbeelden.</p>
</div> </div>
{exports.length > 0 ? ( {exports.length > 0 ? (
<div className="export-history-controls" aria-label="Export history filters"> <div className="export-history-controls" aria-label="Filters op de downloadgeschiedenis">
<label> <label>
Downloads zoeken Downloads zoeken
<input <input
@@ -33,7 +33,7 @@ export function ExportPreview({ content }: ExportPreviewProps): JSX.Element {
</div> </div>
{content ? ( {content ? (
<> <>
<div className="export-preview-summary" aria-label="Export preview summary"> <div className="export-preview-summary" aria-label="Samenvatting van het voorbeeld">
<div className="export-preview-summary-card"> <div className="export-preview-summary-card">
<span>Type inhoud</span> <span>Type inhoud</span>
<strong>{previewStats?.rootType}</strong> <strong>{previewStats?.rootType}</strong>
@@ -189,7 +189,7 @@ export function WorkbenchInspector({
<InspectorField label="Type controle" value={latestQualityCheck?.check_type?.replaceAll('_', ' ')} /> <InspectorField label="Type controle" value={latestQualityCheck?.check_type?.replaceAll('_', ' ')} />
<InspectorField label="Status" value={latestQualityCheck?.status} /> <InspectorField label="Status" value={latestQualityCheck?.status} />
<InspectorField label="Score" value={latestQualityCheck?.score} /> <InspectorField label="Score" value={latestQualityCheck?.score} />
<InspectorField label="Meetwaarden" value={latestQualityCheck?.metrics.length} /> <InspectorField label="Meetwaarden" value={latestQualityCheck?.metrics?.length} />
<div className="button-row"> <div className="button-row">
<button type="button" className="secondary-action" onClick={onOpenQualityWorkspace}> <button type="button" className="secondary-action" onClick={onOpenQualityWorkspace}>
Kwaliteit openen Kwaliteit openen
@@ -887,7 +887,7 @@ export function MapAdvancedWorkbench({ props, view }: MapAdvancedWorkbenchProps)
<p className="muted">Bewaarde afgeleide laag: {latestSelectionDatasetName}</p> <p className="muted">Bewaarde afgeleide laag: {latestSelectionDatasetName}</p>
) : null} ) : null}
{latestSelectionDatasetName ? ( {latestSelectionDatasetName ? (
<div className="map-selection-qa-surface" aria-label="Map selection QA shortcut"> <div className="map-selection-qa-surface" aria-label="Snelkoppeling naar de kwaliteitscontrole van de selectie">
<label> <label>
Referentielaag Referentielaag
<select <select
@@ -913,7 +913,7 @@ export function MapAdvancedWorkbench({ props, view }: MapAdvancedWorkbenchProps)
</button> </button>
{mapSelectionQaError ? <p className="error">{mapSelectionQaError}</p> : null} {mapSelectionQaError ? <p className="error">{mapSelectionQaError}</p> : null}
{mapSelectionQaResult ? ( {mapSelectionQaResult ? (
<div className="map-selection-qa-evidence" aria-label="Map selection QA result"> <div className="map-selection-qa-evidence" aria-label="Kwaliteitsresultaat van de kaartselectie">
<div className="panel-title-row"> <div className="panel-title-row">
<div> <div>
<p className="eyebrow">Kaartbewijs</p> <p className="eyebrow">Kaartbewijs</p>
@@ -977,7 +977,7 @@ export function MapAdvancedWorkbench({ props, view }: MapAdvancedWorkbenchProps)
</div> </div>
) : null} ) : null}
{areaSelectionPreviewFeatures.length > 0 ? ( {areaSelectionPreviewFeatures.length > 0 ? (
<div className="table-scroll feature-property-table" aria-label="Area selection feature table"> <div className="table-scroll feature-property-table" aria-label="Tabel met objecten in de gebiedsselectie">
<table> <table>
<thead> <thead>
<tr> <tr>
+30 -15
View File
@@ -16,6 +16,7 @@ import { datasetCoversSelectedArea, datasetProductKey, floodScenarioLabel, forma
import type { MapWorkspaceProps } from './mapWorkspaceProps' import type { MapWorkspaceProps } from './mapWorkspaceProps'
import type { MapWorkspaceViewModel } from './useMapWorkspaceViewModel' import type { MapWorkspaceViewModel } from './useMapWorkspaceViewModel'
import { ThemeSearchField } from './ThemeSearchField'
interface MapExplorerViewProps { interface MapExplorerViewProps {
props: MapWorkspaceProps props: MapWorkspaceProps
@@ -254,6 +255,14 @@ export function MapExplorerView({ props, view }: MapExplorerViewProps): JSX.Elem
visibleThemes, visibleThemes,
walloniaScopeSelected, walloniaScopeSelected,
} = view } = view
const resultsError = (
analysisMode === 'evolution'
? [temporalComparisonError]
: [mapSelectionError, themeResultsError]
)
.filter((message): message is string => Boolean(message))
.filter((message, index, messages) => messages.indexOf(message) === index)
.join(' ')
return ( return (
<section <section
@@ -369,16 +378,7 @@ export function MapExplorerView({ props, view }: MapExplorerViewProps): JSX.Elem
: 'Zoek optioneel een gemeente of teken vrij op de kaart'} : 'Zoek optioneel een gemeente of teken vrij op de kaart'}
</small> </small>
</div> </div>
<label className="geo-theme-search"> <ThemeSearchField waarde={themeFilter} onChange={setThemeFilter} />
<span className="sr-only">Zoek een thema of gegevensbron</span>
<Search aria-hidden="true" />
<input
type="search"
value={themeFilter}
onChange={(event) => setThemeFilter(event.target.value)}
placeholder="Zoek themas"
/>
</label>
<div className="geo-theme-list"> <div className="geo-theme-list">
{visibleThemes.map((theme) => { {visibleThemes.map((theme) => {
const dataset = themeDatasetMap[theme.id] const dataset = themeDatasetMap[theme.id]
@@ -414,7 +414,7 @@ export function MapExplorerView({ props, view }: MapExplorerViewProps): JSX.Elem
: 'Niet beschikbaar'} : 'Niet beschikbaar'}
</small> </small>
</span> </span>
<i>{active ? 'Gekozen' : available ? 'Kies' : '—'}</i> <i>{workspaceLoading ? 'Laden' : active ? 'Gekozen' : available ? 'Kies' : '—'}</i>
</button> </button>
) )
})} })}
@@ -867,6 +867,25 @@ export function MapExplorerView({ props, view }: MapExplorerViewProps): JSX.Elem
<span /> <span />
<strong>De gekozen bronnen worden begrensd geladen en geanalyseerd</strong> <strong>De gekozen bronnen worden begrensd geladen en geanalyseerd</strong>
</div> </div>
) : resultsError ? (
<div className="geo-results-error" role="alert">
<strong>De analyse kon niet worden voltooid</strong>
<p>{resultsError}</p>
<button
className="secondary-action"
type="button"
disabled={analysisMode === 'evolution' ? !temporalSelectionValid : selectedThemes.length === 0}
onClick={() => {
if (analysisMode === 'evolution') {
runTemporalComparison()
} else if (mapSelectionBbox) {
void analyzeSelection(mapSelectionBbox, areaIdForSelection(mapSelectionBbox))
}
}}
>
Opnieuw proberen
</button>
</div>
) : analysisMode === 'current' && themeInsights.length === 0 && !mapSelectionResult ? ( ) : analysisMode === 'current' && themeInsights.length === 0 && !mapSelectionResult ? (
<div className="geo-results-empty"> <div className="geo-results-empty">
<strong>Nog niet geanalyseerd</strong> <strong>Nog niet geanalyseerd</strong>
@@ -1027,10 +1046,6 @@ export function MapExplorerView({ props, view }: MapExplorerViewProps): JSX.Elem
{analysisMode === 'current' && activeSelectionResult?.summary?.warning ? ( {analysisMode === 'current' && activeSelectionResult?.summary?.warning ? (
<p className="geo-data-notice">{activeSelectionResult.summary.warning}</p> <p className="geo-data-notice">{activeSelectionResult.summary.warning}</p>
) : null} ) : null}
{mapSelectionError ? <p className="error">{mapSelectionError}</p> : null}
{themeResultsError ? <p className="error">{themeResultsError}</p> : null}
{temporalComparisonError ? <p className="error">{temporalComparisonError}</p> : null}
{analysisMode === 'current' && selectedResultProperties.length > 0 ? ( {analysisMode === 'current' && selectedResultProperties.length > 0 ? (
<details className="geo-result-details"> <details className="geo-result-details">
<summary>Kenmerken van de gevonden objecten</summary> <summary>Kenmerken van de gevonden objecten</summary>
@@ -0,0 +1,54 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ThemeSearchField } from './ThemeSearchField'
describe('ThemeSearchField', () => {
afterEach(() => {
cleanup()
vi.useRealTimers()
})
it('toont wat de gebruiker typt zonder daarop te wachten', () => {
vi.useFakeTimers()
const onChange = vi.fn()
render(<ThemeSearchField waarde="" onChange={onChange} />)
const veld = screen.getByRole('searchbox')
fireEvent.change(veld, { target: { value: 'beb' } })
expect((veld as HTMLInputElement).value).toBe('beb')
// De boven­liggende werkruimte weet er nog niets van; die hertekende
// voorheen bij elke letter.
expect(onChange).not.toHaveBeenCalled()
})
it('geeft de waarde één keer door wanneer het typen stopt', () => {
vi.useFakeTimers()
const onChange = vi.fn()
render(<ThemeSearchField waarde="" onChange={onChange} />)
const veld = screen.getByRole('searchbox')
for (const tekst of ['b', 'be', 'beb', 'bebo', 'bebou', 'bebouw']) {
fireEvent.change(veld, { target: { value: tekst } })
vi.advanceTimersByTime(40)
}
expect(onChange).not.toHaveBeenCalled()
vi.advanceTimersByTime(200)
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange).toHaveBeenCalledWith('bebouw')
})
it('volgt een waarde die van buitenaf gewist wordt', () => {
const onChange = vi.fn()
const { rerender } = render(<ThemeSearchField waarde="bebouw" onChange={onChange} />)
expect((screen.getByRole('searchbox') as HTMLInputElement).value).toBe('bebouw')
rerender(<ThemeSearchField waarde="" onChange={onChange} />)
expect((screen.getByRole('searchbox') as HTMLInputElement).value).toBe('')
})
it('houdt een toegankelijke naam', () => {
render(<ThemeSearchField waarde="" onChange={vi.fn()} />)
expect(screen.getByRole('searchbox', { name: /zoek een thema/i })).toBeTruthy()
})
})
@@ -0,0 +1,55 @@
import { useEffect, useRef, useState } from 'react'
import { Search } from 'lucide-react'
interface ThemeSearchFieldProps {
waarde: string
onChange: (waarde: string) => void
}
/**
* Het zoekveld boven de themalijst, met zijn eigen invoertoestand.
*
* De filterwaarde zelf leeft in het viewmodel van de kaartwerkruimte, dat de
* hele werkruimte voedt. Rechtstreeks doorgeven betekende dat elke toetsaanslag
* de volledige boom hertekende, inclusief de kaart: gemeten 33 tot 58 ms per
* letter met lege data, en dat loopt op zodra er echt bronnen in staan.
*
* Wat de gebruiker typt blijft nu hier. Pas als het even stil is gaat de waarde
* naar boven. Typen voelt daardoor direct, terwijl de lijst een fractie later
* bijtrekt wat bij zoeken ook het gewenste gedrag is, want filteren op elke
* losse letter levert toch geen bruikbaar tussenresultaat.
*/
const STILTE_MS = 160
export function ThemeSearchField({ waarde, onChange }: ThemeSearchFieldProps): JSX.Element {
const [invoer, setInvoer] = useState(waarde)
const onChangeRef = useRef(onChange)
onChangeRef.current = onChange
// Wordt de waarde van buitenaf gewist (bijvoorbeeld bij een andere
// werkruimte), dan volgt het veld.
useEffect(() => {
setInvoer((huidig) => (huidig === waarde ? huidig : waarde))
}, [waarde])
useEffect(() => {
if (invoer === waarde) return
const teller = window.setTimeout(() => onChangeRef.current(invoer), STILTE_MS)
return () => window.clearTimeout(teller)
// waarde bewust niet in de lijst: die verandert door onze eigen melding.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [invoer])
return (
<label className="geo-theme-search">
<span className="sr-only">Zoek een thema of gegevensbron</span>
<Search aria-hidden="true" />
<input
type="search"
value={invoer}
onChange={(event) => setInvoer(event.target.value)}
placeholder="Zoek themas"
/>
</label>
)
}
@@ -0,0 +1,91 @@
/**
* Kaartsymbologie.
*
* De kleuren van de kaartlagen stonden als losse hexwaarden door GeoMap.tsx
* heen. Het waren framework-standaardkleuren die bij geen enkel token uit het
* designsysteem hoorden, en op de donkere operationele ondergrond vielen de
* donkere varianten volledig weg.
*
* Deze waarden horen bij de donkere werkstand en zijn afgestemd op de tokens
* in geointel-system.css, sectie "Operationeel donker". Ze staan hier als
* letterlijke waarden omdat MapLibre paint-eigenschappen geen CSS-variabelen
* kunnen lezen; wijzigt een token, dan wijzigt zijn tegenhanger hier mee.
*/
export const mapSymbology = {
/** Officiële grenzen. Komt overeen met --gi-brand-500. */
boundary: '#45cfb4',
boundaryStrong: '#38c0a5',
/** Verandering tussen twee meetmomenten. */
added: '#4cc48d',
removed: '#ec7d76',
modified: '#dda45e',
unchanged: '#6fb0dd',
/** Eigen ingeladen data. Oranje blijft de tegenkleur van het teal-merk. */
dataFill: '#fb923c',
dataLine: '#f97316',
/** Actieve selectie. Geel leest op elke ondergrond. */
selectionFill: '#fde047',
selectionLine: '#fbbf24',
/** Water en bathymetrie. */
waterFill: '#38bdf8',
waterLine: '#7dd3fc',
/** Modelresultaten uit beeldanalyse. */
detectionFill: '#a78bfa',
detectionLine: '#c4b5fd',
/** Randen van puntsymbolen, tegen elke vulkleur. */
pointStroke: '#ffffff',
/** Terugvalkleur wanneer een feature geen bekende klasse heeft. */
fallback: '#94a3b8',
} as const
export type KaartWerkstand = 'dark' | 'light'
/**
* De verf van de ondergrond.
*
* De tegels zijn consumentenkaarten: rode snelwegen, groene bossen,
* POI-drukte. In beide werkstanden worden ze ontkleurd zodat alleen de eigen
* data nog kleur draagt; het verschil zit in hoe ver ze gedempt worden.
*
* De featurekleuren hierboven blijven in beide werkstanden gelijk. Het zijn
* middentonen die op een lichte én een donkere ondergrond leesbaar zijn, en
* één set houdt de betekenis van een kleur constant wanneer een gebruiker
* tussen de twee wisselt.
*/
export function basemapPaint(werkstand: KaartWerkstand): Record<string, number> {
if (werkstand === 'light') {
return {
'raster-saturation': -0.62,
'raster-brightness-min': 0.32,
'raster-brightness-max': 1,
'raster-contrast': -0.14,
'raster-hue-rotate': 140,
'raster-opacity': 0.92,
}
}
return {
'raster-saturation': -0.78,
'raster-brightness-min': 0.03,
'raster-brightness-max': 0.44,
'raster-contrast': -0.08,
'raster-hue-rotate': 140,
'raster-opacity': 0.9,
}
}
export function basemapGround(werkstand: KaartWerkstand): string {
return werkstand === 'light' ? '#eef2f0' : '#0a100e'
}
export function huidigeWerkstand(): KaartWerkstand {
if (typeof document === 'undefined') return 'dark'
return document.body.dataset.theme === 'light' ? 'light' : 'dark'
}
@@ -328,13 +328,17 @@ export function useMapWorkspaceViewModel({
) )
const activeTheme = DATA_THEMES.find((theme) => theme.id === activeThemeId) ?? DATA_THEMES[0] const activeTheme = DATA_THEMES.find((theme) => theme.id === activeThemeId) ?? DATA_THEMES[0]
const activeCoverageTheme = COVERAGE_THEME_BY_MAP_THEME[activeTheme.id] const activeCoverageTheme = COVERAGE_THEME_BY_MAP_THEME[activeTheme.id]
const activeCoverageItems = coverage?.items.filter((item) => item.theme === activeCoverageTheme) ?? [] // Eén normalisatie: hiervoor stond er een guard op de filter, geen op de
// reduce en geen op de some. Half geguard is de eigenlijke fout — het wekt
// zekerheid zonder die te bieden.
const dekkingsItems = coverage?.items ?? []
const activeCoverageItems = dekkingsItems.filter((item) => item.theme === activeCoverageTheme)
const coverageCounts = useMemo( const coverageCounts = useMemo(
() => coverage?.items.reduce<Record<CoverageStatus, number>>( () => dekkingsItems.reduce<Record<CoverageStatus, number>>(
(counts, item) => ({ ...counts, [item.status]: counts[item.status] + 1 }), (counts, item) => ({ ...counts, [item.status]: counts[item.status] + 1 }),
{ operational: 0, partial: 0, not_configured: 0, unsupported: 0 }, { operational: 0, partial: 0, not_configured: 0, unsupported: 0 },
) ?? { operational: 0, partial: 0, not_configured: 0, unsupported: 0 }, ),
[coverage], [dekkingsItems],
) )
const onDemandProductsForZones = useCallback((zones: string[] | null): OnDemandMapProduct[] => { const onDemandProductsForZones = useCallback((zones: string[] | null): OnDemandMapProduct[] => {
const result: OnDemandMapProduct[] = [] const result: OnDemandMapProduct[] = []
@@ -534,7 +538,7 @@ export function useMapWorkspaceViewModel({
return false return false
} }
const coverageTheme = COVERAGE_THEME_BY_MAP_THEME[theme.id] const coverageTheme = COVERAGE_THEME_BY_MAP_THEME[theme.id]
return coverage.items.some( return dekkingsItems.some(
(item) => item.theme === coverageTheme && item.status === 'operational', (item) => item.theme === coverageTheme && item.status === 'operational',
) )
}) })
@@ -18,16 +18,43 @@ describe('ModelSelector', () => {
it('opens the selector and returns an available model choice', () => { it('opens the selector and returns an available model choice', () => {
const onChange = vi.fn() const onChange = vi.fn()
render(<ModelSelector label="AI-model" value="automatic" options={options} onChange={onChange} automaticOption={{ id: 'automatic', name: 'Automatisch aanbevolen', status: 'available', tone: 'recommended' }} />) render(<ModelSelector label="AI-model" value="automatic" options={options} onChange={onChange} automaticOption={{ id: 'automatic', name: 'Automatisch aanbevolen', status: 'available', tone: 'recommended' }} />)
fireEvent.click(screen.getByRole('button', { name: /Automatisch aanbevolen/ })) const trigger = screen.getByRole('button', { name: /Automatisch aanbevolen/ })
expect(trigger.getAttribute('aria-expanded')).toBe('false')
fireEvent.click(trigger)
expect(trigger.getAttribute('aria-expanded')).toBe('true')
expect(trigger.getAttribute('aria-controls')).toBe(screen.getByRole('dialog').id)
expect(document.activeElement).toBe(screen.getByRole('radio', { name: /Automatisch aanbevolen/ }))
fireEvent.click(screen.getByText('Concrete modellen')) fireEvent.click(screen.getByText('Concrete modellen'))
fireEvent.click(screen.getByRole('radio', { name: /Snel lokaal model/ })) fireEvent.click(screen.getByRole('radio', { name: /Snel lokaal model/ }))
expect(onChange).toHaveBeenCalledWith('fast') expect(onChange).toHaveBeenCalledWith('fast')
expect(trigger.getAttribute('aria-expanded')).toBe('false')
expect(document.activeElement).toBe(trigger)
}) })
it('keeps unavailable runtime models disabled', () => { it('keeps unavailable runtime models disabled', () => {
render(<ModelSelector label="Analysemodel" value="fast" options={options} onChange={vi.fn()} />) render(<ModelSelector label="Analysemodel" value="fast" options={options} onChange={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: /Snel lokaal model/ })) const trigger = screen.getByRole('button', { name: /Snel lokaal model/ })
fireEvent.click(screen.getByText('Concrete modellen')) fireEvent.click(trigger)
expect(document.activeElement).toBe(screen.getByRole('radio', { name: /Snel lokaal model/ }))
expect((screen.getByRole('radio', { name: /Niet geconfigureerd/ }) as HTMLButtonElement).disabled).toBe(true) expect((screen.getByRole('radio', { name: /Niet geconfigureerd/ }) as HTMLButtonElement).disabled).toBe(true)
}) })
it('closes predictably and restores trigger focus after close or cancel', () => {
render(<ModelSelector label="Analysemodel" value="fast" options={options} onChange={vi.fn()} />)
const trigger = screen.getByRole('button', { name: /Snel lokaal model/ })
fireEvent.click(trigger)
fireEvent.click(screen.getByRole('button', { name: 'Modelkeuze sluiten' }))
expect(trigger.getAttribute('aria-expanded')).toBe('false')
expect(document.activeElement).toBe(trigger)
fireEvent.click(trigger)
const dialog = screen.getByRole('dialog')
fireEvent(dialog, new Event('cancel', { bubbles: false, cancelable: true }))
expect(trigger.getAttribute('aria-expanded')).toBe('false')
expect(dialog.hasAttribute('open')).toBe(false)
expect(document.activeElement).toBe(trigger)
})
}) })
@@ -53,7 +53,11 @@ export function ModelSelector({
advancedLabel = 'Concrete modellen', advancedLabel = 'Concrete modellen',
}: ModelSelectorProps): JSX.Element { }: ModelSelectorProps): JSX.Element {
const dialogRef = useRef<HTMLDialogElement>(null) const dialogRef = useRef<HTMLDialogElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const closeButtonRef = useRef<HTMLButtonElement>(null)
const titleId = useId() const titleId = useId()
const dialogId = useId()
const [isOpen, setIsOpen] = useState(false)
const [showAdvanced, setShowAdvanced] = useState(false) const [showAdvanced, setShowAdvanced] = useState(false)
const allOptions = useMemo( const allOptions = useMemo(
() => automaticOption ? [automaticOption, ...options] : options, () => automaticOption ? [automaticOption, ...options] : options,
@@ -64,27 +68,44 @@ export function ModelSelector({
?? null ?? null
useEffect(() => { useEffect(() => {
if (!dialogRef.current?.open) return if (!isOpen || !dialogRef.current?.open) return
const selectedButton = dialogRef.current.querySelector<HTMLElement>('[aria-checked="true"]') const selectedButton = dialogRef.current.querySelector<HTMLButtonElement>('[role="radio"][aria-checked="true"]:not(:disabled)')
selectedButton?.focus() const firstAvailableButton = dialogRef.current.querySelector<HTMLButtonElement>('[role="radio"]:not(:disabled)')
}, [showAdvanced]) ;(selectedButton ?? firstAvailableButton ?? closeButtonRef.current)?.focus()
}, [isOpen, showAdvanced, value])
const openDialog = () => {
const dialog = dialogRef.current
if (!dialog || dialog.open) return
setShowAdvanced(options.some((option) => option.id === value))
dialog.showModal()
setIsOpen(true)
}
const closeDialog = () => {
if (dialogRef.current?.open) dialogRef.current.close()
setIsOpen(false)
triggerRef.current?.focus()
}
const select = (option: ModelSelectionOption) => { const select = (option: ModelSelectionOption) => {
if (option.status !== 'available') return if (option.status !== 'available') return
onChange(option.id) onChange(option.id)
dialogRef.current?.close() closeDialog()
} }
return ( return (
<div className="model-selector"> <div className="model-selector">
<span className="model-selector-label">{label}</span> <span className="model-selector-label">{label}</span>
<button <button
ref={triggerRef}
type="button" type="button"
className="model-selector-trigger" className="model-selector-trigger"
aria-haspopup="dialog" aria-haspopup="dialog"
aria-expanded={dialogRef.current?.open ?? false} aria-expanded={isOpen}
aria-controls={dialogId}
disabled={disabled || loading || allOptions.length === 0} disabled={disabled || loading || allOptions.length === 0}
onClick={() => dialogRef.current?.showModal()} onClick={openDialog}
> >
<span className="model-selector-trigger-icon"><Bot aria-hidden="true" /></span> <span className="model-selector-trigger-icon"><Bot aria-hidden="true" /></span>
<span> <span>
@@ -94,14 +115,27 @@ export function ModelSelector({
<ChevronDown aria-hidden="true" /> <ChevronDown aria-hidden="true" />
</button> </button>
<dialog ref={dialogRef} className="model-selector-dialog" aria-labelledby={titleId}> <dialog
id={dialogId}
ref={dialogRef}
className="model-selector-dialog"
aria-labelledby={titleId}
onCancel={(event) => {
event.preventDefault()
closeDialog()
}}
onClose={() => {
setIsOpen(false)
triggerRef.current?.focus()
}}
>
<div className="model-selector-dialog-header"> <div className="model-selector-dialog-header">
<div> <div>
<span className="section-kicker">Taakgerichte modelkeuze</span> <span className="section-kicker">Taakgerichte modelkeuze</span>
<h2 id={titleId}>Kies hoe GeoIntel analyseert</h2> <h2 id={titleId}>Kies hoe GeoIntel analyseert</h2>
<p>GeoIntel toont alleen modellen die door de huidige omgeving worden gerapporteerd.</p> <p>GeoIntel toont alleen modellen die door de huidige omgeving worden gerapporteerd.</p>
</div> </div>
<button type="button" className="icon-action" aria-label="Modelkeuze sluiten" onClick={() => dialogRef.current?.close()}> <button ref={closeButtonRef} type="button" className="icon-action" aria-label="Modelkeuze sluiten" onClick={closeDialog}>
<X aria-hidden="true" /> <X aria-hidden="true" />
</button> </button>
</div> </div>
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import type { DetectionModelCapability } from '../../types'
import {
analysisModelAvailabilityMessage,
analysisModelDisplayName,
toAnalysisModelOption,
} from './modelOptions'
function model(overrides: Partial<DetectionModelCapability> = {}): DetectionModelCapability {
return {
model_id: 'segmentation-placeholder',
display_name: 'Segmentation placeholder',
framework: 'none',
task_type: 'segmentation',
supported_classes: [],
configured: false,
status: 'not_configured',
limitation_message: 'Segmentation inference is not configured for this placeholder.',
validated_regions: [],
nationally_validated: false,
operator_review_required: true,
...overrides,
}
}
describe('analysis model availability copy', () => {
it('does not expose raw English backend placeholder copy in the Dutch UI', () => {
const capability = model()
expect(analysisModelAvailabilityMessage(capability)).toContain('nog geen productiegeschikt segmentatiemodel')
expect(analysisModelDisplayName(capability)).toBe('Segmentatiemodel nog niet geconfigureerd')
expect(toAnalysisModelOption(capability).description).not.toContain('Segmentation inference')
})
it('explains an unavailable NVIDIA runtime explicitly', () => {
const capability = model({
model_id: 'yolo-configured',
task_type: 'object_detection',
status: 'accelerator_unavailable',
})
expect(analysisModelAvailabilityMessage(capability)).toContain('NVIDIA CUDA')
})
})
+53 -3
View File
@@ -1,15 +1,65 @@
import type { DetectionModelCapability } from '../../types' import type { DetectionModelCapability } from '../../types'
import type { ModelSelectionOption } from './ModelSelector' import type { ModelSelectionOption } from './ModelSelector'
export function analysisModelDisplayName(model: DetectionModelCapability): string {
const knownNames: Record<string, string> = {
'yolo-configured': 'Lokaal gebouwmodel',
'manual-fixture-detector': 'Testdetectie (geen productie)',
'yolo-placeholder': 'Gebouwmodel nog niet geconfigureerd',
'segmentation-placeholder': 'Segmentatiemodel nog niet geconfigureerd',
'fixture-segmenter': 'Testsegmentatie (geen productie)',
'yolo-seg-configured': 'Lokaal YOLO-segmentatiemodel',
'sam-configured': 'Lokaal SAM-segmentatiemodel',
'yolo-seg-placeholder': 'YOLO-segmentatie nog niet geconfigureerd',
'sam-placeholder': 'SAM-segmentatie nog niet geconfigureerd',
}
return knownNames[model.model_id] ?? model.display_name
}
function supportedClassLabel(value: string): string {
const labels: Record<string, string> = {
building: 'gebouwen',
vegetation: 'vegetatie',
water: 'water',
landuse: 'landgebruik',
segment: 'algemene vlakken',
}
return labels[value.toLowerCase()] ?? value
}
export function analysisModelAvailabilityMessage(model: DetectionModelCapability): string {
const task = model.task_type === 'segmentation' ? 'segmentatiemodel' : 'detectiemodel'
if (model.model_id === 'manual-fixture-detector' || model.model_id === 'fixture-segmenter') {
return 'Alleen beschikbaar voor expliciete geautomatiseerde tests; dit is geen productie-inferentie.'
}
if (model.configured) {
return `Dit lokale ${task} is op de server geconfigureerd. Resultaten blijven operatorcontrole vereisen.`
}
if (model.status === 'accelerator_unavailable') {
return 'De vereiste NVIDIA CUDA-runtime is momenteel niet beschikbaar op de server.'
}
if (model.status === 'dependency_unavailable') {
return 'De vereiste PyTorch- of modelsoftware is nog niet beschikbaar op de server.'
}
if (model.status === 'contract_incomplete') {
return 'Het modelbestand is aanwezig, maar de versieerbare provenancecontrole is nog niet volledig.'
}
if (model.model_id.includes('placeholder')) {
return `Er is nog geen productiegeschikt ${task} aan deze registratie gekoppeld.`
}
return `Dit ${task} is nog niet volledig geconfigureerd op de server.`
}
export function toAnalysisModelOption(model: DetectionModelCapability): ModelSelectionOption { export function toAnalysisModelOption(model: DetectionModelCapability): ModelSelectionOption {
const task = model.task_type === 'segmentation' ? 'segmentatie' : 'objectdetectie' const task = model.task_type === 'segmentation' ? 'segmentatie' : 'objectdetectie'
const configured = model.configured && model.status !== 'not_configured' const configured = model.configured && model.status !== 'not_configured'
const supportedClasses = model.supported_classes.map(supportedClassLabel)
return { return {
id: model.model_id, id: model.model_id,
name: model.display_name, name: analysisModelDisplayName(model),
description: configured description: configured
? `Beschikbaar voor lokale ${task}${model.supported_classes.length ? ` van ${model.supported_classes.join(', ')}` : ''}.` ? `Beschikbaar voor lokale ${task}${supportedClasses.length ? ` van ${supportedClasses.join(', ')}` : ''}.`
: model.limitation_message, : analysisModelAvailabilityMessage(model),
recommendation: model.validation_scope ? `Gevalideerd voor ${model.validation_scope}.` : undefined, recommendation: model.validation_scope ? `Gevalideerd voor ${model.validation_scope}.` : undefined,
status: configured ? 'available' : 'unavailable', status: configured ? 'available' : 'unavailable',
statusLabel: configured ? 'Beschikbaar' : 'Niet geconfigureerd', statusLabel: configured ? 'Beschikbaar' : 'Niet geconfigureerd',
@@ -0,0 +1,41 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ProviderPanel } from './ProviderPanel'
import type { ProviderCapability } from '../../types'
function props(overschrijf: Partial<Parameters<typeof ProviderPanel>[0]> = {}) {
return {
selectedProjectId: 'p-1',
providers: [] as ProviderCapability[],
loadingCapabilities: false,
capabilitiesError: null,
onRefresh: vi.fn(),
onOpenSources: vi.fn(),
onOpenStatus: vi.fn(),
onOpenMap: vi.fn(),
...overschrijf,
}
}
describe('ProviderPanel', () => {
afterEach(() => cleanup())
it('blijft overeind bij een antwoord dat zijn eigen contract schendt', () => {
// Het type zegt dat providers altijd meekomt. Dit paneel gebruikte die
// lijst op drie plekken en had er op één een guard; nu wordt hij aan de
// kop genormaliseerd. Een type is een belofte van de compiler, niet van
// het netwerk.
const zonder = props({ providers: undefined as unknown as ProviderCapability[] })
expect(() => render(<ProviderPanel {...zonder} />)).not.toThrow()
})
it('toont een lege toestand zonder koppelingen', () => {
render(<ProviderPanel {...props()} />)
expect(screen.getByText(/geen databronnen gemeld/i)).toBeTruthy()
})
it('meldt het wanneer de status niet opgehaald kon worden', () => {
render(<ProviderPanel {...props({ capabilitiesError: 'Bronservice niet bereikbaar' })} />)
expect(screen.getByText(/bronservice niet bereikbaar/i)).toBeTruthy()
})
})
@@ -52,7 +52,10 @@ export function ProviderPanel({
onOpenStatus, onOpenStatus,
onOpenMap, onOpenMap,
}: ProviderPanelProps): JSX.Element { }: ProviderPanelProps): JSX.Element {
const configuredCount = providers.filter((provider) => provider.configured).length // Eén normalisatie aan de kop, in plaats van op elke gebruiksplek een guard.
// Een antwoord zonder deze lijst liet het paneel eerder vallen.
const koppelingen = providers ?? []
const configuredCount = koppelingen.filter((provider) => provider.configured).length
const [operations, setOperations] = useState<AoiOperation[]>([]) const [operations, setOperations] = useState<AoiOperation[]>([])
const [operationsError, setOperationsError] = useState<string | null>(null) const [operationsError, setOperationsError] = useState<string | null>(null)
const [loadingOperations, setLoadingOperations] = useState(false) const [loadingOperations, setLoadingOperations] = useState(false)
@@ -93,7 +96,7 @@ export function ProviderPanel({
</div> </div>
<div className="system-command-surface" aria-label="Systeemacties"> <div className="system-command-surface" aria-label="Systeemacties">
<div><span>Bronkoppelingen</span><strong>{configuredCount} van {providers.length} actief</strong></div> <div><span>Bronkoppelingen</span><strong>{configuredCount} van {koppelingen.length} actief</strong></div>
<div><span>Werkmodus</span><strong>Begrensde bronopvraging</strong></div> <div><span>Werkmodus</span><strong>Begrensde bronopvraging</strong></div>
<div className="system-command-actions"> <div className="system-command-actions">
<button type="button" className="primary-action" onClick={onOpenMap}>Open operationele kaart</button> <button type="button" className="primary-action" onClick={onOpenMap}>Open operationele kaart</button>
@@ -115,7 +118,7 @@ export function ProviderPanel({
<p>{capabilitiesError}</p> <p>{capabilitiesError}</p>
</div> </div>
) : null} ) : null}
{providers.length === 0 && !loadingCapabilities ? ( {koppelingen.length === 0 && !loadingCapabilities ? (
<div className="result-state result-state-empty"> <div className="result-state result-state-empty">
<strong>Geen databronnen gemeld.</strong> <strong>Geen databronnen gemeld.</strong>
<p>Vernieuw de status zodra de backend bereikbaar is.</p> <p>Vernieuw de status zodra de backend bereikbaar is.</p>
@@ -157,7 +160,7 @@ export function ProviderPanel({
</div> </div>
</div> </div>
<ul className="system-provider-list"> <ul className="system-provider-list">
{providers.map((provider) => ( {koppelingen.map((provider) => (
<li className="system-provider-card" key={provider.provider_name}> <li className="system-provider-card" key={provider.provider_name}>
<div className="system-provider-header"> <div className="system-provider-header">
<div> <div>
@@ -197,7 +197,7 @@ export function DetectionReviewPanel({
) : null} ) : null}
<ol className="detection-review-list"> <ol className="detection-review-list">
{queue?.items.map((item) => { {queue?.items?.map((item) => {
const key = reviewKey(item) const key = reviewKey(item)
const decision = draftDecisions[key] ?? item.decision const decision = draftDecisions[key] ?? item.decision
return ( return (
@@ -80,7 +80,7 @@ function qualityCheckTypeLabel(checkType: string | null | undefined): string {
} }
function metricByKey(check: QualityCheckRead | null, metricKey: string): MetricRead | undefined { function metricByKey(check: QualityCheckRead | null, metricKey: string): MetricRead | undefined {
return check?.metrics.find((metric) => metric.metric_key === metricKey) return check?.metrics?.find((metric) => metric.metric_key === metricKey)
} }
function findingEvidenceList(check: QualityCheckRead | null, key: string): Record<string, unknown>[] { function findingEvidenceList(check: QualityCheckRead | null, key: string): Record<string, unknown>[] {
@@ -1,5 +1,6 @@
import type { import type {
DatasetCreateResponse, DatasetCreateResponse,
JobRead,
SegmentationModelCapability, SegmentationModelCapability,
SegmentationQaResult, SegmentationQaResult,
SegmentationRead, SegmentationRead,
@@ -7,7 +8,11 @@ import type {
SegmentationRunResponse, SegmentationRunResponse,
} from '../../types' } from '../../types'
import { ModelSelector } from '../models/ModelSelector' import { ModelSelector } from '../models/ModelSelector'
import { toAnalysisModelOption } from '../models/modelOptions' import {
analysisModelAvailabilityMessage,
analysisModelDisplayName,
toAnalysisModelOption,
} from '../models/modelOptions'
interface SegmentationLabProps { interface SegmentationLabProps {
segmentationModels: SegmentationModelCapability[] segmentationModels: SegmentationModelCapability[]
@@ -18,11 +23,14 @@ interface SegmentationLabProps {
segmentationTileManifestPath: string segmentationTileManifestPath: string
segmentationConfidenceThreshold: number segmentationConfidenceThreshold: number
runningSegmentation: boolean runningSegmentation: boolean
segmentationJob: JobRead | null
segmentationRunResult: SegmentationRunResponse | null segmentationRunResult: SegmentationRunResponse | null
segmentationRunError: string | null segmentationRunError: string | null
segmentationRuns: SegmentationRunRead[] segmentationRuns: SegmentationRunRead[]
selectedSegmentationRunId: string selectedSegmentationRunId: string
segmentationItems: SegmentationRead[] segmentationItems: SegmentationRead[]
segmentationTotal: number
segmentationTruncated: boolean
segmentationClassFilter: string segmentationClassFilter: string
segmentationMinConfidenceFilter: number segmentationMinConfidenceFilter: number
loadingSegmentationResults: boolean loadingSegmentationResults: boolean
@@ -92,11 +100,14 @@ export function SegmentationLab({
segmentationTileManifestPath, segmentationTileManifestPath,
segmentationConfidenceThreshold, segmentationConfidenceThreshold,
runningSegmentation, runningSegmentation,
segmentationJob,
segmentationRunResult, segmentationRunResult,
segmentationRunError, segmentationRunError,
segmentationRuns, segmentationRuns,
selectedSegmentationRunId, selectedSegmentationRunId,
segmentationItems, segmentationItems,
segmentationTotal,
segmentationTruncated,
segmentationClassFilter, segmentationClassFilter,
segmentationMinConfidenceFilter, segmentationMinConfidenceFilter,
loadingSegmentationResults, loadingSegmentationResults,
@@ -127,8 +138,15 @@ export function SegmentationLab({
const segmentationHasTileManifest = segmentationTileManifestPath.trim().length > 0 const segmentationHasTileManifest = segmentationTileManifestPath.trim().length > 0
const segmentationModelUiRunnable = const segmentationModelUiRunnable =
selectedSegmentationModelConfigured && selectedSegmentationModelId !== 'fixture-segmenter' selectedSegmentationModelConfigured && selectedSegmentationModelId !== 'fixture-segmenter'
const selectedSegmentationModel = segmentationModels.find(
(model) => model.model_id === selectedSegmentationModelId,
) ?? null
const selectedSegmentationModelAvailability = selectedSegmentationModel
? analysisModelAvailabilityMessage(selectedSegmentationModel)
: selectedSegmentationModelLimitation ?? 'Het gekozen segmentatiemodel is niet geconfigureerd'
const segmentationRunReady = const segmentationRunReady =
Boolean(selectedProjectId) && segmentationHasDataset && segmentationModelUiRunnable Boolean(selectedProjectId) && segmentationHasDataset && segmentationModelUiRunnable && segmentationHasTileManifest
const segmentationJobActive = segmentationJob?.status === 'queued' || segmentationJob?.status === 'running'
const segmentationRunBlockedReason = !selectedProjectId const segmentationRunBlockedReason = !selectedProjectId
? 'Kies eerst een werkruimte' ? 'Kies eerst een werkruimte'
: !segmentationHasDataset : !segmentationHasDataset
@@ -136,7 +154,9 @@ export function SegmentationLab({
: selectedSegmentationModelId === 'fixture-segmenter' : selectedSegmentationModelId === 'fixture-segmenter'
? 'Het fixturemodel is alleen bedoeld voor expliciete tests en demos' ? 'Het fixturemodel is alleen bedoeld voor expliciete tests en demos'
: !selectedSegmentationModelConfigured : !selectedSegmentationModelConfigured
? selectedSegmentationModelLimitation ?? 'Het gekozen segmentatiemodel is niet geconfigureerd' ? selectedSegmentationModelAvailability
: !segmentationHasTileManifest
? 'Koppel eerst het beeldtegelmanifest van het gekozen rasterbestand'
: null : null
return ( return (
@@ -180,10 +200,10 @@ export function SegmentationLab({
<ul className="model-list"> <ul className="model-list">
{segmentationModels.map((model) => ( {segmentationModels.map((model) => (
<li className={model.configured ? 'model-card model-card-ready' : 'model-card'} key={model.model_id}> <li className={model.configured ? 'model-card model-card-ready' : 'model-card'} key={model.model_id}>
<strong>{model.display_name}</strong> <strong>{analysisModelDisplayName(model)}</strong>
<span className={model.configured ? 'status-badge status-badge-ready' : 'status-badge'}>{model.configured ? 'gereed' : 'niet geconfigureerd'}</span> <span className={model.configured ? 'status-badge status-badge-ready' : 'status-badge'}>{model.configured ? 'gereed' : 'niet geconfigureerd'}</span>
<p className="muted">Ondersteunde klassen: {model.supported_classes.join(', ') || 'niet opgegeven'}</p> <p className="muted">Ondersteunde klassen: {model.supported_classes.join(', ') || 'niet opgegeven'}</p>
<p className="muted">{model.limitation_message}</p> <p className="muted">{analysisModelAvailabilityMessage(model)}</p>
<details className="technical-inline-details"> <details className="technical-inline-details">
<summary>Technische identificatie</summary> <summary>Technische identificatie</summary>
<div className="entity-meta"> <div className="entity-meta">
@@ -219,17 +239,19 @@ export function SegmentationLab({
<span>Rasterbestand</span> <span>Rasterbestand</span>
<strong>{segmentationHasDataset ? 'Geselecteerd' : 'Kies een rasterbestand'}</strong> <strong>{segmentationHasDataset ? 'Geselecteerd' : 'Kies een rasterbestand'}</strong>
</div> </div>
<div className={selectedSegmentationModelConfigured ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}> <div className={segmentationModelUiRunnable ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>Analysemodel</span> <span>Analysemodel</span>
<strong> <strong>
{selectedSegmentationModelConfigured {selectedSegmentationModelId === 'fixture-segmenter'
? 'Alleen beschikbaar voor geautomatiseerde tests'
: selectedSegmentationModelConfigured
? 'Het gekozen model is beschikbaar' ? 'Het gekozen model is beschikbaar'
: selectedSegmentationModelLimitation ?? 'Kies een geconfigureerd segmentatiemodel'} : selectedSegmentationModelAvailability}
</strong> </strong>
</div> </div>
<div className={segmentationHasTileManifest ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}> <div className={segmentationHasTileManifest ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>Beeldtegels</span> <span>Beeldtegels</span>
<strong>{segmentationHasTileManifest ? 'Technisch manifest gekoppeld' : 'Niet vereist voor het fixturemodel'}</strong> <strong>{segmentationHasTileManifest ? 'Technisch manifest gekoppeld' : 'Koppel het tegelmanifest van het rasterbestand'}</strong>
</div> </div>
</div> </div>
</div> </div>
@@ -292,29 +314,44 @@ export function SegmentationLab({
className="primary-action" className="primary-action"
type="button" type="button"
onClick={onRunSegmentation} onClick={onRunSegmentation}
disabled={runningSegmentation || !segmentationRunReady} disabled={runningSegmentation || segmentationJobActive || !segmentationRunReady}
> >
Segmentatie starten {segmentationJob?.status === 'queued'
? 'Wachten op NVIDIA GPU…'
: runningSegmentation
? 'GPU-segmentatie wordt verwerkt…'
: 'Segmentatie starten'}
</button> </button>
</div> </div>
</div> </div>
<div className="ai-lab-state-stack"> <div className="ai-lab-state-stack">
{segmentationJobActive ? (
<div className="result-state result-state-loading" role="status" aria-live="polite">
<strong>{segmentationJob?.status === 'queued' ? 'GPU-taak staat in de wachtrij.' : 'GPU-segmentatie wordt uitgevoerd.'}</strong>
<p>
{segmentationJob?.status === 'queued'
? 'De server start de taak zodra de NVIDIA-worker beschikbaar is.'
: 'GeoIntel volgt de servertaak en toont na voltooiing alleen de werkelijk bewaarde polygonen.'}
</p>
<span className="muted">Taak-ID: {segmentationJob?.id}</span>
</div>
) : null}
{!selectedSegmentationModelConfigured ? ( {!selectedSegmentationModelConfigured ? (
<div className="result-state result-state-empty"> <div className="result-state result-state-empty">
<strong>Het segmentatiemodel is nog niet gereed.</strong> <strong>Het segmentatiemodel is nog niet gereed.</strong>
<p>{selectedSegmentationModelLimitation ?? 'Kies een geconfigureerd segmentatiemodel.'}</p> <p>{selectedSegmentationModelAvailability}</p>
</div> </div>
) : null} ) : null}
{segmentationRunError ? ( {segmentationRunError ? (
<div className="result-state result-state-error"> <div className="result-state result-state-error" role="alert">
<strong>De segmentatie is mislukt.</strong> <strong>De segmentatie is mislukt.</strong>
<p>{segmentationRunError}</p> <p>{segmentationRunError}</p>
</div> </div>
) : null} ) : null}
{segmentationRunResult ? ( {segmentationRunResult ? (
<div className="result-summary-card"> <div className={segmentationRunResult.segmentation_count === 0 ? 'result-state result-state-warning' : 'result-summary-card'} role="status">
<p>Status: {segmentationRunResult.status === 'completed' ? 'afgerond' : segmentationRunResult.status}</p> <p>Status: {analysisStatusLabel(segmentationRunResult.status)}</p>
<p>{segmentationRunResult.message}</p> <p>{segmentationRunResult.message}</p>
<p>Herkende vlakken: {segmentationRunResult.segmentation_count}</p> <p>Herkende vlakken: {segmentationRunResult.segmentation_count}</p>
{segmentationRunResult.error_code ? <p className="error">Code: {segmentationRunResult.error_code}</p> : null} {segmentationRunResult.error_code ? <p className="error">Code: {segmentationRunResult.error_code}</p> : null}
@@ -382,10 +419,30 @@ export function SegmentationLab({
</div> </div>
) : null} ) : null}
<div className="ai-lab-state-stack"> <div className="ai-lab-state-stack">
<div className="result-state result-state-ready"> {loadingSegmentationResults || segmentationRunError ? null : !selectedSegmentationRunId ? (
<strong>{segmentationItems.length} vlakken geladen</strong> <div className="result-state result-state-empty">
<p>{selectedSegmentationRunId ? 'Deze resultaten zijn bewaard in de database.' : 'Kies eerst een bewaarde analyse.'}</p> <strong>Kies eerst een bewaarde analyse.</strong>
<p>Daarna toont GeoIntel uitsluitend de polygonen van die analyserun.</p>
</div> </div>
) : segmentationTotal === 0 ? (
<div className="result-state result-state-empty">
<strong>Geen bewaarde vlakken binnen deze filters.</strong>
<p>Dit bewijst niet dat het gebied geen relevante objecten bevat.</p>
</div>
) : (
<div className={segmentationTruncated ? 'result-state result-state-warning' : 'result-state result-state-ready'}>
<strong>
{segmentationTruncated
? `${segmentationItems.length} van ${segmentationTotal} vlakken geladen`
: `${segmentationTotal} vlakken geladen`}
</strong>
<p>
{segmentationTruncated
? 'De kaart en tabel tonen een begrensde pagina. Gebruik filters om het resultaat gericht te verfijnen.'
: 'Deze resultaten zijn bewaard in de database.'}
</p>
</div>
)}
</div> </div>
{segmentationItems.length > 0 ? ( {segmentationItems.length > 0 ? (
<div className="table-scroll"> <div className="table-scroll">
@@ -18,14 +18,100 @@ function defaultGeometry(): SecondaryDisplayGeometry {
} }
} }
/**
* Neemt de opmaak van het hoofdvenster mee naar de console.
*
* De vorige versie kloonde de <link rel="stylesheet"> naar het nieuwe venster.
* Dat venster wordt geopend met window.open('') en is dus about:blank; daar
* werd de link wel in de head gezet maar nooit opgehaald nagemeten leverde
* link.sheet === null op. Gevolg: de console stond volledig onopgemaakt, in
* Times New Roman op wit, terwijl de werkbank ernaast donker was. Het verklaart
* ook waarom de paneelregels voor dit venster met !important stonden: die
* probeerden iets te overschrijven dat er nooit aankwam. Sinds de opmaak hier
* wel aankomt zijn ze overbodig gebleken en verwijderd.
*
* Nu worden de regels zelf ingeschreven. Dat is dezelfde oorsprong, dus
* cssRules is leesbaar, en er komt geen netwerkverzoek aan te pas. Lukt het
* lezen toch niet, dan valt hij terug op de gekloonde link.
*/
function copyDocumentStyles(target: Document): void { function copyDocumentStyles(target: Document): void {
const regels: string[] = []
let alleenGelezen = true
// Verwijzingen in de regels — lettertypen, iconen, achtergronden — staan
// relatief. Het nieuwe venster is about:blank en heeft dus geen basis om ze
// tegen op te lossen; zonder deze stap blijft het wachten op lettertypen die
// nooit aankomen. Ze worden hier absoluut gemaakt tegen de bron van het blad.
const maakAbsoluut = (tekst: string, basis: string): string =>
tekst.replace(/url\((['"]?)([^'")]+)\1\)/g, (heel, quote, verwijzing) => {
if (/^(data:|blob:|https?:|\/\/)/i.test(verwijzing)) return heel
try {
return `url("${new URL(verwijzing, basis).href}")`
} catch {
return heel
}
})
// @font-face gaat bewust niet mee. De bestanden komen in dit venster wel
// binnen met status 200, maar de FontFace springt nooit naar 'loaded': in een
// document dat op about:blank staat voltooit het lettertypeladen niet. Het
// gevolg was dat document.fonts.status eeuwig op 'loading' bleef en de console
// kort in terugvalletters opende. De gezichten worden in plaats daarvan
// overgenomen uit het hoofdvenster, waar ze al geladen zijn — zie
// copyLoadedFonts hieronder.
const isFontFace = (regel: CSSRule): boolean =>
typeof CSSFontFaceRule !== 'undefined' && regel instanceof CSSFontFaceRule
for (const sheet of Array.from(document.styleSheets)) {
try {
const basis = sheet.href ?? document.baseURI
const tekst = Array.from(sheet.cssRules)
.filter((regel) => !isFontFace(regel))
.map((regel) => maakAbsoluut(regel.cssText, basis))
.join('\n')
if (tekst) regels.push(tekst)
} catch {
alleenGelezen = false
}
}
if (regels.length > 0) {
const stijl = target.createElement('style')
stijl.setAttribute('data-herkomst', 'hoofdvenster')
stijl.textContent = regels.join('\n')
target.head.append(stijl)
}
if (alleenGelezen && regels.length > 0) return
// Terugval voor bladen die niet te lezen zijn, bijvoorbeeld van een ander domein.
document.head.querySelectorAll<HTMLLinkElement | HTMLStyleElement>('link[rel="stylesheet"], style').forEach((node) => { document.head.querySelectorAll<HTMLLinkElement | HTMLStyleElement>('link[rel="stylesheet"], style').forEach((node) => {
const clone = node.cloneNode(true) as HTMLLinkElement | HTMLStyleElement const clone = node.cloneNode(true) as HTMLLinkElement | HTMLStyleElement
if (clone instanceof HTMLLinkElement) clone.href = node instanceof HTMLLinkElement ? node.href : '' if (clone instanceof HTMLLinkElement && node instanceof HTMLLinkElement) clone.href = node.href
target.head.append(clone) target.head.append(clone)
}) })
} }
/**
* Neemt de al geladen lettertypegezichten van het hoofdvenster over.
*
* Een FontFace hangt niet aan een document, dus een gezicht dat hier al
* ingeladen is kan rechtstreeks aan de FontFaceSet van het nieuwe venster
* worden toegevoegd. Dat scheelt niet alleen een tweede download, het omzeilt
* ook dat het laden in een about:blank-document nooit voltooit.
*/
function copyLoadedFonts(target: Window): void {
const doel = target.document.fonts
if (!doel || typeof document.fonts === 'undefined') return
document.fonts.forEach((gezicht) => {
try {
doel.add(gezicht)
} catch {
// Al aanwezig, of dit gezicht laat zich niet overdragen.
}
})
}
function initialiseSecondaryDocument(target: Window, onClose: () => void): HTMLElement { function initialiseSecondaryDocument(target: Window, onClose: () => void): HTMLElement {
const targetDocument = target.document const targetDocument = target.document
targetDocument.title = 'GeoIntel · Analyseconsole' targetDocument.title = 'GeoIntel · Analyseconsole'
@@ -37,6 +123,7 @@ function initialiseSecondaryDocument(target: Window, onClose: () => void): HTMLE
viewport.content = 'width=device-width, initial-scale=1' viewport.content = 'width=device-width, initial-scale=1'
targetDocument.head.append(viewport) targetDocument.head.append(viewport)
copyDocumentStyles(targetDocument) copyDocumentStyles(targetDocument)
copyLoadedFonts(target)
const shell = targetDocument.createElement('div') const shell = targetDocument.createElement('div')
shell.className = 'secondary-display-shell' shell.className = 'secondary-display-shell'
@@ -11,11 +11,11 @@ import {
} from 'lucide-react' } from 'lucide-react'
import type { WorkspaceKey } from '../overview/OverviewWorkspace' import type { WorkspaceKey } from '../overview/OverviewWorkspace'
import { GeoIntelMark } from '../brand/GeoIntelBrand' import { GeoIntelMark } from '../brand/GeoIntelBrand'
import { ItWorxSignature } from '../brand/ItWorxSignature'
export interface WorkspaceNavigationItem { export interface WorkspaceNavigationItem {
key: WorkspaceKey key: WorkspaceKey
label: string label: string
navigationLabel?: string
description: string description: string
} }
@@ -79,14 +79,13 @@ export function WorkbenchNavigation({
data-testid={`workspace-nav-${item.key}`} data-testid={`workspace-nav-${item.key}`}
> >
<Icon className="nav-item-icon" aria-hidden="true" strokeWidth={1.8} /> <Icon className="nav-item-icon" aria-hidden="true" strokeWidth={1.8} />
<span>{item.label}</span> <span>{item.navigationLabel ?? item.label}</span>
</button> </button>
) )
})} })}
</div> </div>
))} ))}
</nav> </nav>
<ItWorxSignature />
</aside> </aside>
) )
} }
@@ -0,0 +1,68 @@
import { useState } from 'react'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { WorkspaceErrorBoundary } from './WorkspaceErrorBoundary'
function Struikelt({ gooi }: { gooi: boolean }): JSX.Element {
if (gooi) {
throw new Error('checks is undefined')
}
return <p>werkblad staat</p>
}
describe('WorkspaceErrorBoundary', () => {
afterEach(() => cleanup())
it('houdt de schil overeind en biedt een nieuwe poging aan', () => {
// React logt de gevangen fout zelf ook; die ruis hoort niet in de uitvoer.
const stil = vi.spyOn(console, 'error').mockImplementation(() => {})
function Proef(): JSX.Element {
const [gooi, setGooi] = useState(true)
return (
<div>
<button type="button" onClick={() => setGooi(false)}>herstel de bron</button>
<WorkspaceErrorBoundary resetKey="ai" label="Beeldanalyse">
<Struikelt gooi={gooi} />
</WorkspaceErrorBoundary>
</div>
)
}
render(<Proef />)
// De fout is opgevangen: de melding staat er, de schil eromheen ook.
expect(screen.getByTestId('workspace-error')).toBeTruthy()
expect(screen.getByText('Beeldanalyse kon niet worden getoond')).toBeTruthy()
expect(screen.getByText('checks is undefined')).toBeTruthy()
expect(screen.getByRole('button', { name: /herstel de bron/i })).toBeTruthy()
// Na herstel van de oorzaak brengt "Opnieuw proberen" het werkblad terug.
fireEvent.click(screen.getByRole('button', { name: /herstel de bron/i }))
fireEvent.click(screen.getByRole('button', { name: /opnieuw proberen/i }))
expect(screen.getByText('werkblad staat')).toBeTruthy()
stil.mockRestore()
})
it('wist de fout zodra de gebruiker naar een ander werkblad gaat', () => {
const stil = vi.spyOn(console, 'error').mockImplementation(() => {})
const { rerender } = render(
<WorkspaceErrorBoundary resetKey="ai" label="Beeldanalyse">
<Struikelt gooi />
</WorkspaceErrorBoundary>,
)
expect(screen.getByTestId('workspace-error')).toBeTruthy()
rerender(
<WorkspaceErrorBoundary resetKey="map" label="Kaart">
<Struikelt gooi={false} />
</WorkspaceErrorBoundary>,
)
expect(screen.getByText('werkblad staat')).toBeTruthy()
expect(screen.queryByTestId('workspace-error')).toBeNull()
stil.mockRestore()
})
})
@@ -0,0 +1,83 @@
import { Component, type ErrorInfo, type ReactNode } from 'react'
import { CircleAlert, RotateCcw } from 'lucide-react'
interface WorkspaceErrorBoundaryProps {
/** Verandert deze sleutel, dan probeert de grens het opnieuw. Zet hier de
actieve werkruimte in, zodat wegnavigeren de fout wist. */
resetKey: string
/** Naam van het werkblad, voor de melding. */
label: string
children: ReactNode
}
interface WorkspaceErrorBoundaryState {
error: Error | null
resetKey: string
}
/**
* Vangt een fout in één werkblad op.
*
* Zonder deze grens nam een enkele component die gooit de hele werkbank mee:
* React ontkoppelt dan de volledige boom en de operator houdt een leeg scherm
* over, inclusief de kaart en de navigatie. Dat is voor een operationeel
* gereedschap de verkeerde verhouding tussen oorzaak en gevolg.
*
* Nu blijft de schil staan. De gebruiker ziet welk werkblad het liet afweten,
* kan naar een ander werkblad, en kan dit werkblad opnieuw proberen.
*/
export class WorkspaceErrorBoundary extends Component<WorkspaceErrorBoundaryProps, WorkspaceErrorBoundaryState> {
constructor(props: WorkspaceErrorBoundaryProps) {
super(props)
this.state = { error: null, resetKey: props.resetKey }
}
static getDerivedStateFromError(error: Error): Partial<WorkspaceErrorBoundaryState> {
return { error }
}
static getDerivedStateFromProps(
props: WorkspaceErrorBoundaryProps,
state: WorkspaceErrorBoundaryState,
): Partial<WorkspaceErrorBoundaryState> | null {
if (props.resetKey !== state.resetKey) {
return { error: null, resetKey: props.resetKey }
}
return null
}
componentDidCatch(error: Error, info: ErrorInfo): void {
// De stack blijft in de console beschikbaar voor wie meekijkt; er gaat
// niets naar buiten.
console.error(`Werkblad "${this.props.label}" is gestopt:`, error, info.componentStack)
}
private retry = (): void => {
this.setState({ error: null })
}
render(): ReactNode {
const { error } = this.state
if (!error) {
return this.props.children
}
return (
<section className="workspace-error" role="alert" data-testid="workspace-error">
<CircleAlert aria-hidden="true" />
<div>
<strong>{this.props.label} kon niet worden getoond</strong>
<p>
De rest van de werkbank blijft bruikbaar. Ga naar een ander werkblad, of probeer dit werkblad
opnieuw te openen.
</p>
<p className="workspace-error-detail">{error.message}</p>
</div>
<button type="button" className="secondary-action" onClick={this.retry}>
<RotateCcw aria-hidden="true" />
<span>Opnieuw proberen</span>
</button>
</section>
)
}
}
@@ -0,0 +1,59 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { SourceFreshnessPanel } from './SourceFreshnessPanel'
import type { SourceFreshnessReport } from '../../types'
function props(overschrijf: Partial<Parameters<typeof SourceFreshnessPanel>[0]> = {}) {
return {
report: null,
loading: false,
error: null,
onRefresh: vi.fn(),
catalogReport: null,
catalogLoading: false,
catalogError: null,
onProbeCatalogs: vi.fn(),
grbRefreshPlan: null,
grbRefreshPlanLoading: false,
grbRefreshPlanError: null,
...overschrijf,
}
}
const leegRapport: SourceFreshnessReport = {
project_id: 'p-1',
generated_at: '2026-08-23T09:00:00Z',
summary: {
source_count: 0,
dataset_count: 0,
current_count: 0,
due_count: 0,
review_required_count: 0,
local_count: 0,
sources_with_integrity_issues: 0,
integrity_issue_count: 0,
},
items: [],
limitations: [],
}
describe('SourceFreshnessPanel', () => {
afterEach(() => cleanup())
it('blijft overeind bij een antwoord dat zijn eigen contract schendt', () => {
// Het type zegt dat items altijd meekomt, maar een type is een belofte van
// de compiler en geen garantie van het netwerk. Vandaar de optionele keten
// in de component; deze test legt vast dat die er blijft.
const zonderItems = { ...leegRapport, items: undefined } as unknown as SourceFreshnessReport
expect(() => render(<SourceFreshnessPanel {...props({ report: zonderItems })} />)).not.toThrow()
})
it('toont een leeg rapport zonder te struikelen', () => {
expect(() => render(<SourceFreshnessPanel {...props({ report: leegRapport })} />)).not.toThrow()
})
it('meldt een fout in plaats van hem te verzwijgen', () => {
render(<SourceFreshnessPanel {...props({ error: 'Kon de brondekking niet ophalen' })} />)
expect(screen.getByText(/kon de brondekking niet ophalen/i)).toBeTruthy()
})
})
@@ -142,7 +142,10 @@ export function SourceFreshnessPanel({
grbRefreshPlanLoading, grbRefreshPlanLoading,
grbRefreshPlanError, grbRefreshPlanError,
}: SourceFreshnessPanelProps): JSX.Element { }: SourceFreshnessPanelProps): JSX.Element {
const attentionItems = report?.items.filter((item) => item.status === 'due' || item.status === 'review_required') ?? [] // Eén normalisatie aan de kop in plaats van een guard per gebruiksplek; de
// vorige versie had er één op de filter en geen op de map.
const bronnen = report?.items ?? []
const attentionItems = bronnen.filter((item) => item.status === 'due' || item.status === 'review_required')
const summary = report?.summary const summary = report?.summary
return ( return (
@@ -191,7 +194,7 @@ export function SourceFreshnessPanel({
<strong>gecontroleerd {formatDate(report.generated_at)}</strong> <strong>gecontroleerd {formatDate(report.generated_at)}</strong>
</summary> </summary>
<div className="source-freshness-list"> <div className="source-freshness-list">
{report.items.map((item) => <SourceRow item={item} key={item.source_name} />)} {bronnen.map((item) => <SourceRow item={item} key={item.source_name} />)}
</div> </div>
<p className="source-freshness-limitation">{report.limitations.join(' ')}</p> <p className="source-freshness-limitation">{report.limitations.join(' ')}</p>
</details> </details>
@@ -38,7 +38,7 @@ export function useChangeDetectionWorkflow({
const targetDatasetId = const targetDatasetId =
changeTargetDatasetId || availableVectorDatasets.find((dataset) => dataset.id !== sourceDatasetId)?.id changeTargetDatasetId || availableVectorDatasets.find((dataset) => dataset.id !== sourceDatasetId)?.id
if (!sourceDatasetId || !targetDatasetId) { if (!sourceDatasetId || !targetDatasetId) {
setChangeDetectionError('Select two vector datasets') setChangeDetectionError('Kies twee vectorbronnen')
return return
} }
if (sourceDatasetId === targetDatasetId) { if (sourceDatasetId === targetDatasetId) {
@@ -85,6 +85,28 @@ describe('useCoverageResolver', () => {
expect(result.current.coverageDurationMs).toBeNull() expect(result.current.coverageDurationMs).toBeNull()
}) })
it('clears stale coverage as soon as a different selection starts resolving', async () => {
const nextBbox = { ...bbox, min_x: 5.1, max_x: 5.2 }
const { result, rerender } = renderHook(
({ selection }) => useCoverageResolver({ projectId: 'project-1', bbox: selection }),
{ initialProps: { selection: bbox } },
)
await act(async () => {
await vi.advanceTimersByTimeAsync(250)
})
expect(result.current.coverage).toEqual(coverageResult)
rerender({ selection: nextBbox })
expect(result.current.coverage).toBeNull()
expect(result.current.loadingCoverage).toBe(true)
await act(async () => {
await vi.advanceTimersByTimeAsync(249)
})
expect(mocks.resolveCoverage).toHaveBeenCalledTimes(1)
})
it('exposes provider failures without retaining stale results', async () => { it('exposes provider failures without retaining stale results', async () => {
mocks.resolveCoverage.mockRejectedValueOnce(new Error('provider unavailable')) mocks.resolveCoverage.mockRejectedValueOnce(new Error('provider unavailable'))
const { result } = renderHook(() => useCoverageResolver({ projectId: 'project-1', bbox })) const { result } = renderHook(() => useCoverageResolver({ projectId: 'project-1', bbox }))
+6 -3
View File
@@ -26,11 +26,14 @@ export function useCoverageResolver({ projectId, bbox }: CoverageResolverOptions
return return
} }
let cancelled = false let cancelled = false
// A new AOI must never temporarily display the previous AOI's coverage.
// Clear immediately; the debounce only postpones the network request.
setCoverage(null)
setCoverageError(null)
setLoadingCoverage(true)
setCoverageDurationMs(null)
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
const startedAt = Date.now() const startedAt = Date.now()
setLoadingCoverage(true)
setCoverageError(null)
setCoverageDurationMs(null)
externalApi.resolveCoverage({ externalApi.resolveCoverage({
projectId, projectId,
bbox: { bbox: {
+17 -10
View File
@@ -82,6 +82,13 @@ export function useDatasetWorkflow({
setErrorMessage, setErrorMessage,
isVectorDatasetType, isVectorDatasetType,
}: DatasetWorkflowOptions) { }: DatasetWorkflowOptions) {
// De detailgegevens van een bron werden bij een fout stilzwijgend
// overgeslagen; het paneel bleef dan leeg zonder dat iemand wist waarom.
const meldDetailFout = (fout: unknown) => {
setErrorMessage(fout instanceof Error ? fout.message : 'De brondetails konden niet worden geladen')
return null
}
const [selectedDatasetId, setSelectedDatasetId] = useState<string | null>(null) const [selectedDatasetId, setSelectedDatasetId] = useState<string | null>(null)
const [selectedDataset, setSelectedDataset] = useState<DatasetCreateResponse | null>(null) const [selectedDataset, setSelectedDataset] = useState<DatasetCreateResponse | null>(null)
const [selectedDatasetSummary, setSelectedDatasetSummary] = useState<VectorSummary | null>(null) const [selectedDatasetSummary, setSelectedDatasetSummary] = useState<VectorSummary | null>(null)
@@ -145,7 +152,7 @@ export function useDatasetWorkflow({
datasets.find((dataset) => dataset.status === 'ready') ?? datasets.find((dataset) => dataset.status === 'ready') ??
datasets[0] datasets[0]
if (defaultDataset) { if (defaultDataset) {
loadDatasetDetails(selectedProjectId, defaultDataset).catch(() => null) loadDatasetDetails(selectedProjectId, defaultDataset).catch(meldDetailFout)
} }
}, [datasets, isVectorDatasetType, selectedDatasetId, selectedProjectId]) }, [datasets, isVectorDatasetType, selectedDatasetId, selectedProjectId])
@@ -223,15 +230,15 @@ export function useDatasetWorkflow({
const uploadDataset = async (event: FormEvent) => { const uploadDataset = async (event: FormEvent) => {
event.preventDefault() event.preventDefault()
if (!selectedProjectId || !datasetForm.file) { if (!selectedProjectId || !datasetForm.file) {
setErrorMessage('Select project and upload a file') setErrorMessage('Kies een werkruimte en voeg een bestand toe')
return return
} }
if (!datasetForm.datasetRole) { if (!datasetForm.datasetRole) {
setErrorMessage('Select dataset role') setErrorMessage('Kies de rol van deze bron')
return return
} }
if (datasetForm.datasetRole === 'reference' && datasetForm.datasetType !== 'vector' && datasetForm.datasetType !== 'geojson') { if (datasetForm.datasetRole === 'reference' && datasetForm.datasetType !== 'vector' && datasetForm.datasetType !== 'geojson') {
setErrorMessage('Reference role requires vector dataset upload') setErrorMessage('Een referentierol vraagt om een vectorbestand')
return return
} }
if (datasetForm.sourceMetadataJson) { if (datasetForm.sourceMetadataJson) {
@@ -273,7 +280,7 @@ export function useDatasetWorkflow({
setDatasetForm((previous) => ({ ...previous, file: null })) setDatasetForm((previous) => ({ ...previous, file: null }))
await loadProjectData(selectedProjectId) await loadProjectData(selectedProjectId)
} catch (error) { } catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'Failed to upload dataset') setErrorMessage(error instanceof Error ? error.message : 'De bron kon niet worden ingeladen')
} }
} }
@@ -284,7 +291,7 @@ export function useDatasetWorkflow({
await Promise.all([loadDatasetJobs(selectedProjectId, selectedDatasetId), loadProjectData(selectedProjectId)]) await Promise.all([loadDatasetJobs(selectedProjectId, selectedDatasetId), loadProjectData(selectedProjectId)])
const refreshed = datasets.find((dataset) => dataset.id === selectedDatasetId) const refreshed = datasets.find((dataset) => dataset.id === selectedDatasetId)
if (refreshed) { if (refreshed) {
loadDatasetDetails(selectedProjectId, refreshed).catch(() => null) loadDatasetDetails(selectedProjectId, refreshed).catch(meldDetailFout)
} }
} }
@@ -293,7 +300,7 @@ export function useDatasetWorkflow({
return return
} }
if (!selectedClipAreaId) { if (!selectedClipAreaId) {
setDatasetDetailError('Select an area for clipping') setDatasetDetailError('Kies eerst een gebied om op bij te snijden')
return return
} }
setDatasetDetailError(null) setDatasetDetailError(null)
@@ -330,7 +337,7 @@ export function useDatasetWorkflow({
} }
const targetId = selectedIntersectTargetId || availableVectorTargets[0]?.id const targetId = selectedIntersectTargetId || availableVectorTargets[0]?.id
if (!targetId) { if (!targetId) {
setDatasetDetailError('Select an intersect target dataset') setDatasetDetailError('Kies eerst een bron om mee te doorsnijden')
return return
} }
setDatasetDetailError(null) setDatasetDetailError(null)
@@ -414,7 +421,7 @@ export function useDatasetWorkflow({
return return
} }
if (!selectedClipAreaId) { if (!selectedClipAreaId) {
setDatasetDetailError('Select an area for raster clip') setDatasetDetailError('Kies eerst een gebied om het raster op bij te snijden')
return return
} }
try { try {
@@ -521,7 +528,7 @@ export function useDatasetWorkflow({
if (selectedDataset?.id === datasetId) { if (selectedDataset?.id === datasetId) {
setSelectedDataset(refreshed) setSelectedDataset(refreshed)
if (isVectorDatasetType(refreshed.dataset_type)) { if (isVectorDatasetType(refreshed.dataset_type)) {
loadDatasetDetails(selectedProjectId, refreshed).catch(() => null) loadDatasetDetails(selectedProjectId, refreshed).catch(meldDetailFout)
} }
} }
} }
@@ -0,0 +1,51 @@
import { cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useDeferredBackground } from './useDeferredBackground'
function Blok(): JSX.Element {
const ref = useDeferredBackground<HTMLDivElement>()
return <div data-testid="blok" ref={ref} />
}
describe('useDeferredBackground', () => {
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
it('zet de achtergrond pas wanneer het blok in beeld komt', () => {
type Terugroep = (invoeren: { isIntersecting: boolean }[]) => void
const waarnemers: { melden: Terugroep }[] = []
const disconnect = vi.fn()
vi.stubGlobal('IntersectionObserver', class {
melden: Terugroep
constructor(terugroep: Terugroep) {
this.melden = terugroep
waarnemers.push(this)
}
observe() {}
disconnect = disconnect
})
const meldIntersectie = (invoeren: { isIntersecting: boolean }[]) => {
for (const w of waarnemers) w.melden(invoeren)
}
const { getByTestId } = render(<Blok />)
expect(getByTestId('blok').getAttribute('data-achtergrond')).toBeNull()
meldIntersectie([{ isIntersecting: false }])
expect(getByTestId('blok').getAttribute('data-achtergrond')).toBeNull()
meldIntersectie([{ isIntersecting: true }])
expect(getByTestId('blok').getAttribute('data-achtergrond')).toBe('geladen')
// Eenmaal geladen hoeft er niet verder gekeken te worden.
expect(disconnect).toHaveBeenCalled()
})
it('zet de achtergrond meteen wanneer de browser niet kan waarnemen', () => {
vi.stubGlobal('IntersectionObserver', undefined)
const { getByTestId } = render(<Blok />)
// Liever een afbeelding te vroeg dan een leeg vlak dat nooit vult.
expect(getByTestId('blok').getAttribute('data-achtergrond')).toBe('geladen')
})
})
@@ -0,0 +1,45 @@
import { useEffect, useRef } from 'react'
/**
* Stelt een achtergrondafbeelding uit tot het element in beeld komt.
*
* Achtergronden in CSS kennen geen loading="lazy": zodra de regel matcht, gaat
* het verzoek eruit. Op de landingspagina betekende dat drie afbeeldingen van
* samen 413 kB bij het openen, terwijl twee ervan pas ver onder de vouw staan.
*
* Het element krijgt hier pas het attribuut data-achtergrond="geladen" wanneer
* het in de buurt van het scherm komt; de CSS hangt de url aan dat attribuut.
* Zonder IntersectionObserver of met beperkte beweging wordt de afbeelding
* meteen gezet, zodat er nooit een leeg vlak achterblijft.
*/
export function useDeferredBackground<T extends HTMLElement>(): React.RefObject<T> {
const ref = useRef<T>(null)
useEffect(() => {
const element = ref.current
if (!element) return
const toon = () => element.setAttribute('data-achtergrond', 'geladen')
if (typeof IntersectionObserver === 'undefined') {
toon()
return
}
const waarnemer = new IntersectionObserver(
(invoeren) => {
if (invoeren.some((invoer) => invoer.isIntersecting)) {
toon()
waarnemer.disconnect()
}
},
// Ruim voor de vouw beginnen, zodat de afbeelding er staat voordat de
// gebruiker hem bereikt.
{ rootMargin: '600px' },
)
waarnemer.observe(element)
return () => waarnemer.disconnect()
}, [])
return ref
}
+2 -2
View File
@@ -77,8 +77,8 @@ export function useDemoWorkflow({
loadQualityChecks(result.project_id), loadQualityChecks(result.project_id),
analysisLoads, analysisLoads,
]) ])
const candidateDataset = projectData?.datasets.find((dataset) => dataset.id === result.candidate_dataset_id) const candidateDataset = projectData?.datasets?.find((dataset) => dataset.id === result.candidate_dataset_id)
const rasterDataset = projectData?.datasets.find((dataset) => dataset.id === result.raster_dataset_id) const rasterDataset = projectData?.datasets?.find((dataset) => dataset.id === result.raster_dataset_id)
if (candidateDataset) { if (candidateDataset) {
await loadDatasetDetails(result.project_id, candidateDataset) await loadDatasetDetails(result.project_id, candidateDataset)
} else if (rasterDataset) { } else if (rasterDataset) {
@@ -0,0 +1,301 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { DetectionRunRead, JobRead, YoloPreflightResponse } from '../types'
const mocks = vi.hoisted(() => ({
listModels: vi.fn(),
listModelAssets: vi.fn(),
getYoloPreflight: vi.fn(),
runAsync: vi.fn(),
listRuns: vi.fn(),
listDetections: vi.fn(),
getRunGeoJson: vi.fn(),
getRun: vi.fn(),
compareWithReference: vi.fn(),
rasterInspect: vi.fn(),
rasterTile: vi.fn(),
upload: vi.fn(),
}))
vi.mock('../services/api', () => ({
detectionApi: {
listModels: mocks.listModels,
listModelAssets: mocks.listModelAssets,
getYoloPreflight: mocks.getYoloPreflight,
runAsync: mocks.runAsync,
listRuns: mocks.listRuns,
listDetections: mocks.listDetections,
getRunGeoJson: mocks.getRunGeoJson,
getRun: mocks.getRun,
compareWithReference: mocks.compareWithReference,
},
datasetsApi: {
rasterInspect: mocks.rasterInspect,
rasterTile: mocks.rasterTile,
upload: mocks.upload,
},
}))
import { useDetectionWorkflow } from './useDetectionWorkflow'
const projectId = 'project-1'
const datasetId = 'dataset-1'
const jobId = 'job-1'
const analysisRunId = 'run-1'
const completedJob: JobRead = {
id: jobId,
job_type: 'detection.run',
status: 'success',
project_id: projectId,
dataset_id: datasetId,
parameters_json: {},
result_json: { detection_count: 1 },
}
const persistedRun: DetectionRunRead = {
id: analysisRunId,
project_id: projectId,
dataset_id: datasetId,
job_id: jobId,
analysis_type: 'detection',
status: 'success',
model_name: 'yolo-configured',
parameters_json: {},
result_json: { detection_count: 1 },
}
function preflight(acceleratorReady: boolean): YoloPreflightResponse {
return {
model_id: 'yolo-configured',
status: acceleratorReady ? 'ready' : 'accelerator_unavailable',
message: acceleratorReady ? 'Gereed' : 'NVIDIA CUDA is niet beschikbaar',
checks: {
enabled: true,
dependencies_available: true,
accelerator_ready: acceleratorReady,
model_path_set: true,
model_file_exists: true,
model_load_requested: false,
manifest_path_set: true,
manifest_valid: true,
tile_paths_exist: true,
tile_limit_ok: true,
},
runtime: { dependencies_assumed: false, cuda_available: acceleratorReady },
tile_count: 1,
max_tiles: 256,
will_download_models: false,
will_run_inference: acceleratorReady,
}
}
function renderWorkflow() {
const loadProjectData = vi.fn().mockResolvedValue(undefined)
const loadQualityChecks = vi.fn().mockResolvedValue([])
const view = renderHook(() => useDetectionWorkflow({
selectedProjectId: projectId,
rasterDatasets: [],
qaIouThreshold: 0.5,
loadProjectData,
loadQualityChecks,
}))
return { ...view, loadProjectData }
}
describe('useDetectionWorkflow GPU execution', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.listRuns.mockResolvedValue({ items: [persistedRun], total: 1 })
mocks.listDetections.mockResolvedValue({ items: [], total: 1, truncated: false })
mocks.getRunGeoJson.mockResolvedValue({ type: 'FeatureCollection', features: [] })
mocks.listModels.mockResolvedValue({
models: [{
model_id: 'yolo-configured',
display_name: 'YOLO',
framework: 'ultralytics/pytorch',
task_type: 'object_detection',
supported_classes: ['building'],
configured: true,
status: 'configured',
limitation_message: '',
operator_review_required: true,
}],
})
mocks.listModelAssets.mockResolvedValue({ items: [], total: 0, model_directory: '/models' })
})
it('queues, follows and loads a persisted result without a synchronous inference fallback', async () => {
mocks.runAsync.mockResolvedValue(completedJob)
const { result, loadProjectData } = renderWorkflow()
act(() => {
result.current.setSelectedDetectionDatasetId(datasetId)
result.current.setDetectionTileManifestPath('/tiles/manifest.json')
})
await act(async () => {
await result.current.runDetection()
})
expect(mocks.runAsync).toHaveBeenCalledWith(expect.objectContaining({
project_id: projectId,
dataset_id: datasetId,
model_id: 'yolo-configured',
tile_manifest_path: '/tiles/manifest.json',
}))
expect(result.current.detectionJob?.status).toBe('success')
expect(result.current.detectionRunResult).toMatchObject({
analysis_run_id: analysisRunId,
job_id: jobId,
detection_count: 1,
status: 'success',
})
expect(result.current.detectionWorkflowStage).toBe('complete')
expect(result.current.detectionRunError).toBeNull()
expect(loadProjectData).toHaveBeenCalledWith(projectId)
})
it('blocks the queue when preflight says the NVIDIA accelerator is unavailable', async () => {
mocks.getYoloPreflight.mockResolvedValue(preflight(false))
const { result } = renderWorkflow()
await act(async () => {
await result.current.loadDetectionModels()
})
act(() => {
result.current.setSelectedDetectionDatasetId(datasetId)
result.current.setDetectionTileManifestPath('/tiles/manifest.json')
})
await act(async () => {
await result.current.prepareAndRunDetection()
})
expect(mocks.runAsync).not.toHaveBeenCalled()
expect(result.current.detectionWorkflowStage).toBe('failed')
expect(result.current.detectionRunError).toContain('NVIDIA CUDA')
})
it('does not let a late run list from another project overwrite the active project', async () => {
let resolveOlder!: (value: { items: DetectionRunRead[]; total: number }) => void
let resolveNewer!: (value: { items: DetectionRunRead[]; total: number }) => void
mocks.listRuns
.mockReturnValueOnce(new Promise((resolve) => { resolveOlder = resolve }))
.mockReturnValueOnce(new Promise((resolve) => { resolveNewer = resolve }))
const loadProjectData = vi.fn().mockResolvedValue(undefined)
const loadQualityChecks = vi.fn().mockResolvedValue([])
const { result, rerender } = renderHook(
({ selectedProjectId }) => useDetectionWorkflow({
selectedProjectId,
rasterDatasets: [],
qaIouThreshold: 0.5,
loadProjectData,
loadQualityChecks,
}),
{ initialProps: { selectedProjectId: 'project-1' } },
)
let olderRequest!: Promise<void>
let newerRequest!: Promise<void>
act(() => { olderRequest = result.current.loadDetectionRuns('project-1') })
rerender({ selectedProjectId: 'project-2' })
act(() => { newerRequest = result.current.loadDetectionRuns('project-2') })
const projectTwoRun = { ...persistedRun, id: 'run-2', project_id: 'project-2' }
await act(async () => {
resolveNewer({ items: [projectTwoRun], total: 1 })
await newerRequest
})
await act(async () => {
resolveOlder({ items: [persistedRun], total: 1 })
await olderRequest
})
expect(result.current.detectionRuns).toEqual([projectTwoRun])
expect(result.current.selectedDetectionRunId).toBe('run-2')
})
it('does not let late detection results from another project overwrite the active project', async () => {
type DetectionList = { items: Array<{ id: string }>; total: number; truncated: boolean }
type DetectionGeoJson = { type: 'FeatureCollection'; features: Array<{ id: string }> }
let resolveOlderList!: (value: DetectionList) => void
let resolveNewerList!: (value: DetectionList) => void
let resolveOlderGeoJson!: (value: DetectionGeoJson) => void
let resolveNewerGeoJson!: (value: DetectionGeoJson) => void
mocks.listDetections
.mockReturnValueOnce(new Promise((resolve) => { resolveOlderList = resolve }))
.mockReturnValueOnce(new Promise((resolve) => { resolveNewerList = resolve }))
mocks.getRunGeoJson
.mockReturnValueOnce(new Promise((resolve) => { resolveOlderGeoJson = resolve }))
.mockReturnValueOnce(new Promise((resolve) => { resolveNewerGeoJson = resolve }))
const loadProjectData = vi.fn().mockResolvedValue(undefined)
const loadQualityChecks = vi.fn().mockResolvedValue([])
const { result, rerender } = renderHook(
({ selectedProjectId }) => useDetectionWorkflow({
selectedProjectId,
rasterDatasets: [],
qaIouThreshold: 0.5,
loadProjectData,
loadQualityChecks,
}),
{ initialProps: { selectedProjectId: 'project-1' } },
)
let olderRequest!: Promise<void>
let newerRequest!: Promise<void>
act(() => { olderRequest = result.current.loadDetectionResults('run-1') })
rerender({ selectedProjectId: 'project-2' })
act(() => { newerRequest = result.current.loadDetectionResults('run-2') })
await act(async () => {
resolveNewerList({ items: [{ id: 'result-2' }], total: 1, truncated: false })
resolveNewerGeoJson({ type: 'FeatureCollection', features: [{ id: 'feature-2' }] })
await newerRequest
})
await act(async () => {
resolveOlderList({ items: [{ id: 'result-1' }], total: 1, truncated: false })
resolveOlderGeoJson({ type: 'FeatureCollection', features: [{ id: 'feature-1' }] })
await olderRequest
})
expect(result.current.detectionItems).toEqual([{ id: 'result-2' }])
expect(result.current.detectionGeoJson).toEqual({
type: 'FeatureCollection',
features: [{ id: 'feature-2' }],
})
expect(result.current.loadingDetectionResults).toBe(false)
})
it('drops a late queue response when the user has already changed project', async () => {
let resolveQueuedJob!: (value: JobRead) => void
mocks.runAsync.mockReturnValue(new Promise((resolve) => { resolveQueuedJob = resolve }))
const loadProjectData = vi.fn().mockResolvedValue(undefined)
const loadQualityChecks = vi.fn().mockResolvedValue([])
const { result, rerender } = renderHook(
({ selectedProjectId }) => useDetectionWorkflow({
selectedProjectId,
rasterDatasets: [],
qaIouThreshold: 0.5,
loadProjectData,
loadQualityChecks,
}),
{ initialProps: { selectedProjectId: 'project-1' } },
)
act(() => {
result.current.setSelectedDetectionDatasetId(datasetId)
result.current.setDetectionTileManifestPath('/tiles/manifest.json')
})
let request!: Promise<void>
act(() => { request = result.current.runDetection() })
rerender({ selectedProjectId: 'project-2' })
await act(async () => {
resolveQueuedJob(completedJob)
await request
})
expect(result.current.detectionJob).toBeNull()
expect(result.current.detectionRunResult).toBeNull()
expect(result.current.runningDetection).toBe(false)
expect(mocks.getRun).not.toHaveBeenCalled()
})
})
+288 -52
View File
@@ -1,4 +1,4 @@
import { useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { datasetsApi, detectionApi } from '../services/api' import { datasetsApi, detectionApi } from '../services/api'
import type { import type {
DatasetCreateResponse, DatasetCreateResponse,
@@ -13,6 +13,12 @@ import type {
YoloPreflightResponse, YoloPreflightResponse,
} from '../types' } from '../types'
import { formatError } from '../lib/formatError' import { formatError } from '../lib/formatError'
import {
analysisRunIdFromJob,
completedDetectionResponse,
DetectionJobError,
waitForDetectionJob,
} from '../services/detectionJob'
interface DetectionWorkflowOptions { interface DetectionWorkflowOptions {
selectedProjectId: string | null selectedProjectId: string | null
@@ -88,6 +94,16 @@ function rasterTileCount(metadata: Record<string, unknown>, tileSize: number, ov
return Math.ceil(width / step) * Math.ceil(height / step) return Math.ceil(width / step) * Math.ceil(height / step)
} }
function isAbortError(error: unknown): boolean {
return error instanceof Error && error.name === 'AbortError'
}
function abortedError(): Error {
const error = new Error('Het volgen van de detectietaak is gestopt')
error.name = 'AbortError'
return error
}
export function useDetectionWorkflow({ export function useDetectionWorkflow({
selectedProjectId, selectedProjectId,
rasterDatasets, rasterDatasets,
@@ -106,6 +122,7 @@ export function useDetectionWorkflow({
const [detectionTileManifestPath, setDetectionTileManifestPath] = useState('') const [detectionTileManifestPath, setDetectionTileManifestPath] = useState('')
const [detectionConfidenceThreshold, setDetectionConfidenceThreshold] = useState(0.15) const [detectionConfidenceThreshold, setDetectionConfidenceThreshold] = useState(0.15)
const [runningDetection, setRunningDetection] = useState(false) const [runningDetection, setRunningDetection] = useState(false)
const [detectionJob, setDetectionJob] = useState<JobRead | null>(null)
const [detectionRunResult, setDetectionRunResult] = useState<DetectionRunResponse | null>(null) const [detectionRunResult, setDetectionRunResult] = useState<DetectionRunResponse | null>(null)
const [detectionRunError, setDetectionRunError] = useState<string | null>(null) const [detectionRunError, setDetectionRunError] = useState<string | null>(null)
const [detectionRuns, setDetectionRuns] = useState<DetectionRunRead[]>([]) const [detectionRuns, setDetectionRuns] = useState<DetectionRunRead[]>([])
@@ -130,6 +147,36 @@ export function useDetectionWorkflow({
const [detectionCalibrationRows, setDetectionCalibrationRows] = useState<DetectionCalibrationRunRow[]>([]) const [detectionCalibrationRows, setDetectionCalibrationRows] = useState<DetectionCalibrationRunRow[]>([])
const [detectionCalibrationError, setDetectionCalibrationError] = useState<string | null>(null) const [detectionCalibrationError, setDetectionCalibrationError] = useState<string | null>(null)
const [detectionWorkflowStage, setDetectionWorkflowStage] = useState<DetectionWorkflowStage>('idle') const [detectionWorkflowStage, setDetectionWorkflowStage] = useState<DetectionWorkflowStage>('idle')
const activeDetectionControllerRef = useRef<AbortController | null>(null)
const selectedProjectIdRef = useRef(selectedProjectId)
const detectionExecutionSequence = useRef(0)
const detectionRunsRequestSequence = useRef(0)
const detectionResultsRequestSequence = useRef(0)
const detectionQaRequestSequence = useRef(0)
const detectionCalibrationSequence = useRef(0)
selectedProjectIdRef.current = selectedProjectId
useEffect(() => {
activeDetectionControllerRef.current?.abort()
activeDetectionControllerRef.current = null
detectionExecutionSequence.current += 1
detectionQaRequestSequence.current += 1
detectionCalibrationSequence.current += 1
setDetectionJob(null)
setRunningDetection(false)
setDetectionRunResult(null)
setDetectionRunError(null)
setDetectionWorkflowStage('idle')
setDetectionQaResult(null)
setDetectionQaError(null)
setRunningDetectionQa(false)
setDetectionCalibrationRows([])
setDetectionCalibrationError(null)
setRunningDetectionCalibration(false)
return () => {
activeDetectionControllerRef.current?.abort()
}
}, [selectedProjectId])
const loadDetectionModels = async () => { const loadDetectionModels = async () => {
setLoadingDetectionModels(true) setLoadingDetectionModels(true)
@@ -146,7 +193,7 @@ export function useDetectionWorkflow({
setSelectedDetectionModelId(configuredModel?.model_id ?? response.models[0].model_id) setSelectedDetectionModelId(configuredModel?.model_id ?? response.models[0].model_id)
} }
} catch (error) { } catch (error) {
setDetectionModelError(formatError(error, 'Failed to load detection models')) setDetectionModelError(formatError(error, 'De detectiemodellen konden niet worden geladen'))
} }
try { try {
const assetResponse = await detectionApi.listModelAssets() const assetResponse = await detectionApi.listModelAssets()
@@ -160,11 +207,11 @@ export function useDetectionWorkflow({
setYoloPreflight(preflight) setYoloPreflight(preflight)
setYoloPreflightError(null) setYoloPreflightError(null)
} catch (error) { } catch (error) {
setYoloPreflightError(formatError(error, 'Failed to load YOLO preflight status')) setYoloPreflightError(formatError(error, 'De modelcontrole kon niet worden opgehaald'))
} }
} catch (error) { } catch (error) {
setModelAssets([]) setModelAssets([])
setModelAssetError(formatError(error, 'Failed to load local model assets')) setModelAssetError(formatError(error, 'De lokale modelbestanden konden niet worden geladen'))
} finally { } finally {
setLoadingDetectionModels(false) setLoadingDetectionModels(false)
} }
@@ -180,34 +227,44 @@ export function useDetectionWorkflow({
}) })
setYoloPreflight(response) setYoloPreflight(response)
} catch (error) { } catch (error) {
setYoloPreflightError(formatError(error, 'Failed to load YOLO preflight status')) setYoloPreflightError(formatError(error, 'De modelcontrole kon niet worden opgehaald'))
} finally { } finally {
setLoadingYoloPreflight(false) setLoadingYoloPreflight(false)
} }
} }
const loadDetectionRuns = async (projectId = selectedProjectId) => { const loadDetectionRuns = async (projectId = selectedProjectId) => {
const sequence = detectionRunsRequestSequence.current + 1
detectionRunsRequestSequence.current = sequence
if (!projectId) { if (!projectId) {
setDetectionRuns([]) setDetectionRuns([])
return return
} }
try { try {
const response = await detectionApi.listRuns({ project_id: projectId }) const response = await detectionApi.listRuns({ project_id: projectId })
if (
detectionRunsRequestSequence.current !== sequence
|| selectedProjectIdRef.current !== projectId
) return
setDetectionRuns(response.items) setDetectionRuns(response.items)
if (!selectedDetectionRunId && response.items.length > 0) { setSelectedDetectionRunId((current) => current || response.items[0]?.id || '')
setSelectedDetectionRunId(response.items[0].id)
}
} catch (error) { } catch (error) {
setDetectionRunError(formatError(error, 'Failed to load detection runs')) if (
detectionRunsRequestSequence.current === sequence
&& selectedProjectIdRef.current === projectId
) {
setDetectionRunError(formatError(error, 'De detectieruns konden niet worden geladen'))
}
} }
} }
const loadDetectionResults = async (analysisRunId = selectedDetectionRunId) => { const loadDetectionResults = async (analysisRunId = selectedDetectionRunId) => {
if (!analysisRunId) { const sequence = detectionResultsRequestSequence.current + 1
detectionResultsRequestSequence.current = sequence
const requestProjectId = selectedProjectIdRef.current
if (!analysisRunId || !requestProjectId) {
setDetectionItems([]) setDetectionItems([])
setDetectionTotal(0) setDetectionTotal(0)
setDetectionTruncated(false)
setDetectionTotal(0)
setDetectionTruncated(false) setDetectionTruncated(false)
setDetectionGeoJson(null) setDetectionGeoJson(null)
return return
@@ -216,7 +273,7 @@ export function useDetectionWorkflow({
setDetectionRunError(null) setDetectionRunError(null)
try { try {
const params = { const params = {
project_id: selectedProjectId ?? '', project_id: requestProjectId,
class_name: detectionClassFilter || null, class_name: detectionClassFilter || null,
min_confidence: detectionMinConfidenceFilter > 0 ? detectionMinConfidenceFilter : null, min_confidence: detectionMinConfidenceFilter > 0 ? detectionMinConfidenceFilter : null,
} }
@@ -224,16 +281,30 @@ export function useDetectionWorkflow({
detectionApi.listDetections(analysisRunId, params), detectionApi.listDetections(analysisRunId, params),
detectionApi.getRunGeoJson(analysisRunId, params), detectionApi.getRunGeoJson(analysisRunId, params),
]) ])
if (
detectionResultsRequestSequence.current !== sequence
|| selectedProjectIdRef.current !== requestProjectId
) return
setDetectionItems(detectionsResponse.items) setDetectionItems(detectionsResponse.items)
setDetectionTotal(detectionsResponse.total) setDetectionTotal(detectionsResponse.total)
setDetectionTruncated(Boolean(detectionsResponse.truncated)) setDetectionTruncated(Boolean(detectionsResponse.truncated))
setDetectionGeoJson(geoJsonResponse) setDetectionGeoJson(geoJsonResponse)
} catch (error) { } catch (error) {
setDetectionRunError(formatError(error, 'Failed to load detection results')) if (
detectionResultsRequestSequence.current === sequence
&& selectedProjectIdRef.current === requestProjectId
) {
setDetectionRunError(formatError(error, 'De detectieresultaten konden niet worden geladen'))
}
} finally { } finally {
if (
detectionResultsRequestSequence.current === sequence
&& selectedProjectIdRef.current === requestProjectId
) {
setLoadingDetectionResults(false) setLoadingDetectionResults(false)
} }
} }
}
const executeDetection = async ( const executeDetection = async (
projectId: string, projectId: string,
@@ -241,33 +312,102 @@ export function useDetectionWorkflow({
manifestPath: string | null, manifestPath: string | null,
modelId = selectedDetectionModelId, modelId = selectedDetectionModelId,
modelAssetId = selectedModelAssetId, modelAssetId = selectedModelAssetId,
confidenceThreshold = detectionConfidenceThreshold,
parametersJson: Record<string, unknown> = {},
) => { ) => {
const result = await detectionApi.run({ if (
(activeDetectionControllerRef.current && !activeDetectionControllerRef.current.signal.aborted)
|| detectionJob?.status === 'queued'
|| detectionJob?.status === 'running'
) {
throw new DetectionJobError(
'Er wordt al een GPU-detectietaak gevolgd. Wacht tot die taak klaar is voordat u een nieuwe start.',
'DETECTION_JOB_ALREADY_ACTIVE',
detectionJob?.id ?? 'unknown',
)
}
const request = {
project_id: projectId, project_id: projectId,
dataset_id: datasetId, dataset_id: datasetId,
model_id: modelId, model_id: modelId,
model_asset_id: modelAssetId || null, model_asset_id: modelAssetId || null,
confidence_threshold: detectionConfidenceThreshold, confidence_threshold: confidenceThreshold,
tile_manifest_path: manifestPath, tile_manifest_path: manifestPath,
parameters_json: {}, parameters_json: parametersJson,
}
const controller = new AbortController()
const executionSequence = detectionExecutionSequence.current + 1
detectionExecutionSequence.current = executionSequence
activeDetectionControllerRef.current = controller
const assertExecutionCurrent = () => {
if (
controller.signal.aborted
|| detectionExecutionSequence.current !== executionSequence
|| selectedProjectIdRef.current !== projectId
) {
throw abortedError()
}
}
try {
setDetectionJob(null)
const queuedJob = await detectionApi.runAsync(request)
assertExecutionCurrent()
setDetectionJob(queuedJob)
const completedJob = await waitForDetectionJob({
projectId,
initialJob: queuedJob,
signal: controller.signal,
onStatus: (job) => {
if (
detectionExecutionSequence.current === executionSequence
&& selectedProjectIdRef.current === projectId
) {
setDetectionJob(job)
}
},
}) })
assertExecutionCurrent()
const explicitAnalysisRunId = analysisRunIdFromJob(completedJob)
const run = explicitAnalysisRunId
? await detectionApi.getRun(explicitAnalysisRunId, projectId)
: (await detectionApi.listRuns({ project_id: projectId, dataset_id: datasetId })).items
.find((candidate) => candidate.job_id === completedJob.id)
assertExecutionCurrent()
if (!run) {
throw new DetectionJobError(
'De GPU-taak is voltooid, maar de bijbehorende bewaarde detectierun ontbreekt.',
'DETECTION_RUN_RESULT_NOT_FOUND',
completedJob.id,
)
}
const result = completedDetectionResponse(request, completedJob, run)
setDetectionRunResult(result) setDetectionRunResult(result)
setSelectedDetectionRunId(result.analysis_run_id) setSelectedDetectionRunId(result.analysis_run_id)
setDetectionWorkflowStage('loading') setDetectionWorkflowStage('loading')
await loadDetectionRuns(projectId) await loadDetectionRuns(projectId)
assertExecutionCurrent()
await loadDetectionResults(result.analysis_run_id) await loadDetectionResults(result.analysis_run_id)
assertExecutionCurrent()
await loadProjectData(projectId) await loadProjectData(projectId)
assertExecutionCurrent()
return result return result
} finally {
if (activeDetectionControllerRef.current === controller) {
activeDetectionControllerRef.current = null
}
}
} }
const runDetection = async () => { const runDetection = async () => {
if (!selectedProjectId) { if (!selectedProjectId) {
setDetectionRunError('Select a project first') setDetectionRunError('Kies eerst een werkruimte')
return return
} }
const projectId = selectedProjectId
const datasetId = selectedDetectionDatasetId const datasetId = selectedDetectionDatasetId
if (!datasetId) { if (!datasetId) {
setDetectionRunError('Select a raster dataset') setDetectionRunError('Kies eerst een rasterbron')
return return
} }
setDetectionRunError(null) setDetectionRunError(null)
@@ -275,25 +415,32 @@ export function useDetectionWorkflow({
setRunningDetection(true) setRunningDetection(true)
setDetectionWorkflowStage('detecting') setDetectionWorkflowStage('detecting')
try { try {
await executeDetection(selectedProjectId, datasetId, detectionTileManifestPath.trim() || null) await executeDetection(projectId, datasetId, detectionTileManifestPath.trim() || null)
if (selectedProjectIdRef.current === projectId) {
setDetectionWorkflowStage('complete') setDetectionWorkflowStage('complete')
}
} catch (error) { } catch (error) {
if (!isAbortError(error) && selectedProjectIdRef.current === projectId) {
setDetectionRunError(formatError(error, 'Detection run failed')) setDetectionRunError(formatError(error, 'Detection run failed'))
setDetectionWorkflowStage('failed') setDetectionWorkflowStage('failed')
}
} finally { } finally {
if (selectedProjectIdRef.current === projectId) {
setRunningDetection(false) setRunningDetection(false)
} }
} }
}
const uploadDetectionRaster = async (file: File): Promise<boolean> => { const uploadDetectionRaster = async (file: File): Promise<boolean> => {
if (!selectedProjectId) { if (!selectedProjectId) {
setDetectionRunError('De regionale werkruimte is nog niet geladen') setDetectionRunError('De regionale werkruimte is nog niet geladen')
return false return false
} }
const projectId = selectedProjectId
setDetectionRunError(null) setDetectionRunError(null)
setDetectionWorkflowStage('uploading') setDetectionWorkflowStage('uploading')
try { try {
const dataset = await datasetsApi.upload(selectedProjectId, { const dataset = await datasetsApi.upload(projectId, {
file, file,
datasetType: 'raster', datasetType: 'raster',
source: 'user_upload', source: 'user_upload',
@@ -302,15 +449,19 @@ export function useDetectionWorkflow({
sourceMetadataJson: JSON.stringify({ purpose: 'building_detection' }), sourceMetadataJson: JSON.stringify({ purpose: 'building_detection' }),
provenanceMetadataJson: JSON.stringify({ original_filename: file.name, acquisition: 'explicit_user_upload' }), provenanceMetadataJson: JSON.stringify({ original_filename: file.name, acquisition: 'explicit_user_upload' }),
}) })
if (selectedProjectIdRef.current !== projectId) throw abortedError()
setSelectedDetectionDatasetId(dataset.id) setSelectedDetectionDatasetId(dataset.id)
setDetectionTileManifestPath('') setDetectionTileManifestPath('')
setDetectionRunResult(null) setDetectionRunResult(null)
setDetectionWorkflowStage('ready') setDetectionWorkflowStage('ready')
await loadProjectData(selectedProjectId) await loadProjectData(projectId)
if (selectedProjectIdRef.current !== projectId) throw abortedError()
return true return true
} catch (error) { } catch (error) {
if (!isAbortError(error) && selectedProjectIdRef.current === projectId) {
setDetectionRunError(formatError(error, 'Het luchtbeeld kon niet worden toegevoegd')) setDetectionRunError(formatError(error, 'Het luchtbeeld kon niet worden toegevoegd'))
setDetectionWorkflowStage('failed') setDetectionWorkflowStage('failed')
}
return false return false
} }
} }
@@ -323,6 +474,10 @@ export function useDetectionWorkflow({
setDetectionRunError('De regionale werkruimte is nog niet geladen') setDetectionRunError('De regionale werkruimte is nog niet geladen')
return null return null
} }
const projectId = selectedProjectId
const assertProjectCurrent = () => {
if (selectedProjectIdRef.current !== projectId) throw abortedError()
}
const datasetId = datasetIdOverride || selectedDetectionDatasetId const datasetId = datasetIdOverride || selectedDetectionDatasetId
if (!datasetId) { if (!datasetId) {
setDetectionRunError('Kies of voeg eerst een gegeorefereerd luchtbeeld toe') setDetectionRunError('Kies of voeg eerst een gegeorefereerd luchtbeeld toe')
@@ -334,7 +489,7 @@ export function useDetectionWorkflow({
: selectedModelAssetId : selectedModelAssetId
const selectedModel = detectionModels.find((model) => model.model_id === effectiveModelId) const selectedModel = detectionModels.find((model) => model.model_id === effectiveModelId)
if (!selectedModel?.configured || effectiveModelId === 'manual-fixture-detector') { if (!selectedModel?.configured || effectiveModelId === 'manual-fixture-detector') {
setDetectionRunError(selectedModel?.limitation_message ?? 'Het gekozen analysemodel is niet beschikbaar') setDetectionRunError('Het gekozen productie-analysemodel is niet beschikbaar; vernieuw de modelstatus en controleer de serverconfiguratie')
return null return null
} }
if (effectiveModelId === 'yolo-configured' && modelAssets.length > 0 && !effectiveModelAssetId) { if (effectiveModelId === 'yolo-configured' && modelAssets.length > 0 && !effectiveModelAssetId) {
@@ -349,7 +504,8 @@ export function useDetectionWorkflow({
let manifestPath = detectionTileManifestPath.trim() let manifestPath = detectionTileManifestPath.trim()
if (!manifestPath) { if (!manifestPath) {
setDetectionWorkflowStage('tiling') setDetectionWorkflowStage('tiling')
const inspection = await datasetsApi.rasterInspect(selectedProjectId, datasetId) const inspection = await datasetsApi.rasterInspect(projectId, datasetId)
assertProjectCurrent()
const expectedTileCount = rasterTileCount(inspection.metadata, 512, 64) const expectedTileCount = rasterTileCount(inspection.metadata, 512, 64)
const maxTiles = yoloPreflight?.max_tiles ?? 256 const maxTiles = yoloPreflight?.max_tiles ?? 256
if (expectedTileCount === null) { if (expectedTileCount === null) {
@@ -360,10 +516,11 @@ export function useDetectionWorkflow({
`Dit luchtbeeld zou ${expectedTileCount} beeldtegels maken; het veilige maximum is ${maxTiles}. Knip het beeld eerst tot het gewenste werkgebied.`, `Dit luchtbeeld zou ${expectedTileCount} beeldtegels maken; het veilige maximum is ${maxTiles}. Knip het beeld eerst tot het gewenste werkgebied.`,
) )
} }
const tileJob = await datasetsApi.rasterTile(selectedProjectId, datasetId, { const tileJob = await datasetsApi.rasterTile(projectId, datasetId, {
tile_size: 512, tile_size: 512,
overlap: 64, overlap: 64,
}) })
assertProjectCurrent()
manifestPath = tileManifestPathFromJob(tileJob) ?? '' manifestPath = tileManifestPathFromJob(tileJob) ?? ''
if (!manifestPath) { if (!manifestPath) {
throw new Error(tileJob.error_message || 'De tegelvoorbereiding leverde geen geldig manifest op') throw new Error(tileJob.error_message || 'De tegelvoorbereiding leverde geen geldig manifest op')
@@ -376,6 +533,7 @@ export function useDetectionWorkflow({
tile_manifest_path: manifestPath, tile_manifest_path: manifestPath,
model_asset_id: effectiveModelAssetId || null, model_asset_id: effectiveModelAssetId || null,
}) })
assertProjectCurrent()
setYoloPreflight(preflight) setYoloPreflight(preflight)
setYoloPreflightError(null) setYoloPreflightError(null)
if ( if (
@@ -383,6 +541,7 @@ export function useDetectionWorkflow({
!preflight.checks.tile_paths_exist || !preflight.checks.tile_paths_exist ||
!preflight.checks.tile_limit_ok || !preflight.checks.tile_limit_ok ||
!preflight.checks.dependencies_available || !preflight.checks.dependencies_available ||
preflight.checks.accelerator_ready !== true ||
!preflight.checks.model_file_exists !preflight.checks.model_file_exists
) { ) {
throw new Error(preflight.message || 'De beeldtegels of modelruntime zijn niet startklaar') throw new Error(preflight.message || 'De beeldtegels of modelruntime zijn niet startklaar')
@@ -390,22 +549,27 @@ export function useDetectionWorkflow({
setDetectionWorkflowStage('detecting') setDetectionWorkflowStage('detecting')
const result = await executeDetection( const result = await executeDetection(
selectedProjectId, projectId,
datasetId, datasetId,
manifestPath, manifestPath,
effectiveModelId, effectiveModelId,
effectiveModelAssetId, effectiveModelAssetId,
) )
assertProjectCurrent()
setDetectionWorkflowStage('complete') setDetectionWorkflowStage('complete')
return result return result
} catch (error) { } catch (error) {
if (!isAbortError(error) && selectedProjectIdRef.current === projectId) {
setDetectionRunError(formatError(error, 'De beeldanalyse is mislukt')) setDetectionRunError(formatError(error, 'De beeldanalyse is mislukt'))
setDetectionWorkflowStage('failed') setDetectionWorkflowStage('failed')
}
return null return null
} finally { } finally {
if (selectedProjectIdRef.current === projectId) {
setRunningDetection(false) setRunningDetection(false)
} }
} }
}
const compareDetectionRunWithReference = async ( const compareDetectionRunWithReference = async (
analysisRunId: string, analysisRunId: string,
@@ -414,71 +578,106 @@ export function useDetectionWorkflow({
iouThresholdOverride?: number, iouThresholdOverride?: number,
): Promise<DetectionQaResult | null> => { ): Promise<DetectionQaResult | null> => {
if (!analysisRunId) { if (!analysisRunId) {
setDetectionQaError('Select a detection run') setDetectionQaError('Kies eerst een detectierun')
return null return null
} }
if (!referenceDatasetId) { if (!referenceDatasetId) {
setDetectionQaError('Select a reference dataset') setDetectionQaError('Kies eerst een referentiebron')
return null return null
} }
const projectId = selectedProjectIdRef.current
if (!projectId) {
setDetectionQaError('Kies eerst een werkruimte')
return null
}
const sequence = detectionQaRequestSequence.current + 1
detectionQaRequestSequence.current = sequence
setSelectedDetectionRunId(analysisRunId) setSelectedDetectionRunId(analysisRunId)
setDetectionReferenceDatasetId(referenceDatasetId) setDetectionReferenceDatasetId(referenceDatasetId)
setDetectionQaError(null) setDetectionQaError(null)
setDetectionQaResult(null) setDetectionQaResult(null)
setRunningDetectionQa(true) setRunningDetectionQa(true)
try { try {
const result = await detectionApi.compareWithReference(analysisRunId, selectedProjectId!, { const result = await detectionApi.compareWithReference(analysisRunId, projectId, {
reference_dataset_id: referenceDatasetId, reference_dataset_id: referenceDatasetId,
iou_threshold: iouThresholdOverride ?? qaIouThreshold, iou_threshold: iouThresholdOverride ?? qaIouThreshold,
class_name: useCurrentFilters ? detectionClassFilter || null : null, class_name: useCurrentFilters ? detectionClassFilter || null : null,
min_confidence: useCurrentFilters && detectionMinConfidenceFilter > 0 ? detectionMinConfidenceFilter : null, min_confidence: useCurrentFilters && detectionMinConfidenceFilter > 0 ? detectionMinConfidenceFilter : null,
}) })
if (
detectionQaRequestSequence.current !== sequence
|| selectedProjectIdRef.current !== projectId
) return null
setDetectionQaResult(result) setDetectionQaResult(result)
await loadQualityChecks(selectedProjectId) await loadQualityChecks(projectId)
if (
detectionQaRequestSequence.current !== sequence
|| selectedProjectIdRef.current !== projectId
) return null
return result return result
} catch (error) { } catch (error) {
if (
detectionQaRequestSequence.current === sequence
&& selectedProjectIdRef.current === projectId
) {
setDetectionQaError(formatError(error, 'Detection QA failed')) setDetectionQaError(formatError(error, 'Detection QA failed'))
}
return null return null
} finally { } finally {
if (
detectionQaRequestSequence.current === sequence
&& selectedProjectIdRef.current === projectId
) {
setRunningDetectionQa(false) setRunningDetectionQa(false)
} }
} }
}
const runDetectionQa = async (): Promise<DetectionQaResult | null> => const runDetectionQa = async (): Promise<DetectionQaResult | null> =>
compareDetectionRunWithReference(selectedDetectionRunId, detectionReferenceDatasetId) compareDetectionRunWithReference(selectedDetectionRunId, detectionReferenceDatasetId)
const runDetectionCalibration = async () => { const runDetectionCalibration = async () => {
if (!selectedProjectId) { if (!selectedProjectId) {
setDetectionCalibrationError('Select a project before calibration') setDetectionCalibrationError('Kies eerst een werkruimte om te kalibreren')
return return
} }
const projectId = selectedProjectId
const datasetId = selectedDetectionDatasetId const datasetId = selectedDetectionDatasetId
if (!datasetId) { if (!datasetId) {
setDetectionCalibrationError('Select a raster dataset before calibration') setDetectionCalibrationError('Kies eerst een rasterbron om te kalibreren')
return return
} }
if (!detectionReferenceDatasetId) { if (!detectionReferenceDatasetId) {
setDetectionCalibrationError('Select a reference dataset before calibration') setDetectionCalibrationError('Kies eerst een referentiebron om te kalibreren')
return return
} }
const referenceDatasetId = detectionReferenceDatasetId
const selectedModel = detectionModels.find((model) => model.model_id === selectedDetectionModelId) const selectedModel = detectionModels.find((model) => model.model_id === selectedDetectionModelId)
if (!selectedModel?.configured || selectedDetectionModelId === 'manual-fixture-detector') { if (!selectedModel?.configured || selectedDetectionModelId === 'manual-fixture-detector') {
setDetectionCalibrationError('Select a configured non-fixture detection model before calibration') setDetectionCalibrationError('Kies eerst een geconfigureerd detectiemodel; testgegevens kunnen niet gekalibreerd worden')
return return
} }
if (selectedDetectionModelId === 'yolo-configured' && !detectionTileManifestPath.trim()) { if (selectedDetectionModelId === 'yolo-configured' && !detectionTileManifestPath.trim()) {
setDetectionCalibrationError('Configured YOLO calibration requires a tile manifest') setDetectionCalibrationError('Kalibratie met YOLO vereist een beeldtegelmanifest')
return return
} }
if (selectedDetectionModelId === 'yolo-configured' && modelAssets.length > 0 && !selectedModelAssetId) { if (selectedDetectionModelId === 'yolo-configured' && modelAssets.length > 0 && !selectedModelAssetId) {
setDetectionCalibrationError('Select a local model asset before calibration') setDetectionCalibrationError('Kies eerst een lokaal modelbestand om te kalibreren')
return return
} }
const thresholds = parseCalibrationThresholds(calibrationThresholdText) const thresholds = parseCalibrationThresholds(calibrationThresholdText)
if (thresholds.length === 0) { if (thresholds.length === 0) {
setDetectionCalibrationError('Provide at least one valid threshold between 0 and 1') setDetectionCalibrationError('Geef minstens één geldige drempel tussen 0 en 1 op')
return return
} }
const sequence = detectionCalibrationSequence.current + 1
detectionCalibrationSequence.current = sequence
const assertCalibrationCurrent = () => {
if (
detectionCalibrationSequence.current !== sequence
|| selectedProjectIdRef.current !== projectId
) throw abortedError()
}
setDetectionCalibrationError(null) setDetectionCalibrationError(null)
setDetectionCalibrationRows(thresholds.map((threshold) => ({ threshold, status: 'queued' }))) setDetectionCalibrationRows(thresholds.map((threshold) => ({ threshold, status: 'queued' })))
setRunningDetectionCalibration(true) setRunningDetectionCalibration(true)
@@ -492,24 +691,28 @@ export function useDetectionWorkflow({
setDetectionCalibrationRows((rows) => setDetectionCalibrationRows((rows) =>
rows.map((row) => ({ ...row, status: 'running', message: 'Eén inferentie voor alle drempels' })), rows.map((row) => ({ ...row, status: 'running', message: 'Eén inferentie voor alle drempels' })),
) )
const result = await detectionApi.run({ setDetectionWorkflowStage('detecting')
project_id: selectedProjectId, const result = await executeDetection(
dataset_id: datasetId, projectId,
model_id: selectedDetectionModelId, datasetId,
model_asset_id: selectedModelAssetId || null, detectionTileManifestPath.trim() || null,
confidence_threshold: lowestThreshold, selectedDetectionModelId,
tile_manifest_path: detectionTileManifestPath.trim() || null, selectedModelAssetId,
parameters_json: { calibration: true, calibration_thresholds: thresholds }, lowestThreshold,
}) { calibration: true, calibration_thresholds: thresholds },
)
assertCalibrationCurrent()
setDetectionWorkflowStage('complete')
setSelectedDetectionRunId(result.analysis_run_id) setSelectedDetectionRunId(result.analysis_run_id)
const qa = await detectionApi.compareWithReference(result.analysis_run_id, selectedProjectId, { const qa = await detectionApi.compareWithReference(result.analysis_run_id, projectId, {
reference_dataset_id: detectionReferenceDatasetId, reference_dataset_id: referenceDatasetId,
iou_threshold: qaIouThreshold, iou_threshold: qaIouThreshold,
class_name: detectionClassFilter || null, class_name: detectionClassFilter || null,
min_confidence: null, min_confidence: null,
calibration_thresholds: thresholds, calibration_thresholds: thresholds,
}) })
assertCalibrationCurrent()
const sweep = new Map((qa.calibration_sweep ?? []).map((point) => [point.min_confidence, point])) const sweep = new Map((qa.calibration_sweep ?? []).map((point) => [point.min_confidence, point]))
setDetectionCalibrationRows((rows) => setDetectionCalibrationRows((rows) =>
@@ -536,19 +739,33 @@ export function useDetectionWorkflow({
}), }),
) )
await loadDetectionRuns(selectedProjectId) await loadDetectionRuns(projectId)
await loadQualityChecks(selectedProjectId) assertCalibrationCurrent()
await loadProjectData(selectedProjectId) await loadQualityChecks(projectId)
assertCalibrationCurrent()
await loadProjectData(projectId)
assertCalibrationCurrent()
} catch (error) { } catch (error) {
const message = formatError(error, 'Calibration failed') if (
!isAbortError(error)
&& detectionCalibrationSequence.current === sequence
&& selectedProjectIdRef.current === projectId
) {
const message = formatError(error, 'Kalibratie mislukt')
setDetectionCalibrationRows((rows) => setDetectionCalibrationRows((rows) =>
rows.map((row) => (row.status === 'success' ? row : { ...row, status: 'failed', message })), rows.map((row) => (row.status === 'success' ? row : { ...row, status: 'failed', message })),
) )
setDetectionCalibrationError(message) setDetectionCalibrationError(message)
}
} finally { } finally {
if (
detectionCalibrationSequence.current === sequence
&& selectedProjectIdRef.current === projectId
) {
setRunningDetectionCalibration(false) setRunningDetectionCalibration(false)
} }
} }
}
const applyDetectionOperatorProfile = (profile: DetectionOperatorProfileSelection) => { const applyDetectionOperatorProfile = (profile: DetectionOperatorProfileSelection) => {
setSelectedDetectionModelId('yolo-configured') setSelectedDetectionModelId('yolo-configured')
@@ -557,14 +774,32 @@ export function useDetectionWorkflow({
} }
const resetDetectionForProject = () => { const resetDetectionForProject = () => {
detectionExecutionSequence.current += 1
detectionRunsRequestSequence.current += 1
detectionResultsRequestSequence.current += 1
detectionQaRequestSequence.current += 1
detectionCalibrationSequence.current += 1
activeDetectionControllerRef.current?.abort()
activeDetectionControllerRef.current = null
setSelectedDetectionDatasetId('') setSelectedDetectionDatasetId('')
setDetectionRuns([]) setDetectionRuns([])
setSelectedDetectionRunId('') setSelectedDetectionRunId('')
setDetectionItems([]) setDetectionItems([])
setDetectionTotal(0)
setDetectionTruncated(false)
setDetectionGeoJson(null) setDetectionGeoJson(null)
setDetectionRunResult(null) setDetectionRunResult(null)
setDetectionJob(null)
setDetectionReferenceDatasetId('')
setDetectionQaResult(null)
setDetectionQaError(null)
setRunningDetectionQa(false)
setDetectionCalibrationRows([]) setDetectionCalibrationRows([])
setDetectionCalibrationError(null) setDetectionCalibrationError(null)
setRunningDetectionCalibration(false)
setDetectionRunError(null)
setLoadingDetectionResults(false)
setRunningDetection(false)
setDetectionWorkflowStage('idle') setDetectionWorkflowStage('idle')
} }
@@ -580,6 +815,7 @@ export function useDetectionWorkflow({
detectionTileManifestPath, detectionTileManifestPath,
detectionConfidenceThreshold, detectionConfidenceThreshold,
runningDetection, runningDetection,
detectionJob,
detectionRunResult, detectionRunResult,
detectionRunError, detectionRunError,
detectionRuns, detectionRuns,
+14 -14
View File
@@ -45,7 +45,7 @@ export function useExportWorkflow({
const response = await exportsApi.listProjectExports(projectId) const response = await exportsApi.listProjectExports(projectId)
setExports(response.items) setExports(response.items)
} catch (error) { } catch (error) {
setExportError(formatError(error, 'Failed to load exports')) setExportError(formatError(error, 'De downloads konden niet worden geladen'))
} finally { } finally {
setLoadingExports(false) setLoadingExports(false)
} }
@@ -53,7 +53,7 @@ export function useExportWorkflow({
const exportSelectedDatasetGeoJson = async () => { const exportSelectedDatasetGeoJson = async () => {
if (!selectedDataset || !isVectorDatasetType(selectedDataset.dataset_type)) { if (!selectedDataset || !isVectorDatasetType(selectedDataset.dataset_type)) {
setExportError('Select a vector dataset before exporting GeoJSON.') setExportError('Kies eerst een vectorbron om als GeoJSON te bewaren.')
return return
} }
setExporting(true) setExporting(true)
@@ -67,7 +67,7 @@ export function useExportWorkflow({
setLatestExport(response) setLatestExport(response)
await loadExports(selectedDataset.project_id) await loadExports(selectedDataset.project_id)
} catch (error) { } catch (error) {
setExportError(formatError(error, 'Failed to export selected dataset')) setExportError(formatError(error, 'De gekozen bron kon niet worden geëxporteerd'))
} finally { } finally {
setExporting(false) setExporting(false)
} }
@@ -75,7 +75,7 @@ export function useExportWorkflow({
const exportSelectedDetectionRunGeoJson = async () => { const exportSelectedDetectionRunGeoJson = async () => {
if (!selectedDetectionRunId || !selectedProjectId) { if (!selectedDetectionRunId || !selectedProjectId) {
setExportError('Select a detection run before exporting GeoJSON.') setExportError('Kies eerst een detectierun om als GeoJSON te bewaren.')
return return
} }
setExporting(true) setExporting(true)
@@ -89,7 +89,7 @@ export function useExportWorkflow({
setLatestExport(response) setLatestExport(response)
await loadExports(selectedProjectId) await loadExports(selectedProjectId)
} catch (error) { } catch (error) {
setExportError(formatError(error, 'Failed to export detection run')) setExportError(formatError(error, 'De detectierun kon niet worden geëxporteerd'))
} finally { } finally {
setExporting(false) setExporting(false)
} }
@@ -97,7 +97,7 @@ export function useExportWorkflow({
const exportSelectedSegmentationRunGeoJson = async () => { const exportSelectedSegmentationRunGeoJson = async () => {
if (!selectedSegmentationRunId || !selectedProjectId) { if (!selectedSegmentationRunId || !selectedProjectId) {
setExportError('Select a segmentation run before exporting GeoJSON.') setExportError('Kies eerst een segmentatierun om als GeoJSON te bewaren.')
return return
} }
setExporting(true) setExporting(true)
@@ -110,7 +110,7 @@ export function useExportWorkflow({
setLatestExport(response) setLatestExport(response)
await loadExports(selectedProjectId) await loadExports(selectedProjectId)
} catch (error) { } catch (error) {
setExportError(formatError(error, 'Failed to export segmentation run')) setExportError(formatError(error, 'De segmentatierun kon niet worden geëxporteerd'))
} finally { } finally {
setExporting(false) setExporting(false)
} }
@@ -118,7 +118,7 @@ export function useExportWorkflow({
const exportMapSelectionGeoJson = async (bbox: VectorSelectionBBox, areaId?: string) => { const exportMapSelectionGeoJson = async (bbox: VectorSelectionBBox, areaId?: string) => {
if (!selectedDataset || !isVectorDatasetType(selectedDataset.dataset_type)) { if (!selectedDataset || !isVectorDatasetType(selectedDataset.dataset_type)) {
setSelectionExportError('Select a vector dataset before saving an area export.') setSelectionExportError('Kies eerst een vectorbron om het gebied mee te bewaren.')
return null return null
} }
setSelectionExporting(true) setSelectionExporting(true)
@@ -137,7 +137,7 @@ export function useExportWorkflow({
await loadExports(selectedDataset.project_id) await loadExports(selectedDataset.project_id)
return response return response
} catch (error) { } catch (error) {
setSelectionExportError(formatError(error, 'Failed to save area export')) setSelectionExportError(formatError(error, 'De gebiedsdownload kon niet worden bewaard'))
return null return null
} finally { } finally {
setSelectionExporting(false) setSelectionExporting(false)
@@ -146,7 +146,7 @@ export function useExportWorkflow({
const exportProjectMetadata = async () => { const exportProjectMetadata = async () => {
if (!selectedProjectId) { if (!selectedProjectId) {
setExportError('Select a project before exporting metadata.') setExportError('Kies eerst een werkruimte om de projectgegevens te bewaren.')
return return
} }
setExporting(true) setExporting(true)
@@ -156,7 +156,7 @@ export function useExportWorkflow({
setLatestExport(response) setLatestExport(response)
await loadExports(selectedProjectId) await loadExports(selectedProjectId)
} catch (error) { } catch (error) {
setExportError(formatError(error, 'Failed to export project metadata')) setExportError(formatError(error, 'De projectgegevens konden niet worden geëxporteerd'))
} finally { } finally {
setExporting(false) setExporting(false)
} }
@@ -164,7 +164,7 @@ export function useExportWorkflow({
const exportProjectReport = async () => { const exportProjectReport = async () => {
if (!selectedProjectId) { if (!selectedProjectId) {
setExportError('Select a project before exporting a report.') setExportError('Kies eerst een werkruimte om een rapport te maken.')
return return
} }
setExporting(true) setExporting(true)
@@ -174,7 +174,7 @@ export function useExportWorkflow({
setLatestExport(response) setLatestExport(response)
await loadExports(selectedProjectId) await loadExports(selectedProjectId)
} catch (error) { } catch (error) {
setExportError(formatError(error, 'Failed to export project report')) setExportError(formatError(error, 'Het projectrapport kon niet worden gemaakt'))
} finally { } finally {
setExporting(false) setExporting(false)
} }
@@ -204,7 +204,7 @@ export function useExportWorkflow({
const response = await exportsApi.getContent(selectedProjectId, exportId) const response = await exportsApi.getContent(selectedProjectId, exportId)
setExportPreview(response.content) setExportPreview(response.content)
} catch (error) { } catch (error) {
setExportError(formatError(error, 'Failed to load export content')) setExportError(formatError(error, 'De inhoud van de download kon niet worden geladen'))
} }
} }
+117
View File
@@ -0,0 +1,117 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AssistantQueryResponse } from '../types'
const mocks = vi.hoisted(() => ({
status: vi.fn(),
models: vi.fn(),
query: vi.fn(),
}))
vi.mock('../services/api/assistant', () => ({
assistantApi: mocks,
}))
import { useGeoAssistant } from './useGeoAssistant'
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, resolve, reject }
}
function response(answer: string): AssistantQueryResponse {
return {
answer,
model: 'geo-model',
scope_label: 'testgebied',
context_metrics: [],
temporal_series: [],
source_dataset_ids: [],
warnings: [],
generated_at: '2026-08-23T12:00:00Z',
}
}
describe('useGeoAssistant request scope', () => {
beforeEach(() => {
window.localStorage.clear()
mocks.status.mockResolvedValue({
enabled: true,
reachable: true,
status: 'ready',
base_url: 'http://localhost',
default_model: 'geo-model',
model_count: 1,
limitation_message: '',
})
mocks.models.mockResolvedValue({
items: [{ name: 'geo-model', capabilities: ['chat'] }],
total: 1,
default_model: 'geo-model',
})
})
it('ignores an answer that returns after the active project changed', async () => {
const pending = deferred<AssistantQueryResponse>()
mocks.query.mockReturnValueOnce(pending.promise)
const { result, rerender } = renderHook(
({ projectId }) => useGeoAssistant({
selectedProjectId: projectId,
selectedAreaId: null,
selectionBbox: null,
}),
{ initialProps: { projectId: 'project-1' } },
)
await waitFor(() => expect(result.current.selectedModel).toBe('geo-model'))
let request!: Promise<boolean>
act(() => {
request = result.current.ask('Wat staat hier?')
})
rerender({ projectId: 'project-2' })
await act(async () => {
pending.resolve(response('antwoord uit project 1'))
await request
})
expect(result.current.messages).toEqual([])
expect(result.current.loading).toBe(false)
expect(result.current.error).toBeNull()
})
it('lets only the newest request update a conversation', async () => {
const older = deferred<AssistantQueryResponse>()
const newer = deferred<AssistantQueryResponse>()
mocks.query
.mockReturnValueOnce(older.promise)
.mockReturnValueOnce(newer.promise)
const { result } = renderHook(() => useGeoAssistant({
selectedProjectId: 'project-1',
selectedAreaId: null,
selectionBbox: null,
}))
await waitFor(() => expect(result.current.selectedModel).toBe('geo-model'))
let olderRequest!: Promise<boolean>
let newerRequest!: Promise<boolean>
act(() => { olderRequest = result.current.ask('Eerste vraag') })
act(() => { newerRequest = result.current.ask('Tweede vraag') })
await act(async () => {
newer.resolve(response('nieuwste antwoord'))
await newerRequest
})
await act(async () => {
older.resolve(response('verouderd antwoord'))
await olderRequest
})
const assistantMessages = result.current.messages.filter((message) => message.role === 'assistant')
expect(assistantMessages.map((message) => message.content)).toEqual(['nieuwste antwoord'])
expect(result.current.loading).toBe(false)
})
})
+100 -14
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { formatError } from '../lib/formatError' import { formatError } from '../lib/formatError'
import { assistantApi } from '../services/api/assistant' import { assistantApi } from '../services/api/assistant'
import type { import type {
@@ -36,15 +36,61 @@ function readStoredPreference(): string {
} }
} }
function assistantScopeKey(
projectId: string | null,
areaId: string | null,
bbox: VectorSelectionBBox | null,
): string {
return JSON.stringify([
projectId,
areaId,
bbox?.min_x ?? null,
bbox?.min_y ?? null,
bbox?.max_x ?? null,
bbox?.max_y ?? null,
bbox?.crs ?? null,
])
}
interface AssistantConversationState {
scopeKey: string
messages: GeoAssistantMessage[]
}
interface AssistantRequestState {
scopeKey: string
requestId: number
loading: boolean
error: string | null
}
export function useGeoAssistant({ selectedProjectId, selectedAreaId, selectionBbox }: UseGeoAssistantOptions) { export function useGeoAssistant({ selectedProjectId, selectedAreaId, selectionBbox }: UseGeoAssistantOptions) {
const scopeKey = assistantScopeKey(selectedProjectId, selectedAreaId, selectionBbox)
const activeScopeRef = useRef(scopeKey)
const latestRequestIdRef = useRef(0)
if (activeScopeRef.current !== scopeKey) {
activeScopeRef.current = scopeKey
latestRequestIdRef.current += 1
}
const [status, setStatus] = useState<AssistantStatus | null>(null) const [status, setStatus] = useState<AssistantStatus | null>(null)
const [models, setModels] = useState<AssistantModelRead[]>([]) const [models, setModels] = useState<AssistantModelRead[]>([])
const [selectedModelChoice, setSelectedModelChoice] = useState(readStoredPreference) const [selectedModelChoice, setSelectedModelChoice] = useState(readStoredPreference)
const [defaultModel, setDefaultModel] = useState('') const [defaultModel, setDefaultModel] = useState('')
const [messages, setMessages] = useState<GeoAssistantMessage[]>([]) const [conversation, setConversation] = useState<AssistantConversationState>({ scopeKey, messages: [] })
const [loading, setLoading] = useState(false) const [requestState, setRequestState] = useState<AssistantRequestState>({
scopeKey,
requestId: 0,
loading: false,
error: null,
})
const [loadingModels, setLoadingModels] = useState(false) const [loadingModels, setLoadingModels] = useState(false)
const [error, setError] = useState<string | null>(null) const [modelError, setModelError] = useState<string | null>(null)
const messages = conversation.scopeKey === scopeKey ? conversation.messages : []
const loading = requestState.scopeKey === scopeKey && requestState.loading
const queryError = requestState.scopeKey === scopeKey ? requestState.error : null
const error = queryError ?? modelError
const selectedModel = useMemo(() => { const selectedModel = useMemo(() => {
const available = new Set(models.map((model) => model.name)) const available = new Set(models.map((model) => model.name))
@@ -62,7 +108,7 @@ export function useGeoAssistant({ selectedProjectId, selectedAreaId, selectionBb
const loadModels = async () => { const loadModels = async () => {
setLoadingModels(true) setLoadingModels(true)
setError(null) setModelError(null)
try { try {
const currentStatus = await assistantApi.status() const currentStatus = await assistantApi.status()
setStatus(currentStatus) setStatus(currentStatus)
@@ -82,22 +128,39 @@ export function useGeoAssistant({ selectedProjectId, selectedAreaId, selectionBb
setStatus(null) setStatus(null)
setModels([]) setModels([])
setDefaultModel('') setDefaultModel('')
setError(formatError(requestError, 'De lokale AI-assistent kon niet worden bereikt.')) setModelError(formatError(requestError, 'De lokale AI-assistent kon niet worden bereikt.'))
} finally { } finally {
setLoadingModels(false) setLoadingModels(false)
} }
} }
useEffect(() => { void loadModels() }, []) useEffect(() => { void loadModels() }, [])
useEffect(() => { setMessages([]); setError(null) }, [selectedProjectId]) useEffect(() => {
setConversation({ scopeKey, messages: [] })
setRequestState({
scopeKey,
requestId: latestRequestIdRef.current,
loading: false,
error: null,
})
}, [scopeKey])
const ask = async (question: string): Promise<boolean> => { const ask = async (question: string): Promise<boolean> => {
const trimmed = question.trim() const trimmed = question.trim()
if (!selectedProjectId || !trimmed || !selectedModel) return false if (!selectedProjectId || !trimmed || !selectedModel) return false
const requestId = latestRequestIdRef.current + 1
latestRequestIdRef.current = requestId
const requestScopeKey = scopeKey
const userMessage: GeoAssistantMessage = { id: nextAssistantMessageId('user'), role: 'user', content: trimmed } const userMessage: GeoAssistantMessage = { id: nextAssistantMessageId('user'), role: 'user', content: trimmed }
setMessages((current) => [...current, userMessage]) setConversation((current) => ({
setLoading(true) scopeKey: requestScopeKey,
setError(null) messages: [...(current.scopeKey === requestScopeKey ? current.messages : []), userMessage],
}))
setRequestState({ scopeKey: requestScopeKey, requestId, loading: true, error: null })
const isLatestRequest = () => (
latestRequestIdRef.current === requestId
&& activeScopeRef.current === requestScopeKey
)
try { try {
const history = messages.slice(-6).map(({ role, content }) => ({ role, content })) const history = messages.slice(-6).map(({ role, content }) => ({ role, content }))
const result = await assistantApi.query(selectedProjectId, { const result = await assistantApi.query(selectedProjectId, {
@@ -107,17 +170,40 @@ export function useGeoAssistant({ selectedProjectId, selectedAreaId, selectionBb
area_id: selectedAreaId, area_id: selectedAreaId,
history, history,
}) })
setMessages((current) => [...current, { id: nextAssistantMessageId('assistant'), role: 'assistant', content: result.answer, response: result }]) if (!isLatestRequest()) return false
setConversation((current) => current.scopeKey === requestScopeKey ? {
scopeKey: requestScopeKey,
messages: [...current.messages, { id: nextAssistantMessageId('assistant'), role: 'assistant', content: result.answer, response: result }],
} : current)
return true return true
} catch (requestError) { } catch (requestError) {
setError(formatError(requestError, 'GeoIntel kon de vraag niet beantwoorden.')) if (!isLatestRequest()) return false
setRequestState({
scopeKey: requestScopeKey,
requestId,
loading: false,
error: formatError(requestError, 'GeoIntel kon de vraag niet beantwoorden.'),
})
return false return false
} finally { } finally {
setLoading(false) if (isLatestRequest()) {
setRequestState((current) => current.scopeKey === requestScopeKey && current.requestId === requestId
? { ...current, loading: false }
: current)
}
} }
} }
const clear = () => { setMessages([]); setError(null) } const clear = () => {
latestRequestIdRef.current += 1
setConversation({ scopeKey, messages: [] })
setRequestState({
scopeKey,
requestId: latestRequestIdRef.current,
loading: false,
error: null,
})
}
return { status, models, selectedModel, selectedModelChoice, defaultModel, messages, loading, loadingModels, error, loadModels, ask, clear, setSelectedModel } return { status, models, selectedModel, selectedModelChoice, defaultModel, messages, loading, loadingModels, error, loadModels, ask, clear, setSelectedModel }
} }
+2 -2
View File
@@ -26,7 +26,7 @@ export function useMapSelectionDataset({
const deriveMapSelectionDataset = async (bbox: VectorSelectionBBox, areaId?: string) => { const deriveMapSelectionDataset = async (bbox: VectorSelectionBBox, areaId?: string) => {
if (!selectedProjectId || !selectedDataset || !isVectorDatasetType(selectedDataset.dataset_type)) { if (!selectedProjectId || !selectedDataset || !isVectorDatasetType(selectedDataset.dataset_type)) {
setSelectionDatasetError('Select a vector dataset before saving the area as a dataset.') setSelectionDatasetError('Kies eerst een vectorbron om het gebied als bron te bewaren.')
return null return null
} }
setSelectionDatasetSaving(true) setSelectionDatasetSaving(true)
@@ -44,7 +44,7 @@ export function useMapSelectionDataset({
setMapLayerVisible(true) setMapLayerVisible(true)
return derived return derived
} catch (error) { } catch (error) {
setSelectionDatasetError(formatError(error, 'Failed to save area as dataset')) setSelectionDatasetError(formatError(error, 'Het gebied kon niet als bron worden bewaard'))
return null return null
} finally { } finally {
setSelectionDatasetSaving(false) setSelectionDatasetSaving(false)
+2 -2
View File
@@ -24,7 +24,7 @@ export function useMapSelectionQa({
const runMapSelectionQa = async (candidateDataset = latestSelectionDataset) => { const runMapSelectionQa = async (candidateDataset = latestSelectionDataset) => {
if (!selectedProjectId) { if (!selectedProjectId) {
setMapSelectionQaError('Select a project before running QA/QC.') setMapSelectionQaError('Kies eerst een werkruimte om een kwaliteitscontrole te draaien.')
return null return null
} }
if (!candidateDataset) { if (!candidateDataset) {
@@ -32,7 +32,7 @@ export function useMapSelectionQa({
return null return null
} }
if (!selectedMapQaReferenceDatasetId) { if (!selectedMapQaReferenceDatasetId) {
setMapSelectionQaError('Select a reference dataset for QA/QC.') setMapSelectionQaError('Kies eerst een referentiebron voor de kwaliteitscontrole.')
return null return null
} }
if (candidateDataset.id === selectedMapQaReferenceDatasetId) { if (candidateDataset.id === selectedMapQaReferenceDatasetId) {
+2 -2
View File
@@ -89,8 +89,8 @@ export function useMapWorkspaceState({
} }
return 'Geen actieve kaartlaag' return 'Geen actieve kaartlaag'
}, [changeDetectionGeoJson, datasetLayerActive, detectionGeoJson, segmentationGeoJson, selectedDataset]) }, [changeDetectionGeoJson, datasetLayerActive, detectionGeoJson, segmentationGeoJson, selectedDataset])
const mapFeatureCount = mapFeatureCollection?.features.length ?? 0 const mapFeatureCount = mapFeatureCollection?.features?.length ?? 0
const areaFeatureCount = areaFeatureCollection?.features.length ?? 0 const areaFeatureCount = areaFeatureCollection?.features?.length ?? 0
useEffect(() => { useEffect(() => {
setSelectedMapFeature(null) setSelectedMapFeature(null)
+4
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { formatAuthError } from '../lib/authError' import { formatAuthError } from '../lib/authError'
import { getAuthSession, logout, type AuthSession } from '../services/api/auth' import { getAuthSession, logout, type AuthSession } from '../services/api/auth'
import { vergeetGedeeldeVerzoeken } from '../services/api/client'
const signedOutSession: AuthSession = { const signedOutSession: AuthSession = {
authentication_required: true, authentication_required: true,
@@ -53,6 +54,8 @@ export function useOperatorSession() {
setLoggingOut(true) setLoggingOut(true)
try { try {
setSession(await logout()) setSession(await logout())
// Geen gedeelde antwoorden meenemen naar de volgende gebruiker.
vergeetGedeeldeVerzoeken()
setSessionError(null) setSessionError(null)
} catch (error) { } catch (error) {
setSessionError(formatAuthError(error, 'Uitloggen is niet gelukt. Vernieuw de pagina en probeer opnieuw.')) setSessionError(formatAuthError(error, 'Uitloggen is niet gelukt. Vernieuw de pagina en probeer opnieuw.'))
@@ -62,6 +65,7 @@ export function useOperatorSession() {
} }
const handleAuthenticated = (authenticatedSession: AuthSession) => { const handleAuthenticated = (authenticatedSession: AuthSession) => {
vergeetGedeeldeVerzoeken()
setSession(authenticatedSession) setSession(authenticatedSession)
setSessionError(null) setSessionError(null)
} }
@@ -16,7 +16,7 @@ export function useProviderCapabilities() {
const providerResponse = await externalApi.listProviders() const providerResponse = await externalApi.listProviders()
setProviderCapabilities(providerResponse.providers) setProviderCapabilities(providerResponse.providers)
} catch (error) { } catch (error) {
setCapabilitiesError(error instanceof Error ? error.message : 'Failed to load external capabilities') setCapabilitiesError(error instanceof Error ? error.message : 'De bronkoppelingen konden niet worden geladen')
} finally { } finally {
setLoadingCapabilities(false) setLoadingCapabilities(false)
} }
+6 -6
View File
@@ -33,13 +33,13 @@ export function useQualityWorkflow({ selectedProjectId, loadProjectData }: Quali
setQualityChecks(response.items) setQualityChecks(response.items)
return response.items return response.items
} catch (error) { } catch (error) {
setQualityChecksError(formatError(error, 'Failed to load QA/QC results')) setQualityChecksError(formatError(error, 'De kwaliteitscontroles konden niet worden geladen'))
} }
} }
const loadQualityEvidenceGeoJson = async (qualityCheckId: string, projectId = selectedProjectId): Promise<QualityEvidenceGeoJsonResponse | null> => { const loadQualityEvidenceGeoJson = async (qualityCheckId: string, projectId = selectedProjectId): Promise<QualityEvidenceGeoJsonResponse | null> => {
if (!projectId) { if (!projectId) {
setQualityEvidenceError('Select a project first') setQualityEvidenceError('Kies eerst een werkruimte')
return null return null
} }
setQualityEvidenceLoading(true) setQualityEvidenceLoading(true)
@@ -49,7 +49,7 @@ export function useQualityWorkflow({ selectedProjectId, loadProjectData }: Quali
setQualityEvidenceGeoJson(response) setQualityEvidenceGeoJson(response)
return response return response
} catch (error) { } catch (error) {
setQualityEvidenceError(formatError(error, 'Failed to load QA/QC evidence overlay')) setQualityEvidenceError(formatError(error, 'De bewijslaag van de kwaliteitscontrole kon niet worden geladen'))
return null return null
} finally { } finally {
setQualityEvidenceLoading(false) setQualityEvidenceLoading(false)
@@ -63,15 +63,15 @@ export function useQualityWorkflow({ selectedProjectId, loadProjectData }: Quali
const runQaComparison = async () => { const runQaComparison = async () => {
if (!selectedProjectId) { if (!selectedProjectId) {
setQaError('Select a project first') setQaError('Kies eerst een werkruimte')
return return
} }
if (!qaCandidateDatasetId) { if (!qaCandidateDatasetId) {
setQaError('Select candidate dataset') setQaError('Kies eerst een kandidaatbron')
return return
} }
if (!qaReferenceDatasetId) { if (!qaReferenceDatasetId) {
setQaError('Select reference dataset') setQaError('Kies eerst een referentiebron')
return return
} }
if (qaCandidateDatasetId === qaReferenceDatasetId) { if (qaCandidateDatasetId === qaReferenceDatasetId) {
@@ -0,0 +1,220 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { JobRead, SegmentationRead, SegmentationRunRead } from '../types'
const mocks = vi.hoisted(() => ({
listModels: vi.fn(),
runAsync: vi.fn(),
listRuns: vi.fn(),
getRun: vi.fn(),
listSegmentations: vi.fn(),
getRunGeoJson: vi.fn(),
compareWithReference: vi.fn(),
}))
vi.mock('../services/api', () => ({
segmentationApi: {
listModels: mocks.listModels,
runAsync: mocks.runAsync,
listRuns: mocks.listRuns,
getRun: mocks.getRun,
listSegmentations: mocks.listSegmentations,
getRunGeoJson: mocks.getRunGeoJson,
compareWithReference: mocks.compareWithReference,
},
}))
import { useSegmentationWorkflow } from './useSegmentationWorkflow'
const projectId = 'project-1'
const datasetId = 'dataset-1'
const jobId = 'job-1'
const analysisRunId = 'run-1'
const completedJob: JobRead = {
id: jobId,
job_type: 'segmentation.run',
status: 'success',
project_id: projectId,
dataset_id: datasetId,
parameters_json: {},
result_json: { analysis_run_id: analysisRunId, segmentation_count: 2 },
}
const persistedRun: SegmentationRunRead = {
id: analysisRunId,
project_id: projectId,
dataset_id: datasetId,
job_id: jobId,
analysis_type: 'segmentation',
status: 'success',
model_name: 'yolo-seg-configured',
parameters_json: {},
result_json: { segmentation_count: 2 },
}
function renderWorkflow(selectedProjectId = projectId) {
const loadProjectData = vi.fn().mockResolvedValue(undefined)
const loadQualityChecks = vi.fn().mockResolvedValue([])
const view = renderHook(() => useSegmentationWorkflow({
selectedProjectId,
rasterDatasets: [],
qaIouThreshold: 0.5,
loadProjectData,
loadQualityChecks,
}))
return { ...view, loadProjectData }
}
describe('useSegmentationWorkflow GPU execution', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.listModels.mockResolvedValue({
models: [{
model_id: 'yolo-seg-configured',
display_name: 'YOLO segmentatie',
framework: 'ultralytics/pytorch',
task_type: 'segmentation',
supported_classes: ['building'],
configured: true,
status: 'configured',
limitation_message: '',
operator_review_required: true,
}],
})
mocks.runAsync.mockResolvedValue(completedJob)
mocks.listRuns.mockResolvedValue({ items: [persistedRun], total: 1 })
mocks.getRun.mockResolvedValue(persistedRun)
mocks.listSegmentations.mockResolvedValue({ items: [], total: 0, truncated: false })
mocks.getRunGeoJson.mockResolvedValue({ type: 'FeatureCollection', features: [] })
})
it('queues, follows and reconciles a persisted segmentation result', async () => {
const { result, loadProjectData } = renderWorkflow()
await act(async () => { await result.current.loadSegmentationModels() })
act(() => {
result.current.setSelectedSegmentationDatasetId(datasetId)
result.current.setSegmentationTileManifestPath('/tiles/manifest.json')
})
await act(async () => { await result.current.runSegmentation() })
expect(mocks.runAsync).toHaveBeenCalledWith(expect.objectContaining({
project_id: projectId,
dataset_id: datasetId,
model_id: 'yolo-seg-configured',
tile_manifest_path: '/tiles/manifest.json',
}))
expect(mocks.getRun).toHaveBeenCalledWith(analysisRunId, projectId)
expect(result.current.segmentationRunResult).toMatchObject({
analysis_run_id: analysisRunId,
job_id: jobId,
segmentation_count: 2,
status: 'success',
})
expect(result.current.segmentationRunError).toBeNull()
expect(result.current.segmentationTotal).toBe(0)
expect(result.current.segmentationTruncated).toBe(false)
expect(loadProjectData).toHaveBeenCalledWith(projectId)
})
it('does not queue a configured model without a tile manifest', async () => {
const { result } = renderWorkflow()
await act(async () => { await result.current.loadSegmentationModels() })
act(() => { result.current.setSelectedSegmentationDatasetId(datasetId) })
await act(async () => { await result.current.runSegmentation() })
expect(mocks.runAsync).not.toHaveBeenCalled()
expect(result.current.segmentationRunError).toContain('beeldtegelmanifest')
})
it('ignores a late run list after the active project changes', async () => {
let resolveOlder!: (value: { items: SegmentationRunRead[]; total: number }) => void
let resolveNewer!: (value: { items: SegmentationRunRead[]; total: number }) => void
mocks.listRuns
.mockReturnValueOnce(new Promise((resolve) => { resolveOlder = resolve }))
.mockReturnValueOnce(new Promise((resolve) => { resolveNewer = resolve }))
const loadProjectData = vi.fn().mockResolvedValue(undefined)
const loadQualityChecks = vi.fn().mockResolvedValue([])
const { result, rerender } = renderHook(
({ selectedProjectId }) => useSegmentationWorkflow({
selectedProjectId,
rasterDatasets: [],
qaIouThreshold: 0.5,
loadProjectData,
loadQualityChecks,
}),
{ initialProps: { selectedProjectId: 'project-1' } },
)
let olderRequest!: Promise<void>
let newerRequest!: Promise<void>
act(() => { olderRequest = result.current.loadSegmentationRuns('project-1') })
rerender({ selectedProjectId: 'project-2' })
act(() => { newerRequest = result.current.loadSegmentationRuns('project-2') })
const projectTwoRun = { ...persistedRun, id: 'run-2', project_id: 'project-2' }
await act(async () => {
resolveNewer({ items: [projectTwoRun], total: 1 })
await newerRequest
})
await act(async () => {
resolveOlder({ items: [persistedRun], total: 1 })
await olderRequest
})
expect(result.current.segmentationRuns).toEqual([projectTwoRun])
expect(result.current.selectedSegmentationRunId).toBe('run-2')
})
it('ignores late polygons from another project and clears an empty selection loader', async () => {
let resolveOlderList!: (value: { items: SegmentationRead[]; total: number }) => void
let resolveNewerList!: (value: { items: SegmentationRead[]; total: number }) => void
let resolveOlderGeo!: (value: GeoJSON.FeatureCollection) => void
let resolveNewerGeo!: (value: GeoJSON.FeatureCollection) => void
mocks.listSegmentations
.mockReturnValueOnce(new Promise((resolve) => { resolveOlderList = resolve }))
.mockReturnValueOnce(new Promise((resolve) => { resolveNewerList = resolve }))
mocks.getRunGeoJson
.mockReturnValueOnce(new Promise((resolve) => { resolveOlderGeo = resolve }))
.mockReturnValueOnce(new Promise((resolve) => { resolveNewerGeo = resolve }))
const loadProjectData = vi.fn().mockResolvedValue(undefined)
const loadQualityChecks = vi.fn().mockResolvedValue([])
const { result, rerender } = renderHook(
({ selectedProjectId }) => useSegmentationWorkflow({
selectedProjectId,
rasterDatasets: [],
qaIouThreshold: 0.5,
loadProjectData,
loadQualityChecks,
}),
{ initialProps: { selectedProjectId: 'project-1' } },
)
const oldItem: SegmentationRead = {
id: 'segment-1', project_id: 'project-1', analysis_run_id: 'run-1', model_name: 'model', class_name: 'building',
}
const newItem: SegmentationRead = {
id: 'segment-2', project_id: 'project-2', analysis_run_id: 'run-2', model_name: 'model', class_name: 'building',
}
let olderRequest!: Promise<void>
let newerRequest!: Promise<void>
act(() => { olderRequest = result.current.loadSegmentationResults('run-1') })
rerender({ selectedProjectId: 'project-2' })
act(() => { newerRequest = result.current.loadSegmentationResults('run-2') })
await act(async () => {
resolveNewerList({ items: [newItem], total: 1 })
resolveNewerGeo({ type: 'FeatureCollection', features: [] })
await newerRequest
})
await act(async () => {
resolveOlderList({ items: [oldItem], total: 1 })
resolveOlderGeo({ type: 'FeatureCollection', features: [] })
await olderRequest
})
expect(result.current.segmentationItems).toEqual([newItem])
await act(async () => { await result.current.loadSegmentationResults('') })
expect(result.current.loadingSegmentationResults).toBe(false)
expect(result.current.segmentationItems).toEqual([])
})
})
+248 -31
View File
@@ -1,7 +1,8 @@
import { useMemo, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { segmentationApi } from '../services/api' import { segmentationApi } from '../services/api'
import type { import type {
DatasetCreateResponse, DatasetCreateResponse,
JobRead,
QualityCheckRead, QualityCheckRead,
SegmentationModelCapability, SegmentationModelCapability,
SegmentationQaResult, SegmentationQaResult,
@@ -10,6 +11,12 @@ import type {
SegmentationRunResponse, SegmentationRunResponse,
} from '../types' } from '../types'
import { formatError } from '../lib/formatError' import { formatError } from '../lib/formatError'
import {
analysisRunIdFromSegmentationJob,
completedSegmentationResponse,
SegmentationJobError,
waitForSegmentationJob,
} from '../services/segmentationJob'
interface SegmentationWorkflowOptions { interface SegmentationWorkflowOptions {
selectedProjectId: string | null selectedProjectId: string | null
@@ -19,6 +26,16 @@ interface SegmentationWorkflowOptions {
loadQualityChecks: (projectId?: string | null) => Promise<QualityCheckRead[] | void> loadQualityChecks: (projectId?: string | null) => Promise<QualityCheckRead[] | void>
} }
function isAbortError(error: unknown): boolean {
return error instanceof Error && error.name === 'AbortError'
}
function abortedError(): Error {
const error = new Error('Het volgen van de segmentatietaak is gestopt')
error.name = 'AbortError'
return error
}
export function useSegmentationWorkflow({ export function useSegmentationWorkflow({
selectedProjectId, selectedProjectId,
rasterDatasets, rasterDatasets,
@@ -34,11 +51,14 @@ export function useSegmentationWorkflow({
const [segmentationTileManifestPath, setSegmentationTileManifestPath] = useState('') const [segmentationTileManifestPath, setSegmentationTileManifestPath] = useState('')
const [segmentationConfidenceThreshold, setSegmentationConfidenceThreshold] = useState(0.5) const [segmentationConfidenceThreshold, setSegmentationConfidenceThreshold] = useState(0.5)
const [runningSegmentation, setRunningSegmentation] = useState(false) const [runningSegmentation, setRunningSegmentation] = useState(false)
const [segmentationJob, setSegmentationJob] = useState<JobRead | null>(null)
const [segmentationRunResult, setSegmentationRunResult] = useState<SegmentationRunResponse | null>(null) const [segmentationRunResult, setSegmentationRunResult] = useState<SegmentationRunResponse | null>(null)
const [segmentationRunError, setSegmentationRunError] = useState<string | null>(null) const [segmentationRunError, setSegmentationRunError] = useState<string | null>(null)
const [segmentationRuns, setSegmentationRuns] = useState<SegmentationRunRead[]>([]) const [segmentationRuns, setSegmentationRuns] = useState<SegmentationRunRead[]>([])
const [selectedSegmentationRunId, setSelectedSegmentationRunId] = useState('') const [selectedSegmentationRunId, setSelectedSegmentationRunId] = useState('')
const [segmentationItems, setSegmentationItems] = useState<SegmentationRead[]>([]) const [segmentationItems, setSegmentationItems] = useState<SegmentationRead[]>([])
const [segmentationTotal, setSegmentationTotal] = useState(0)
const [segmentationTruncated, setSegmentationTruncated] = useState(false)
const [segmentationGeoJson, setSegmentationGeoJson] = useState<GeoJSON.FeatureCollection | null>(null) const [segmentationGeoJson, setSegmentationGeoJson] = useState<GeoJSON.FeatureCollection | null>(null)
const [segmentationClassFilter, setSegmentationClassFilter] = useState('') const [segmentationClassFilter, setSegmentationClassFilter] = useState('')
const [segmentationMinConfidenceFilter, setSegmentationMinConfidenceFilter] = useState(0) const [segmentationMinConfidenceFilter, setSegmentationMinConfidenceFilter] = useState(0)
@@ -47,6 +67,41 @@ export function useSegmentationWorkflow({
const [segmentationQaResult, setSegmentationQaResult] = useState<SegmentationQaResult | null>(null) const [segmentationQaResult, setSegmentationQaResult] = useState<SegmentationQaResult | null>(null)
const [segmentationQaError, setSegmentationQaError] = useState<string | null>(null) const [segmentationQaError, setSegmentationQaError] = useState<string | null>(null)
const [runningSegmentationQa, setRunningSegmentationQa] = useState(false) const [runningSegmentationQa, setRunningSegmentationQa] = useState(false)
const activeSegmentationControllerRef = useRef<AbortController | null>(null)
const selectedProjectIdRef = useRef(selectedProjectId)
const segmentationExecutionSequence = useRef(0)
const segmentationRunsRequestSequence = useRef(0)
const segmentationResultsRequestSequence = useRef(0)
const segmentationQaRequestSequence = useRef(0)
selectedProjectIdRef.current = selectedProjectId
useEffect(() => {
activeSegmentationControllerRef.current?.abort()
activeSegmentationControllerRef.current = null
segmentationExecutionSequence.current += 1
segmentationRunsRequestSequence.current += 1
segmentationResultsRequestSequence.current += 1
segmentationQaRequestSequence.current += 1
setSelectedSegmentationDatasetId('')
setSegmentationRuns([])
setSelectedSegmentationRunId('')
setSegmentationItems([])
setSegmentationTotal(0)
setSegmentationTruncated(false)
setSegmentationGeoJson(null)
setSegmentationRunResult(null)
setSegmentationRunError(null)
setSegmentationJob(null)
setRunningSegmentation(false)
setLoadingSegmentationResults(false)
setSegmentationTileManifestPath('')
setSegmentationQaResult(null)
setSegmentationQaError(null)
setRunningSegmentationQa(false)
return () => {
activeSegmentationControllerRef.current?.abort()
}
}, [selectedProjectId])
const selectedSegmentationModel = useMemo( const selectedSegmentationModel = useMemo(
() => segmentationModels.find((model) => model.model_id === selectedSegmentationModelId) ?? null, () => segmentationModels.find((model) => model.model_id === selectedSegmentationModelId) ?? null,
@@ -72,39 +127,61 @@ export function useSegmentationWorkflow({
) )
} }
} catch (error) { } catch (error) {
setSegmentationModelError(formatError(error, 'Failed to load segmentation models')) setSegmentationModelError(formatError(error, 'De segmentatiemodellen konden niet worden geladen'))
} finally { } finally {
setLoadingSegmentationModels(false) setLoadingSegmentationModels(false)
} }
} }
const loadSegmentationRuns = async (projectId = selectedProjectId) => { const loadSegmentationRuns = async (projectId = selectedProjectId) => {
const sequence = segmentationRunsRequestSequence.current + 1
segmentationRunsRequestSequence.current = sequence
if (!projectId) { if (!projectId) {
setSegmentationRuns([]) setSegmentationRuns([])
setSelectedSegmentationRunId('')
return return
} }
try { try {
const response = await segmentationApi.listRuns({ project_id: projectId }) const response = await segmentationApi.listRuns({ project_id: projectId })
if (
segmentationRunsRequestSequence.current !== sequence
|| selectedProjectIdRef.current !== projectId
) return
setSegmentationRuns(response.items) setSegmentationRuns(response.items)
if (!selectedSegmentationRunId && response.items.length > 0) { setSelectedSegmentationRunId((current) => (
setSelectedSegmentationRunId(response.items[0].id) response.items.some((run) => run.id === current) ? current : response.items[0]?.id ?? ''
} ))
} catch (error) { } catch (error) {
setSegmentationRunError(formatError(error, 'Failed to load segmentation runs')) if (
segmentationRunsRequestSequence.current === sequence
&& selectedProjectIdRef.current === projectId
) {
setSegmentationRunError(formatError(error, 'De segmentatieruns konden niet worden geladen'))
}
} }
} }
const loadSegmentationResults = async (analysisRunId = selectedSegmentationRunId) => { const loadSegmentationResults = async (analysisRunId = selectedSegmentationRunId) => {
if (!analysisRunId) { const sequence = segmentationResultsRequestSequence.current + 1
segmentationResultsRequestSequence.current = sequence
const requestProjectId = selectedProjectIdRef.current
if (!analysisRunId || !requestProjectId) {
setSegmentationItems([]) setSegmentationItems([])
setSegmentationTotal(0)
setSegmentationTruncated(false)
setSegmentationGeoJson(null) setSegmentationGeoJson(null)
setLoadingSegmentationResults(false)
return return
} }
setLoadingSegmentationResults(true) setLoadingSegmentationResults(true)
setSegmentationRunError(null) setSegmentationRunError(null)
setSegmentationItems([])
setSegmentationTotal(0)
setSegmentationTruncated(false)
setSegmentationGeoJson(null)
try { try {
const params = { const params = {
project_id: selectedProjectId ?? '', project_id: requestProjectId,
class_name: segmentationClassFilter || null, class_name: segmentationClassFilter || null,
min_confidence: segmentationMinConfidenceFilter > 0 ? segmentationMinConfidenceFilter : null, min_confidence: segmentationMinConfidenceFilter > 0 ? segmentationMinConfidenceFilter : null,
} }
@@ -112,93 +189,230 @@ export function useSegmentationWorkflow({
segmentationApi.listSegmentations(analysisRunId, params), segmentationApi.listSegmentations(analysisRunId, params),
segmentationApi.getRunGeoJson(analysisRunId, params), segmentationApi.getRunGeoJson(analysisRunId, params),
]) ])
if (
segmentationResultsRequestSequence.current !== sequence
|| selectedProjectIdRef.current !== requestProjectId
) return
if (segmentationsResponse.items.some((item) => (
item.project_id !== requestProjectId || item.analysis_run_id !== analysisRunId
))) {
throw new Error('De server retourneerde segmentaties uit een andere werkruimte of analyserun')
}
setSegmentationItems(segmentationsResponse.items) setSegmentationItems(segmentationsResponse.items)
setSegmentationTotal(segmentationsResponse.total)
setSegmentationTruncated(Boolean(segmentationsResponse.truncated))
setSegmentationGeoJson(geoJsonResponse) setSegmentationGeoJson(geoJsonResponse)
} catch (error) { } catch (error) {
setSegmentationRunError(formatError(error, 'Failed to load segmentation results')) if (
segmentationResultsRequestSequence.current === sequence
&& selectedProjectIdRef.current === requestProjectId
) {
setSegmentationRunError(formatError(error, 'De segmentatieresultaten konden niet worden geladen'))
}
} finally { } finally {
if (
segmentationResultsRequestSequence.current === sequence
&& selectedProjectIdRef.current === requestProjectId
) {
setLoadingSegmentationResults(false) setLoadingSegmentationResults(false)
} }
} }
}
const runSegmentation = async () => { const runSegmentation = async () => {
if (!selectedProjectId) { if (!selectedProjectId) {
setSegmentationRunError('Select a project first') setSegmentationRunError('Kies eerst een werkruimte')
return return
} }
const datasetId = selectedSegmentationDatasetId || rasterDatasets[0]?.id const datasetId = selectedSegmentationDatasetId || rasterDatasets[0]?.id
if (!datasetId) { if (!datasetId) {
setSegmentationRunError('Select a raster dataset') setSegmentationRunError('Kies eerst een rasterbron')
return return
} }
if (!selectedSegmentationModel?.configured) { if (!selectedSegmentationModel?.configured) {
setSegmentationRunError('Selected segmentation model is not configured') setSegmentationRunError('Het gekozen segmentatiemodel is niet geconfigureerd')
return return
} }
setSegmentationRunError(null) if (selectedSegmentationModelId === 'fixture-segmenter') {
setSegmentationRunResult(null) setSegmentationRunError('Het fixturemodel is uitsluitend beschikbaar voor expliciete geautomatiseerde tests')
setRunningSegmentation(true) return
try { }
const parameters = if (!segmentationTileManifestPath.trim()) {
selectedSegmentationModelId === 'fixture-segmenter' setSegmentationRunError('Koppel eerst het beeldtegelmanifest van het gekozen rasterbestand')
? { fixture_mode: true, fixture_segmentations: [] } return
: {} }
const result = await segmentationApi.run({ if (
project_id: selectedProjectId, (activeSegmentationControllerRef.current && !activeSegmentationControllerRef.current.signal.aborted)
|| segmentationJob?.status === 'queued'
|| segmentationJob?.status === 'running'
) {
setSegmentationRunError('Er wordt al een GPU-segmentatietaak verwerkt. Wacht tot die taak klaar is.')
return
}
const projectId = selectedProjectId
const parameters: Record<string, unknown> = {}
const request = {
project_id: projectId,
dataset_id: datasetId, dataset_id: datasetId,
model_id: selectedSegmentationModelId, model_id: selectedSegmentationModelId,
confidence_threshold: segmentationConfidenceThreshold, confidence_threshold: segmentationConfidenceThreshold,
tile_manifest_path: segmentationTileManifestPath.trim() || null, tile_manifest_path: segmentationTileManifestPath.trim() || null,
parameters_json: parameters, parameters_json: parameters,
}
const controller = new AbortController()
const executionSequence = segmentationExecutionSequence.current + 1
segmentationExecutionSequence.current = executionSequence
activeSegmentationControllerRef.current = controller
const assertExecutionCurrent = () => {
if (
controller.signal.aborted
|| segmentationExecutionSequence.current !== executionSequence
|| selectedProjectIdRef.current !== projectId
) {
throw abortedError()
}
}
setSegmentationRunError(null)
setSegmentationRunResult(null)
setRunningSegmentation(true)
setSegmentationJob(null)
try {
const queuedJob = await segmentationApi.runAsync(request)
assertExecutionCurrent()
setSegmentationJob(queuedJob)
const completedJob = await waitForSegmentationJob({
projectId,
initialJob: queuedJob,
signal: controller.signal,
onStatus: (job) => {
if (
segmentationExecutionSequence.current === executionSequence
&& selectedProjectIdRef.current === projectId
) {
setSegmentationJob(job)
}
},
}) })
assertExecutionCurrent()
const explicitAnalysisRunId = analysisRunIdFromSegmentationJob(completedJob)
const run = explicitAnalysisRunId
? await segmentationApi.getRun(explicitAnalysisRunId, projectId)
: (await segmentationApi.listRuns({ project_id: projectId, dataset_id: datasetId })).items
.find((candidate) => candidate.job_id === completedJob.id)
assertExecutionCurrent()
if (!run) {
throw new SegmentationJobError(
'De GPU-taak is voltooid, maar de bijbehorende bewaarde segmentatierun ontbreekt.',
'SEGMENTATION_RUN_RESULT_NOT_FOUND',
completedJob.id,
)
}
const result = completedSegmentationResponse(request, completedJob, run)
setSegmentationRunError(null)
setSegmentationRunResult(result) setSegmentationRunResult(result)
setSelectedSegmentationRunId(result.analysis_run_id) setSelectedSegmentationRunId(result.analysis_run_id)
await loadSegmentationRuns(selectedProjectId) await loadSegmentationRuns(projectId)
assertExecutionCurrent()
await loadSegmentationResults(result.analysis_run_id) await loadSegmentationResults(result.analysis_run_id)
await loadProjectData(selectedProjectId) assertExecutionCurrent()
await loadProjectData(projectId)
} catch (error) { } catch (error) {
setSegmentationRunError(formatError(error, 'Segmentation run failed')) if (
!isAbortError(error)
&& segmentationExecutionSequence.current === executionSequence
&& selectedProjectIdRef.current === projectId
) {
setSegmentationRunError(formatError(error, 'De segmentatie is mislukt'))
}
} finally { } finally {
if (activeSegmentationControllerRef.current === controller) {
activeSegmentationControllerRef.current = null
}
if (
segmentationExecutionSequence.current === executionSequence
&& selectedProjectIdRef.current === projectId
) {
setRunningSegmentation(false) setRunningSegmentation(false)
} }
} }
}
const runSegmentationQa = async () => { const runSegmentationQa = async () => {
if (!selectedSegmentationRunId) { if (!selectedSegmentationRunId) {
setSegmentationQaError('Select a segmentation run') setSegmentationQaError('Kies eerst een segmentatierun')
return return
} }
if (!segmentationReferenceDatasetId) { if (!segmentationReferenceDatasetId) {
setSegmentationQaError('Select a reference dataset') setSegmentationQaError('Kies eerst een referentiebron')
return return
} }
const projectId = selectedProjectIdRef.current
if (!projectId) {
setSegmentationQaError('Kies eerst een werkruimte')
return
}
const analysisRunId = selectedSegmentationRunId
const referenceDatasetId = segmentationReferenceDatasetId
const sequence = segmentationQaRequestSequence.current + 1
segmentationQaRequestSequence.current = sequence
setSegmentationQaError(null) setSegmentationQaError(null)
setSegmentationQaResult(null) setSegmentationQaResult(null)
setRunningSegmentationQa(true) setRunningSegmentationQa(true)
try { try {
const result = await segmentationApi.compareWithReference(selectedSegmentationRunId, selectedProjectId!, { const result = await segmentationApi.compareWithReference(analysisRunId, projectId, {
reference_dataset_id: segmentationReferenceDatasetId, reference_dataset_id: referenceDatasetId,
iou_threshold: qaIouThreshold, iou_threshold: qaIouThreshold,
class_name: segmentationClassFilter || null, class_name: segmentationClassFilter || null,
min_confidence: segmentationMinConfidenceFilter > 0 ? segmentationMinConfidenceFilter : null, min_confidence: segmentationMinConfidenceFilter > 0 ? segmentationMinConfidenceFilter : null,
}) })
if (
segmentationQaRequestSequence.current !== sequence
|| selectedProjectIdRef.current !== projectId
) return
setSegmentationQaResult(result) setSegmentationQaResult(result)
await loadQualityChecks(selectedProjectId) await loadQualityChecks(projectId)
} catch (error) { } catch (error) {
setSegmentationQaError(formatError(error, 'Segmentation QA failed')) if (
segmentationQaRequestSequence.current === sequence
&& selectedProjectIdRef.current === projectId
) {
setSegmentationQaError(formatError(error, 'De segmentatiecontrole is mislukt'))
}
} finally { } finally {
if (
segmentationQaRequestSequence.current === sequence
&& selectedProjectIdRef.current === projectId
) {
setRunningSegmentationQa(false) setRunningSegmentationQa(false)
} }
} }
}
const resetSegmentationForProject = () => { const resetSegmentationForProject = () => {
activeSegmentationControllerRef.current?.abort()
activeSegmentationControllerRef.current = null
segmentationExecutionSequence.current += 1
segmentationRunsRequestSequence.current += 1
segmentationResultsRequestSequence.current += 1
segmentationQaRequestSequence.current += 1
setSelectedSegmentationDatasetId('') setSelectedSegmentationDatasetId('')
setSegmentationRuns([]) setSegmentationRuns([])
setSelectedSegmentationRunId('') setSelectedSegmentationRunId('')
setSegmentationItems([]) setSegmentationItems([])
setSegmentationTotal(0)
setSegmentationTruncated(false)
setSegmentationGeoJson(null) setSegmentationGeoJson(null)
setSegmentationRunResult(null) setSegmentationRunResult(null)
setSegmentationRunError(null)
setSegmentationJob(null)
setRunningSegmentation(false)
setLoadingSegmentationResults(false)
setSegmentationTileManifestPath('') setSegmentationTileManifestPath('')
setSegmentationQaResult(null)
setSegmentationQaError(null)
setRunningSegmentationQa(false)
} }
return { return {
@@ -211,11 +425,14 @@ export function useSegmentationWorkflow({
segmentationTileManifestPath, segmentationTileManifestPath,
segmentationConfidenceThreshold, segmentationConfidenceThreshold,
runningSegmentation, runningSegmentation,
segmentationJob,
segmentationRunResult, segmentationRunResult,
segmentationRunError, segmentationRunError,
segmentationRuns, segmentationRuns,
selectedSegmentationRunId, selectedSegmentationRunId,
segmentationItems, segmentationItems,
segmentationTotal,
segmentationTruncated,
segmentationGeoJson, segmentationGeoJson,
segmentationClassFilter, segmentationClassFilter,
segmentationMinConfidenceFilter, segmentationMinConfidenceFilter,
@@ -69,4 +69,34 @@ describe('useTemporalComparison', () => {
preview_limit: 500, preview_limit: 500,
}) })
}) })
it('keeps a newer comparison when an older request finishes last', async () => {
const resolvers: Array<(value: TemporalComparisonResponse) => void> = []
mocks.compare.mockImplementation(() => new Promise<TemporalComparisonResponse>((resolve) => {
resolvers.push(resolve)
}))
const older = { earlier_dataset_id: 'older' } as unknown as TemporalComparisonResponse
const newer = { earlier_dataset_id: 'newer' } as unknown as TemporalComparisonResponse
const { result } = renderHook(() => useTemporalComparison('project-1'))
let olderRequest: Promise<TemporalComparisonResponse | null>
let newerRequest: Promise<TemporalComparisonResponse | null>
await act(async () => {
olderRequest = result.current.compareTemporalSnapshots('older', 'later', bbox)
newerRequest = result.current.compareTemporalSnapshots('newer', 'later', bbox)
await Promise.resolve()
})
await act(async () => {
resolvers[1](newer)
await newerRequest!
})
expect(result.current.temporalComparison).toEqual(newer)
await act(async () => {
resolvers[0](older)
await olderRequest!
})
expect(result.current.temporalComparison).toEqual(newer)
expect(result.current.temporalComparisonLoading).toBe(false)
})
}) })
+14 -1
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { formatError } from '../lib/formatError' import { formatError } from '../lib/formatError'
import { temporalApi } from '../services/api/temporal' import { temporalApi } from '../services/api/temporal'
import type { TemporalComparisonResponse, VectorSelectionBBox } from '../types' import type { TemporalComparisonResponse, VectorSelectionBBox } from '../types'
@@ -7,15 +7,20 @@ export function useTemporalComparison(selectedProjectId: string | null) {
const [temporalComparison, setTemporalComparison] = useState<TemporalComparisonResponse | null>(null) const [temporalComparison, setTemporalComparison] = useState<TemporalComparisonResponse | null>(null)
const [temporalComparisonLoading, setTemporalComparisonLoading] = useState(false) const [temporalComparisonLoading, setTemporalComparisonLoading] = useState(false)
const [temporalComparisonError, setTemporalComparisonError] = useState<string | null>(null) const [temporalComparisonError, setTemporalComparisonError] = useState<string | null>(null)
const requestSequence = useRef(0)
useEffect(() => { useEffect(() => {
requestSequence.current += 1
setTemporalComparison(null) setTemporalComparison(null)
setTemporalComparisonError(null) setTemporalComparisonError(null)
setTemporalComparisonLoading(false)
}, [selectedProjectId]) }, [selectedProjectId])
const clearTemporalComparison = () => { const clearTemporalComparison = () => {
requestSequence.current += 1
setTemporalComparison(null) setTemporalComparison(null)
setTemporalComparisonError(null) setTemporalComparisonError(null)
setTemporalComparisonLoading(false)
} }
const compareTemporalSnapshots = async ( const compareTemporalSnapshots = async (
@@ -24,6 +29,8 @@ export function useTemporalComparison(selectedProjectId: string | null) {
bbox: VectorSelectionBBox, bbox: VectorSelectionBBox,
areaId?: string, areaId?: string,
): Promise<TemporalComparisonResponse | null> => { ): Promise<TemporalComparisonResponse | null> => {
const sequence = requestSequence.current + 1
requestSequence.current = sequence
if (!selectedProjectId) { if (!selectedProjectId) {
setTemporalComparisonError('Open eerst een project om evoluties te vergelijken.') setTemporalComparisonError('Open eerst een project om evoluties te vergelijken.')
return null return null
@@ -43,16 +50,22 @@ export function useTemporalComparison(selectedProjectId: string | null) {
area_id: areaId || null, area_id: areaId || null,
preview_limit: 500, preview_limit: 500,
}) })
if (requestSequence.current === sequence) {
setTemporalComparison(result) setTemporalComparison(result)
}
return result return result
} catch (error) { } catch (error) {
if (requestSequence.current === sequence) {
setTemporalComparison(null) setTemporalComparison(null)
setTemporalComparisonError(formatError(error, 'De evolutieanalyse is mislukt.')) setTemporalComparisonError(formatError(error, 'De evolutieanalyse is mislukt.'))
}
return null return null
} finally { } finally {
if (requestSequence.current === sequence) {
setTemporalComparisonLoading(false) setTemporalComparisonLoading(false)
} }
} }
}
return { return {
temporalComparison, temporalComparison,
@@ -7,8 +7,9 @@ function action() {
return vi.fn().mockResolvedValue(undefined) return vi.fn().mockResolvedValue(undefined)
} }
function options(selectedProjectId: string | null) { function options(selectedProjectId: string | null, activeWorkspace = 'map') {
return { return {
activeWorkspace,
selectedProjectId, selectedProjectId,
selectedDetectionRunId: '', selectedDetectionRunId: '',
detectionClassFilter: '', detectionClassFilter: '',
@@ -36,20 +37,17 @@ function options(selectedProjectId: string | null) {
} }
describe('useWorkbenchBootstrap', () => { describe('useWorkbenchBootstrap', () => {
it('loads global capabilities and clears project-owned state without a project', async () => { it('haalt zonder project alleen de werkruimtes op en wist projectgebonden state', async () => {
const state = options(null) const state = options(null)
renderHook(() => useWorkbenchBootstrap(state)) renderHook(() => useWorkbenchBootstrap(state))
await waitFor(() => expect(state.loadProjects).toHaveBeenCalledOnce()) await waitFor(() => expect(state.loadProjects).toHaveBeenCalledOnce())
expect(state.loadCapabilities).toHaveBeenCalledOnce()
expect(state.loadDetectionModels).toHaveBeenCalledOnce()
expect(state.loadSegmentationModels).toHaveBeenCalledOnce()
expect(state.resetProjectData).toHaveBeenCalledOnce() expect(state.resetProjectData).toHaveBeenCalledOnce()
expect(state.resetDatasetForProject).toHaveBeenCalledOnce() expect(state.resetDatasetForProject).toHaveBeenCalledOnce()
expect(state.loadProjectData).not.toHaveBeenCalled() expect(state.loadProjectData).not.toHaveBeenCalled()
}) })
it('resets stale state before loading every project-owned collection', async () => { it('laadt op het kaartwerkblad de gebieden en bronnen, en verder niets', async () => {
const state = options('project-1') const state = options('project-1')
renderHook(() => useWorkbenchBootstrap(state)) renderHook(() => useWorkbenchBootstrap(state))
@@ -59,25 +57,82 @@ describe('useWorkbenchBootstrap', () => {
expect(state.resetDetectionForProject).toHaveBeenCalledOnce() expect(state.resetDetectionForProject).toHaveBeenCalledOnce()
expect(state.resetSegmentationForProject).toHaveBeenCalledOnce() expect(state.resetSegmentationForProject).toHaveBeenCalledOnce()
expect(state.resetExportsForProject).toHaveBeenCalledOnce() expect(state.resetExportsForProject).toHaveBeenCalledOnce()
expect(state.loadDetectionRuns).toHaveBeenCalledWith('project-1')
expect(state.loadSegmentationRuns).toHaveBeenCalledWith('project-1') // Alles wat bij een ander werkblad hoort blijft liggen tot dat werkblad
expect(state.loadQualityChecks).toHaveBeenCalledWith('project-1') // geopend wordt. Voorheen ging dit alles bij het opstarten de deur uit,
expect(state.loadExports).toHaveBeenCalledWith('project-1') // ook voor werkbladen die de gebruiker nooit opende.
expect(state.loadCapabilities).not.toHaveBeenCalled()
expect(state.loadDetectionModels).not.toHaveBeenCalled()
expect(state.loadSegmentationModels).not.toHaveBeenCalled()
expect(state.loadDetectionRuns).not.toHaveBeenCalled()
expect(state.loadSegmentationRuns).not.toHaveBeenCalled()
expect(state.loadQualityChecks).not.toHaveBeenCalled()
expect(state.loadExports).not.toHaveBeenCalled()
}) })
it('loads the same analysis catalog and project results for a guest', async () => { it('haalt de beeldanalysegegevens op zodra dat werkblad open staat', async () => {
const state = { ...options('project-1'), restrictedMode: true } const state = options('project-1', 'ai')
renderHook(() => useWorkbenchBootstrap(state)) renderHook(() => useWorkbenchBootstrap(state))
await waitFor(() => expect(state.loadProjectData).toHaveBeenCalledWith('project-1')) await waitFor(() => expect(state.loadDetectionModels).toHaveBeenCalledOnce())
expect(state.loadCapabilities).toHaveBeenCalledOnce()
expect(state.loadQualityChecks).toHaveBeenCalledWith('project-1')
expect(state.loadDetectionModels).toHaveBeenCalledOnce()
expect(state.loadSegmentationModels).toHaveBeenCalledOnce() expect(state.loadSegmentationModels).toHaveBeenCalledOnce()
expect(state.loadDetectionRuns).toHaveBeenCalledWith('project-1') expect(state.loadDetectionRuns).toHaveBeenCalledWith('project-1')
expect(state.loadSegmentationRuns).toHaveBeenCalledWith('project-1') expect(state.loadSegmentationRuns).toHaveBeenCalledWith('project-1')
expect(state.loadExports).toHaveBeenCalledWith('project-1')
expect(state.loadDetectionResults).toHaveBeenCalledOnce() expect(state.loadDetectionResults).toHaveBeenCalledOnce()
expect(state.loadSegmentationResults).toHaveBeenCalledOnce() expect(state.loadSegmentationResults).toHaveBeenCalledOnce()
expect(state.loadExports).not.toHaveBeenCalled()
})
it('haalt kwaliteit, downloads en bronkoppelingen op hun eigen werkblad', async () => {
const kwaliteit = options('project-1', 'analysis')
renderHook(() => useWorkbenchBootstrap(kwaliteit))
await waitFor(() => expect(kwaliteit.loadQualityChecks).toHaveBeenCalledWith('project-1'))
const downloads = options('project-1', 'exports')
renderHook(() => useWorkbenchBootstrap(downloads))
await waitFor(() => expect(downloads.loadExports).toHaveBeenCalledWith('project-1'))
const systeem = options('project-1', 'system')
renderHook(() => useWorkbenchBootstrap(systeem))
await waitFor(() => expect(systeem.loadCapabilities).toHaveBeenCalledOnce())
})
it('herlaadt bezochte werkbladen niet wanneer een ander werkblad opent', async () => {
const state = options('project-1', 'ai')
const { rerender } = renderHook((props: { werkblad: string }) =>
useWorkbenchBootstrap({ ...state, activeWorkspace: props.werkblad }), {
initialProps: { werkblad: 'ai' },
})
await waitFor(() => expect(state.loadDetectionRuns).toHaveBeenCalledWith('project-1'))
const detectionRunCalls = state.loadDetectionRuns.mock.calls.length
const detectionResultCalls = state.loadDetectionResults.mock.calls.length
rerender({ werkblad: 'map' })
rerender({ werkblad: 'exports' })
await waitFor(() => expect(state.loadExports).toHaveBeenCalledOnce())
rerender({ werkblad: 'analysis' })
await waitFor(() => expect(state.loadQualityChecks).toHaveBeenCalledOnce())
expect(state.loadDetectionRuns).toHaveBeenCalledTimes(detectionRunCalls)
expect(state.loadDetectionResults).toHaveBeenCalledTimes(detectionResultCalls)
expect(state.loadExports).toHaveBeenCalledOnce()
})
it('meldt een mislukte laadactie in plaats van haar weg te slikken', async () => {
const state = options('project-1')
state.loadProjectData = vi.fn().mockRejectedValue(new Error('netwerk weg'))
const gemeld: string[] = []
renderHook(() => useWorkbenchBootstrap({ ...state, onLoadError: (onderdeel) => gemeld.push(onderdeel) }))
await waitFor(() => expect(gemeld).toContain('gebieden en bronnen'))
})
it('laadt voor een gast hetzelfde als voor een operator', async () => {
const state = { ...options('project-1', 'ai'), restrictedMode: true }
renderHook(() => useWorkbenchBootstrap(state))
await waitFor(() => expect(state.loadProjectData).toHaveBeenCalledWith('project-1'))
expect(state.loadDetectionModels).toHaveBeenCalledOnce()
expect(state.loadDetectionRuns).toHaveBeenCalledWith('project-1')
}) })
}) })
+54 -14
View File
@@ -1,10 +1,15 @@
import { useEffect } from 'react' import { useEffect, useRef } from 'react'
type AsyncAction = () => Promise<unknown> type AsyncAction = () => Promise<unknown>
type ProjectAction = (projectId: string) => Promise<unknown> type ProjectAction = (projectId: string) => Promise<unknown>
interface WorkbenchBootstrapOptions { interface WorkbenchBootstrapOptions {
restrictedMode?: boolean restrictedMode?: boolean
/** Het werkblad dat nu open staat. Bepaalt wat er geladen mag worden. */
activeWorkspace?: string
/** Meldt een mislukte laadactie, zodat de gebruiker het verschil ziet tussen
"er is niets" en "het is niet gelukt". */
onLoadError?: (onderdeel: string, fout: unknown) => void
selectedProjectId: string | null selectedProjectId: string | null
selectedDetectionRunId: string selectedDetectionRunId: string
detectionClassFilter: string detectionClassFilter: string
@@ -32,6 +37,8 @@ interface WorkbenchBootstrapOptions {
export function useWorkbenchBootstrap({ export function useWorkbenchBootstrap({
restrictedMode = false, restrictedMode = false,
activeWorkspace = 'map',
onLoadError,
selectedProjectId, selectedProjectId,
selectedDetectionRunId, selectedDetectionRunId,
detectionClassFilter, detectionClassFilter,
@@ -56,13 +63,32 @@ export function useWorkbenchBootstrap({
resetSegmentationForProject, resetSegmentationForProject,
resetExportsForProject, resetExportsForProject,
}: WorkbenchBootstrapOptions): void { }: WorkbenchBootstrapOptions): void {
const foutMelder = useRef(onLoadError)
foutMelder.current = onLoadError
// Een mislukte laadactie werd overal met .catch(() => null) weggeslikt. De
// gebruiker zag dan "Nog geen bronnen beschikbaar", precies hetzelfde scherm
// als wanneer er echt niets is. Nu wordt de fout doorgegeven.
const meld = (onderdeel: string) => (fout: unknown) => {
foutMelder.current?.(onderdeel, fout)
return null
}
useEffect(() => { useEffect(() => {
loadProjects().catch(() => null) loadProjects().catch(meld('werkruimtes'))
loadCapabilities().catch(() => null)
loadDetectionModels().catch(() => null)
loadSegmentationModels().catch(() => null)
}, [restrictedMode]) }, [restrictedMode])
useEffect(() => {
if (activeWorkspace !== 'system') return
loadCapabilities().catch(meld('bronkoppelingen'))
}, [restrictedMode, activeWorkspace])
useEffect(() => {
if (activeWorkspace !== 'ai') return
loadDetectionModels().catch(meld('detectiemodellen'))
loadSegmentationModels().catch(meld('segmentatiemodellen'))
}, [restrictedMode, activeWorkspace])
useEffect(() => { useEffect(() => {
if (!selectedProjectId) { if (!selectedProjectId) {
resetProjectData() resetProjectData()
@@ -77,18 +103,32 @@ export function useWorkbenchBootstrap({
resetDetectionForProject() resetDetectionForProject()
resetSegmentationForProject() resetSegmentationForProject()
resetExportsForProject() resetExportsForProject()
loadProjectData(selectedProjectId).catch(() => null) loadProjectData(selectedProjectId).catch(meld('gebieden en bronnen'))
loadQualityChecks(selectedProjectId).catch(() => null)
loadDetectionRuns(selectedProjectId).catch(() => null)
loadSegmentationRuns(selectedProjectId).catch(() => null)
loadExports(selectedProjectId).catch(() => null)
}, [restrictedMode, selectedProjectId]) }, [restrictedMode, selectedProjectId])
useEffect(() => { useEffect(() => {
loadDetectionResults().catch(() => null) if (!selectedProjectId || activeWorkspace !== 'analysis') return
}, [restrictedMode, selectedDetectionRunId, detectionClassFilter, detectionMinConfidenceFilter]) loadQualityChecks(selectedProjectId).catch(meld('kwaliteitscontroles'))
}, [restrictedMode, selectedProjectId, activeWorkspace])
useEffect(() => { useEffect(() => {
loadSegmentationResults().catch(() => null) if (!selectedProjectId || activeWorkspace !== 'ai') return
}, [restrictedMode, selectedSegmentationRunId, segmentationClassFilter, segmentationMinConfidenceFilter]) loadDetectionRuns(selectedProjectId).catch(meld('detectieruns'))
loadSegmentationRuns(selectedProjectId).catch(meld('segmentatieruns'))
}, [restrictedMode, selectedProjectId, activeWorkspace])
useEffect(() => {
if (!selectedProjectId || activeWorkspace !== 'exports') return
loadExports(selectedProjectId).catch(meld('downloads'))
}, [restrictedMode, selectedProjectId, activeWorkspace])
useEffect(() => {
if (activeWorkspace !== 'ai') return
loadDetectionResults().catch(meld('detectieresultaten'))
}, [restrictedMode, activeWorkspace, selectedDetectionRunId, detectionClassFilter, detectionMinConfidenceFilter])
useEffect(() => {
if (activeWorkspace !== 'ai') return
loadSegmentationResults().catch(meld('segmentatieresultaten'))
}, [restrictedMode, activeWorkspace, selectedSegmentationRunId, segmentationClassFilter, segmentationMinConfidenceFilter])
} }
@@ -0,0 +1,62 @@
import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { useWorkbenchTheme } from './useWorkbenchTheme'
const SLEUTEL = 'geointel.workbench-theme.v1'
describe('useWorkbenchTheme', () => {
beforeEach(() => {
window.localStorage.clear()
delete document.body.dataset.theme
})
afterEach(() => {
window.localStorage.clear()
})
it('begint donker, ook wanneer het besturingssysteem licht meldt', () => {
// Browsers melden standaard 'light', ook als de gebruiker nooit iets
// instelde. Daarop afgaan zou vrijwel iedereen in de lichte werkstand
// zetten terwijl donker de gekozen richting is.
const { result } = renderHook(() => useWorkbenchTheme())
expect(result.current.theme).toBe('dark')
expect(document.body.dataset.theme).toBe('dark')
})
it('schakelt om en zet het attribuut op body', () => {
const { result } = renderHook(() => useWorkbenchTheme())
act(() => result.current.toggleTheme())
expect(result.current.theme).toBe('light')
expect(document.body.dataset.theme).toBe('light')
act(() => result.current.toggleTheme())
expect(result.current.theme).toBe('dark')
expect(document.body.dataset.theme).toBe('dark')
})
it('onthoudt de keuze voor een volgende sessie', () => {
const eerste = renderHook(() => useWorkbenchTheme())
act(() => eerste.result.current.toggleTheme())
eerste.unmount()
expect(window.localStorage.getItem(SLEUTEL)).toBe('light')
const tweede = renderHook(() => useWorkbenchTheme())
expect(tweede.result.current.theme).toBe('light')
})
it('blijft werken wanneer opslag geblokkeerd is', () => {
const origineel = window.localStorage.setItem
window.localStorage.setItem = () => {
throw new Error('opslag geweigerd')
}
try {
const { result } = renderHook(() => useWorkbenchTheme())
act(() => result.current.toggleTheme())
// Niet kunnen bewaren mag het omschakelen niet blokkeren.
expect(result.current.theme).toBe('light')
} finally {
window.localStorage.setItem = origineel
}
})
})
+56
View File
@@ -0,0 +1,56 @@
import { useCallback, useEffect, useState } from 'react'
export type WorkbenchTheme = 'dark' | 'light'
const OPSLAGSLEUTEL = 'geointel.workbench-theme.v1'
/**
* De werkstand van de werkbank.
*
* Donker is de standaard: de werkbank staat tegen luchtbeelden en
* satellietdata aan, en een lichte schil daarnaast laat de kaart altijd
* verliezen. Maar donker-alleen is geen antwoord voor wie op een fel verlichte
* locatie werkt of een scherm deelt met een beamer, dus de keuze blijft.
*
* De keuze wordt per browser bewaard. Wie niets kiest krijgt donker. Het
* voorkeurssignaal van het besturingssysteem telt bewust niet mee: browsers
* melden standaard 'light', ook als de gebruiker nooit iets ingesteld heeft,
* en dan zou vrijwel iedereen in de lichte werkstand landen terwijl donker de
* gekozen richting van dit product is. Licht is een bewuste keuze, geen
* gevolg van een standaardwaarde elders.
*/
function beginwaarde(): WorkbenchTheme {
if (typeof window === 'undefined') return 'dark'
try {
const bewaard = window.localStorage.getItem(OPSLAGSLEUTEL)
if (bewaard === 'light' || bewaard === 'dark') return bewaard
} catch {
// Privémodus of geblokkeerde opslag: dan geldt gewoon de standaard.
}
return 'dark'
}
export function useWorkbenchTheme(): { theme: WorkbenchTheme; toggleTheme: () => void } {
const [theme, setTheme] = useState<WorkbenchTheme>(beginwaarde)
useEffect(() => {
document.body.dataset.theme = theme
return () => {
delete document.body.dataset.theme
}
}, [theme])
const toggleTheme = useCallback(() => {
setTheme((huidig) => {
const volgend: WorkbenchTheme = huidig === 'dark' ? 'light' : 'dark'
try {
window.localStorage.setItem(OPSLAGSLEUTEL, volgend)
} catch {
// Niet kunnen bewaren mag het omschakelen niet blokkeren.
}
return volgend
})
}, [])
return { theme, toggleTheme }
}
+11 -1
View File
@@ -1,6 +1,16 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './styles/app.css' // Elk geladen gewicht komt overeen met een stap uit de schaal in
// geointel-system.css. Gewichten die niet geladen zijn vallen terug op het
// dichtstbijzijnde gezicht, waardoor negen gedeclareerde gewichten er twee
// worden en de hiërarchie verdwijnt.
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 '@fontsource/public-sans/latin-700.css'
import './styles/base.css'
import App from './App' import App from './App'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
+128
View File
@@ -0,0 +1,128 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { apiGet, apiPost, vergeetGedeeldeVerzoeken } from './client'
function antwoord(data: unknown) {
return {
ok: true,
status: 200,
json: async () => ({ data }),
} as unknown as Response
}
describe('apiGet deelt verzoeken', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.runOnlyPendingTimers()
vi.useRealTimers()
vi.restoreAllMocks()
})
it('stuurt één verzoek wanneer drie aanroepers tegelijk hetzelfde vragen', async () => {
const fetchSpy = vi.fn().mockResolvedValue(antwoord({ items: [1] }))
vi.stubGlobal('fetch', fetchSpy)
const [a, b, c] = await Promise.all([
apiGet<{ items: number[] }>('/api/v1/projects'),
apiGet<{ items: number[] }>('/api/v1/projects'),
apiGet<{ items: number[] }>('/api/v1/projects'),
])
expect(fetchSpy).toHaveBeenCalledTimes(1)
expect(a).toEqual({ items: [1] })
expect(b).toBe(a)
expect(c).toBe(a)
})
it('deelt ook wanneer het tweede verzoek kort ná het eerste komt', async () => {
// Dit was het echte geval bij het opstarten: twee effecten die ongeveer
// negentig milliseconde na elkaar dezelfde gegevens opvroegen, dus de
// eerste was al klaar voordat de tweede begon.
const fetchSpy = vi.fn().mockResolvedValue(antwoord({ items: [] }))
vi.stubGlobal('fetch', fetchSpy)
await apiGet('/api/v1/projects/p-1/datasets')
await vi.advanceTimersByTimeAsync(90)
await apiGet('/api/v1/projects/p-1/datasets')
expect(fetchSpy).toHaveBeenCalledTimes(1)
})
it('haalt opnieuw op zodra het deelvenster voorbij is', async () => {
const fetchSpy = vi.fn().mockResolvedValue(antwoord({ items: [] }))
vi.stubGlobal('fetch', fetchSpy)
await apiGet('/api/v1/projects/p-1/areas')
await vi.advanceTimersByTimeAsync(400)
await apiGet('/api/v1/projects/p-1/areas')
expect(fetchSpy).toHaveBeenCalledTimes(2)
})
it('houdt verschillende paden uit elkaar', async () => {
const fetchSpy = vi.fn().mockResolvedValue(antwoord({ items: [] }))
vi.stubGlobal('fetch', fetchSpy)
await Promise.all([
apiGet('/api/v1/projects'),
apiGet('/api/v1/projects?name=Kempen&limit=1'),
])
expect(fetchSpy).toHaveBeenCalledTimes(2)
})
it('deelt geen POST — twee keer versturen is een andere handeling', async () => {
const fetchSpy = vi.fn().mockResolvedValue(antwoord({ ok: true }))
vi.stubGlobal('fetch', fetchSpy)
await Promise.all([
apiPost('/api/v1/projects', { name: 'a' }),
apiPost('/api/v1/projects', { name: 'a' }),
])
expect(fetchSpy).toHaveBeenCalledTimes(2)
})
it('deelt niets meer over een identiteitswissel heen', async () => {
// Uitloggen herlaadt de pagina niet, dus de tabel leeft door. Zonder deze
// reset zou de volgende gebruiker binnen het deelvenster het antwoord van
// de vorige kunnen krijgen.
const fetchSpy = vi.fn()
.mockResolvedValueOnce(antwoord({ items: ['werkruimte van A'] }))
.mockResolvedValueOnce(antwoord({ items: ['werkruimte van B'] }))
vi.stubGlobal('fetch', fetchSpy)
const vanA = await apiGet<{ items: string[] }>('/api/v1/projects')
vergeetGedeeldeVerzoeken()
const vanB = await apiGet<{ items: string[] }>('/api/v1/projects')
expect(fetchSpy).toHaveBeenCalledTimes(2)
expect(vanA.items).toEqual(['werkruimte van A'])
expect(vanB.items).toEqual(['werkruimte van B'])
})
it('wist de tabel ook wanneer de sessie verloopt', async () => {
const fetchSpy = vi.fn().mockResolvedValue(antwoord({ items: [] }))
vi.stubGlobal('fetch', fetchSpy)
await apiGet('/api/v1/projects')
window.dispatchEvent(new CustomEvent('geointel:session-expired'))
await apiGet('/api/v1/projects')
expect(fetchSpy).toHaveBeenCalledTimes(2)
})
it('laat een mislukt verzoek niet in de tabel achter', async () => {
const fetchSpy = vi.fn()
.mockRejectedValueOnce(new Error('netwerk weg'))
.mockResolvedValue(antwoord({ items: [] }))
vi.stubGlobal('fetch', fetchSpy)
await expect(apiGet('/api/v1/detection/models')).rejects.toThrow('netwerk weg')
await vi.advanceTimersByTimeAsync(400)
await expect(apiGet('/api/v1/detection/models')).resolves.toEqual({ items: [] })
expect(fetchSpy).toHaveBeenCalledTimes(2)
})
})
+57 -2
View File
@@ -31,9 +31,64 @@ async function parseResponse<T>(response: Response): Promise<T> {
return payload.data as T; return payload.data as T;
} }
/**
* Gelijktijdige identieke GET-verzoeken worden gedeeld.
*
* Bij het opstarten vroegen drie verschillende plekken tegelijk om
* /api/v1/projects, en areas en datasets elk twee keer 27 verzoeken in totaal
* waarvan vijf overbodig. Elke aanroeper hier krijgt dezelfde belofte zolang
* het verzoek onderweg is; zodra het klaar is verdwijnt het uit de tabel, dus
* er wordt niets gecachet en een volgende aanroep haalt gewoon opnieuw op.
*
* Alleen GET. Een POST twee keer versturen is een andere handeling en mag
* nooit stilzwijgend samengevoegd worden.
*/
const lopendeGets = new Map<string, Promise<unknown>>();
/**
* Hoe lang een afgerond GET-verzoek nog gedeeld wordt.
*
* Puur gelijktijdige verzoeken samenvoegen was niet genoeg: bij het opstarten
* vroegen twee verschillende effecten dezelfde gebieden en bronnen op met
* ongeveer negentig milliseconde ertussen, dus was de eerste al klaar voordat
* de tweede begon. Dit venster is kort genoeg om nooit verouderde gegevens te
* tonen een gebruiker die op "Vernieuwen" drukt zit er ruim boven en lang
* genoeg om de opstartcascade op te vangen.
*/
const DEEL_VENSTER_MS = 300;
/**
* Vergeet alle gedeelde antwoorden.
*
* Uitloggen herlaadt de pagina niet, dus zonder dit zou een antwoord van de ene
* gebruiker binnen het deelvenster nog aan de volgende geserveerd kunnen worden.
* Dat is binnen driehonderd milliseconde met de hand nauwelijks te bereiken,
* maar een verzoekcache die een identiteitswissel overleeft is hoe dan ook fout.
*/
export function vergeetGedeeldeVerzoeken(): void {
lopendeGets.clear();
}
if (typeof window !== "undefined") {
window.addEventListener("geointel:session-expired", vergeetGedeeldeVerzoeken);
}
export async function apiGet<T>(path: string): Promise<T> { export async function apiGet<T>(path: string): Promise<T> {
const response = await fetch(apiUrl(path), { credentials: "same-origin" }); const lopend = lopendeGets.get(path);
return parseResponse<T>(response); if (lopend) {
return lopend as Promise<T>;
}
const verzoek = fetch(apiUrl(path), { credentials: "same-origin" })
.then((response) => parseResponse<T>(response))
.finally(() => {
window.setTimeout(() => {
lopendeGets.delete(path);
}, DEEL_VENSTER_MS);
});
lopendeGets.set(path, verzoek);
return verzoek;
} }
export async function apiPost<T>(path: string, body?: object): Promise<T> { export async function apiPost<T>(path: string, body?: object): Promise<T> {

Some files were not shown because too many files have changed in this diff Show More