Merge visual audit and GPU workflow upgrade
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 |
@@ -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'
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 287 KiB After Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 240 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 358 KiB After Width: | Height: | Size: 169 KiB |
|
Before Width: | Height: | Size: 181 KiB |
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 thema’s"
|
|
||||||
/>
|
|
||||||
</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 bovenliggende 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 thema’s"
|
||||||
|
/>
|
||||||
|
</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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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,8 +154,10 @@ export function SegmentationLab({
|
|||||||
: selectedSegmentationModelId === 'fixture-segmenter'
|
: selectedSegmentationModelId === 'fixture-segmenter'
|
||||||
? 'Het fixturemodel is alleen bedoeld voor expliciete tests en demo’s'
|
? 'Het fixturemodel is alleen bedoeld voor expliciete tests en demo’s'
|
||||||
: !selectedSegmentationModelConfigured
|
: !selectedSegmentationModelConfigured
|
||||||
? selectedSegmentationModelLimitation ?? 'Het gekozen segmentatiemodel is niet geconfigureerd'
|
? selectedSegmentationModelAvailability
|
||||||
: null
|
: !segmentationHasTileManifest
|
||||||
|
? 'Koppel eerst het beeldtegelmanifest van het gekozen rasterbestand'
|
||||||
|
: null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="workspace-panel ai-lab-shell segmentation-lab-shell">
|
<section className="workspace-panel ai-lab-shell segmentation-lab-shell">
|
||||||
@@ -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>
|
||||||
</div>
|
<p>Daarna toont GeoIntel uitsluitend de polygonen van die analyserun.</p>
|
||||||
|
</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 }))
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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,33 +227,43 @@ 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)
|
|
||||||
setDetectionTruncated(false)
|
|
||||||
setDetectionTotal(0)
|
setDetectionTotal(0)
|
||||||
setDetectionTruncated(false)
|
setDetectionTruncated(false)
|
||||||
setDetectionGeoJson(null)
|
setDetectionGeoJson(null)
|
||||||
@@ -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,14 +281,28 @@ 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 {
|
||||||
setLoadingDetectionResults(false)
|
if (
|
||||||
|
detectionResultsRequestSequence.current === sequence
|
||||||
|
&& selectedProjectIdRef.current === requestProjectId
|
||||||
|
) {
|
||||||
|
setLoadingDetectionResults(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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,
|
||||||
})
|
}
|
||||||
setDetectionRunResult(result)
|
const controller = new AbortController()
|
||||||
setSelectedDetectionRunId(result.analysis_run_id)
|
const executionSequence = detectionExecutionSequence.current + 1
|
||||||
setDetectionWorkflowStage('loading')
|
detectionExecutionSequence.current = executionSequence
|
||||||
await loadDetectionRuns(projectId)
|
activeDetectionControllerRef.current = controller
|
||||||
await loadDetectionResults(result.analysis_run_id)
|
const assertExecutionCurrent = () => {
|
||||||
await loadProjectData(projectId)
|
if (
|
||||||
return result
|
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)
|
||||||
|
setSelectedDetectionRunId(result.analysis_run_id)
|
||||||
|
setDetectionWorkflowStage('loading')
|
||||||
|
await loadDetectionRuns(projectId)
|
||||||
|
assertExecutionCurrent()
|
||||||
|
await loadDetectionResults(result.analysis_run_id)
|
||||||
|
assertExecutionCurrent()
|
||||||
|
await loadProjectData(projectId)
|
||||||
|
assertExecutionCurrent()
|
||||||
|
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,13 +415,19 @@ 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)
|
||||||
setDetectionWorkflowStage('complete')
|
if (selectedProjectIdRef.current === projectId) {
|
||||||
|
setDetectionWorkflowStage('complete')
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setDetectionRunError(formatError(error, 'Detection run failed'))
|
if (!isAbortError(error) && selectedProjectIdRef.current === projectId) {
|
||||||
setDetectionWorkflowStage('failed')
|
setDetectionRunError(formatError(error, 'Detection run failed'))
|
||||||
|
setDetectionWorkflowStage('failed')
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setRunningDetection(false)
|
if (selectedProjectIdRef.current === projectId) {
|
||||||
|
setRunningDetection(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,10 +436,11 @@ export function useDetectionWorkflow({
|
|||||||
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) {
|
||||||
setDetectionRunError(formatError(error, 'Het luchtbeeld kon niet worden toegevoegd'))
|
if (!isAbortError(error) && selectedProjectIdRef.current === projectId) {
|
||||||
setDetectionWorkflowStage('failed')
|
setDetectionRunError(formatError(error, 'Het luchtbeeld kon niet worden toegevoegd'))
|
||||||
|
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,20 +549,25 @@ 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) {
|
||||||
setDetectionRunError(formatError(error, 'De beeldanalyse is mislukt'))
|
if (!isAbortError(error) && selectedProjectIdRef.current === projectId) {
|
||||||
setDetectionWorkflowStage('failed')
|
setDetectionRunError(formatError(error, 'De beeldanalyse is mislukt'))
|
||||||
|
setDetectionWorkflowStage('failed')
|
||||||
|
}
|
||||||
return null
|
return null
|
||||||
} finally {
|
} finally {
|
||||||
setRunningDetection(false)
|
if (selectedProjectIdRef.current === projectId) {
|
||||||
|
setRunningDetection(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,33 +578,58 @@ 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) {
|
||||||
setDetectionQaError(formatError(error, 'Detection QA failed'))
|
if (
|
||||||
|
detectionQaRequestSequence.current === sequence
|
||||||
|
&& selectedProjectIdRef.current === projectId
|
||||||
|
) {
|
||||||
|
setDetectionQaError(formatError(error, 'Detection QA failed'))
|
||||||
|
}
|
||||||
return null
|
return null
|
||||||
} finally {
|
} finally {
|
||||||
setRunningDetectionQa(false)
|
if (
|
||||||
|
detectionQaRequestSequence.current === sequence
|
||||||
|
&& selectedProjectIdRef.current === projectId
|
||||||
|
) {
|
||||||
|
setRunningDetectionQa(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -449,36 +638,46 @@ export function useDetectionWorkflow({
|
|||||||
|
|
||||||
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,17 +739,31 @@ 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 (
|
||||||
setDetectionCalibrationRows((rows) =>
|
!isAbortError(error)
|
||||||
rows.map((row) => (row.status === 'success' ? row : { ...row, status: 'failed', message })),
|
&& detectionCalibrationSequence.current === sequence
|
||||||
)
|
&& selectedProjectIdRef.current === projectId
|
||||||
setDetectionCalibrationError(message)
|
) {
|
||||||
|
const message = formatError(error, 'Kalibratie mislukt')
|
||||||
|
setDetectionCalibrationRows((rows) =>
|
||||||
|
rows.map((row) => (row.status === 'success' ? row : { ...row, status: 'failed', message })),
|
||||||
|
)
|
||||||
|
setDetectionCalibrationError(message)
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setRunningDetectionCalibration(false)
|
if (
|
||||||
|
detectionCalibrationSequence.current === sequence
|
||||||
|
&& selectedProjectIdRef.current === projectId
|
||||||
|
) {
|
||||||
|
setRunningDetectionCalibration(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 {
|
||||||
setLoadingSegmentationResults(false)
|
if (
|
||||||
|
segmentationResultsRequestSequence.current === sequence
|
||||||
|
&& selectedProjectIdRef.current === requestProjectId
|
||||||
|
) {
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
if (selectedSegmentationModelId === 'fixture-segmenter') {
|
||||||
|
setSegmentationRunError('Het fixturemodel is uitsluitend beschikbaar voor expliciete geautomatiseerde tests')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!segmentationTileManifestPath.trim()) {
|
||||||
|
setSegmentationRunError('Koppel eerst het beeldtegelmanifest van het gekozen rasterbestand')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(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,
|
||||||
|
model_id: selectedSegmentationModelId,
|
||||||
|
confidence_threshold: segmentationConfidenceThreshold,
|
||||||
|
tile_manifest_path: segmentationTileManifestPath.trim() || null,
|
||||||
|
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)
|
setSegmentationRunError(null)
|
||||||
setSegmentationRunResult(null)
|
setSegmentationRunResult(null)
|
||||||
setRunningSegmentation(true)
|
setRunningSegmentation(true)
|
||||||
|
setSegmentationJob(null)
|
||||||
try {
|
try {
|
||||||
const parameters =
|
const queuedJob = await segmentationApi.runAsync(request)
|
||||||
selectedSegmentationModelId === 'fixture-segmenter'
|
assertExecutionCurrent()
|
||||||
? { fixture_mode: true, fixture_segmentations: [] }
|
setSegmentationJob(queuedJob)
|
||||||
: {}
|
const completedJob = await waitForSegmentationJob({
|
||||||
const result = await segmentationApi.run({
|
projectId,
|
||||||
project_id: selectedProjectId,
|
initialJob: queuedJob,
|
||||||
dataset_id: datasetId,
|
signal: controller.signal,
|
||||||
model_id: selectedSegmentationModelId,
|
onStatus: (job) => {
|
||||||
confidence_threshold: segmentationConfidenceThreshold,
|
if (
|
||||||
tile_manifest_path: segmentationTileManifestPath.trim() || null,
|
segmentationExecutionSequence.current === executionSequence
|
||||||
parameters_json: parameters,
|
&& 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 {
|
||||||
setRunningSegmentation(false)
|
if (activeSegmentationControllerRef.current === controller) {
|
||||||
|
activeSegmentationControllerRef.current = null
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
segmentationExecutionSequence.current === executionSequence
|
||||||
|
&& selectedProjectIdRef.current === projectId
|
||||||
|
) {
|
||||||
|
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 {
|
||||||
setRunningSegmentationQa(false)
|
if (
|
||||||
|
segmentationQaRequestSequence.current === sequence
|
||||||
|
&& selectedProjectIdRef.current === projectId
|
||||||
|
) {
|
||||||
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,14 +50,20 @@ export function useTemporalComparison(selectedProjectId: string | null) {
|
|||||||
area_id: areaId || null,
|
area_id: areaId || null,
|
||||||
preview_limit: 500,
|
preview_limit: 500,
|
||||||
})
|
})
|
||||||
setTemporalComparison(result)
|
if (requestSequence.current === sequence) {
|
||||||
|
setTemporalComparison(result)
|
||||||
|
}
|
||||||
return result
|
return result
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setTemporalComparison(null)
|
if (requestSequence.current === sequence) {
|
||||||
setTemporalComparisonError(formatError(error, 'De evolutieanalyse is mislukt.'))
|
setTemporalComparison(null)
|
||||||
|
setTemporalComparisonError(formatError(error, 'De evolutieanalyse is mislukt.'))
|
||||||
|
}
|
||||||
return null
|
return null
|
||||||
} finally {
|
} finally {
|
||||||
setTemporalComparisonLoading(false)
|
if (requestSequence.current === sequence) {
|
||||||
|
setTemporalComparisonLoading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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> {
|
||||||
|
|||||||