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