Complete operational map result workflow
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-17 12:35:50 +02:00
parent 8266929b2e
commit a29c787238
33 changed files with 1121 additions and 107 deletions
@@ -23,6 +23,14 @@ function detectionModelLabel(model: DetectionModelCapability): string {
return model.display_name
}
function detectionQualityInterpretation(f1: number | null | undefined): string {
if (typeof f1 !== 'number' || !Number.isFinite(f1)) return 'Nog geen gevalideerde kwaliteitsmeting.'
if (f1 >= 0.85) return 'Sterk resultaat; steekproefcontrole blijft vereist.'
if (f1 >= 0.70) return 'Bruikbaar met gerichte handmatige controle.'
if (f1 >= 0.50) return 'Verkennend resultaat; beoordeel fouten voor operationeel gebruik.'
return 'Onvoldoende betrouwbaar voor operationeel gebruik.'
}
interface CalibrationRow {
analysisRunId: string
qualityCheckId: string
@@ -272,6 +280,9 @@ export function DetectionLab({
<p>{rasterDatasets.length > 0 ? 'Klaar om een beeld te kiezen.' : 'Laad eerst een gegeorefereerd luchtbeeld in.'}</p>
</div>
</div>
<p className="ai-quality-guidance">
{detectionQualityInterpretation(selectedOperatorProfile?.f1)}
</p>
<details className="ai-lab-model-surface" aria-label="Detection model capabilities">
<summary>
@@ -44,6 +44,19 @@ function compactPath(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
.split('_')
.filter(Boolean)
@@ -53,9 +66,10 @@ function formatExportType(value: string): string {
function formatExportCreated(value?: string | null): string {
if (!value) {
return 'created time unavailable'
return 'Tijdstip onbekend'
}
return value
const parsed = new Date(value)
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString('nl-BE')
}
function getLatestExportByType(exports: ExportRead[], exportType: string): ExportRead | null {
@@ -103,6 +117,24 @@ export function ExportCenter({
const exportStatuses = useMemo(() => Array.from(new Set(exports.map((item) => item.status))).sort(), [exports])
const latestHandoffArtifacts = useMemo(
() => [
{
key: 'map_analysis_json',
label: 'Gebiedsanalyse',
detail: 'Server-side herberekende metingen van de kaartselectie',
item: getLatestExportByType(exports, 'map_analysis_json'),
},
{
key: 'map_evolution_json',
label: 'Historische vergelijking',
detail: 'Bewaarde evolutie van het geselecteerde gebied',
item: getLatestExportByType(exports, 'map_evolution_json'),
},
{
key: 'vector_selection_geojson',
label: 'Kaartselectie (GeoJSON)',
detail: 'Persistente objecten binnen de geselecteerde rechthoek',
item: getLatestExportByType(exports, 'vector_selection_geojson'),
},
{
key: 'project_report_html',
label: 'Leesbaar rapport',
@@ -182,8 +214,8 @@ export function ExportCenter({
<div className="handoff-summary-card">
<div className="panel-title-row">
<div>
<h3>Handoff readiness</h3>
<p className="muted">Pick the artifact that matches the current review state before sharing data outside the workbench.</p>
<h3>Klaar om te delen</h3>
<p className="muted">Controleer het juiste bestand voordat je gegevens buiten GeoIntel gebruikt.</p>
</div>
<span className="status-badge">{handoffStatus}</span>
</div>
@@ -260,9 +292,9 @@ export function ExportCenter({
<div className="export-action-grid">
<div className="handoff-action-grid">
<div className="handoff-action-card handoff-action-card-technical">
<span>Refresh</span>
<strong>Export registry</strong>
<p>Reload the persisted artifact list for the selected project.</p>
<span>Vernieuwen</span>
<strong>Bewaarde bestanden</strong>
<p>Laad de persistente downloads van de geselecteerde werkruimte opnieuw.</p>
<button
type="button"
className="secondary-action"
@@ -270,7 +302,7 @@ export function ExportCenter({
disabled={!selectedProjectId || loadingExports}
data-testid="refresh-exports"
>
Refresh exports
Downloads vernieuwen
</button>
</div>
<div className="handoff-action-card handoff-action-card-primary">
@@ -304,19 +336,19 @@ export function ExportCenter({
</button>
</div>
{selectedDetectionRunId ? <div className="handoff-action-card">
<span>Detection run</span>
<strong>Detections GeoJSON</strong>
<p>Export persisted detection geometries for the selected analysis run.</p>
<span>Gebouwanalyse</span>
<strong>Herkende gebouwen (GeoJSON)</strong>
<p>Bewaar de persistente detectiegeometrie van de geselecteerde analyserun.</p>
<button type="button" className="secondary-action" onClick={onExportDetectionRun} disabled={!selectedDetectionRunId || exporting}>
Export selected detection run GeoJSON
Gebouwanalyse bewaren
</button>
</div> : null}
{selectedSegmentationRunId ? <div className="handoff-action-card">
<span>Segmentation run</span>
<strong>Segmentations GeoJSON</strong>
<p>Export persisted segmentation polygons for the selected analysis run.</p>
<span>Segmentatieanalyse</span>
<strong>Segmentaties (GeoJSON)</strong>
<p>Bewaar de persistente polygonen van de geselecteerde segmentatierun.</p>
<button type="button" className="secondary-action" onClick={onExportSegmentationRun} disabled={!selectedSegmentationRunId || exporting}>
Export selected segmentation run GeoJSON
Segmentatie bewaren
</button>
</div> : null}
</div>
@@ -358,16 +390,16 @@ export function ExportCenter({
</summary>
<div className="export-history-body">
<div>
<h3>Export history</h3>
<p className="muted">Review persisted artifacts and open JSON previews where supported.</p>
<h3>Bewaarde downloads</h3>
<p className="muted">Bekijk persistente bestanden en open beschikbare JSON-voorbeelden.</p>
</div>
{exports.length > 0 ? (
<div className="export-history-controls" aria-label="Export history filters">
<label>
Search exports
Downloads zoeken
<input
type="search"
placeholder="Type, id or path"
placeholder="Type, id of pad"
value={exportSearchQuery}
onChange={(event) => setExportSearchQuery(event.target.value)}
/>
@@ -375,7 +407,7 @@ export function ExportCenter({
<label>
Type
<select value={exportTypeFilter} onChange={(event) => setExportTypeFilter(event.target.value)}>
<option value="all">All types</option>
<option value="all">Alle types</option>
{exportTypes.map((type) => (
<option key={type} value={type}>
{type}
@@ -386,7 +418,7 @@ export function ExportCenter({
<label>
Status
<select value={exportStatusFilter} onChange={(event) => setExportStatusFilter(event.target.value)}>
<option value="all">All statuses</option>
<option value="all">Alle statussen</option>
{exportStatuses.map((status) => (
<option key={status} value={status}>
{status}
@@ -405,25 +437,25 @@ export function ExportCenter({
}}
disabled={!hasActiveExportFilters && !showAllExports}
>
Reset view
Filters wissen
</button>
</div>
) : null}
{filteredExports.length > 10 ? (
<div className="list-limit-banner">
<span>
Showing {visibleExports.length} of {filteredExports.length} matching exports.
{hiddenExportCount > 0 ? ` ${hiddenExportCount} older artifacts hidden.` : ' All matching artifacts shown.'}
{visibleExports.length} van {filteredExports.length} passende downloads zichtbaar.
{hiddenExportCount > 0 ? ` ${hiddenExportCount} oudere bestanden verborgen.` : ' Alle passende bestanden zijn zichtbaar.'}
</span>
<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>
</div>
) : null}
{exports.length > 0 && filteredExports.length === 0 ? (
<div className="result-state result-state-empty">
<strong>No exports match the current filters.</strong>
<p>Clear the search, type or status filter to return to the latest artifacts.</p>
<strong>Geen downloads passen bij deze filters.</strong>
<p>Wis de zoekopdracht of filters om de recentste bestanden opnieuw te tonen.</p>
</div>
) : null}
<ul className="export-list">
@@ -433,9 +465,9 @@ export function ExportCenter({
<div>
<span className="export-type-badge">{formatExportType(item.export_type)}</span>
<div className="entity-meta">
<span>export id: {item.id}</span>
{item.analysis_run_id ? <span>analysis run: {item.analysis_run_id}</span> : null}
{item.created_at ? <span>created: {item.created_at}</span> : null}
<span>download-id: {item.id}</span>
{item.analysis_run_id ? <span>analyserun: {item.analysis_run_id}</span> : null}
{item.created_at ? <span>gemaakt: {formatExportCreated(item.created_at)}</span> : null}
</div>
</div>
<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">
{canPreviewJson(item) ? (
<button type="button" className="secondary-action" onClick={() => onPreviewContent(item.id)}>
Preview JSON content
JSON bekijken
</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)}>
Download artifact
Downloaden
</button>
</div>
</li>
+80 -14
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import GeoMap from '../GeoMap'
import type { AreaRead, DatasetCreateResponse, DetectionQaResult, MapViewportState, OrthophotoAcquisitionResult, OrthophotoProductRead, ProjectRead, QaComparisonResult, VectorSelectionBBox, VectorSelectionMetric, VectorSelectionResponse } from '../../types'
import type { AreaRead, DatasetCreateResponse, DetectionQaResult, MapResultExportRequest, MapViewportState, OrthophotoAcquisitionResult, OrthophotoProductRead, ProjectRead, QaComparisonResult, VectorSelectionBBox, VectorSelectionMetric, VectorSelectionResponse } from '../../types'
import { featureCollectionBounds } from '../../lib/geojsonBounds'
import { useMapThemeSelectionInsights } from '../../hooks/useMapThemeSelectionInsights'
import { useTemporalComparison } from '../../hooks/useTemporalComparison'
@@ -708,7 +708,8 @@ interface MapWorkspaceProps {
onSetMapSelectionBbox: (bbox: VectorSelectionBBox | null) => void
onRunMapSelectionExtract: (bbox: VectorSelectionBBox, areaId?: string) => Promise<VectorSelectionResponse | null>
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>
onSelectMapQaReferenceDataset: (datasetId: string) => void
onRunMapSelectionQa: (candidateDataset?: DatasetCreateResponse | null) => Promise<QaComparisonResult | null>
@@ -792,6 +793,7 @@ export function MapWorkspace({
onRunMapSelectionExtract,
onClearMapSelectionExtract,
onExportMapSelection,
onPersistMapResult,
onDeriveMapSelectionDataset,
onSelectMapQaReferenceDataset,
onRunMapSelectionQa,
@@ -1151,6 +1153,15 @@ export function MapWorkspace({
setBboxInput(bboxToInputState(bbox))
}
const areaIdForSelection = (bbox: VectorSelectionBBox | null): string | undefined => (
bbox
&& selectedMapArea
&& selectedAreaBbox
&& bboxesEqual(bbox, selectedAreaBbox)
? selectedMapArea.id
: undefined
)
const startBboxSelection = () => {
setFirstSelectionCorner(null)
clearThemeInsights()
@@ -1167,7 +1178,7 @@ export function MapWorkspace({
setSelectionBbox(bbox)
setFirstSelectionCorner(null)
setBboxSelectionMode(false)
void analyzeSelection(bbox, selectedMapArea?.id)
void analyzeSelection(bbox)
}
const runAreaExtract = () => {
@@ -1175,7 +1186,7 @@ export function MapWorkspace({
if (!bbox) {
return
}
void analyzeSelection(bbox, selectedMapArea?.id)
void analyzeSelection(bbox, areaIdForSelection(bbox))
}
const clearAreaSelection = () => {
@@ -1210,7 +1221,7 @@ export function MapWorkspace({
if (activeThemeDataset?.dataset_type === 'raster') {
downloadJsonFile(`${activeTheme.id}-analysis.json`, {
project_id: selectedProjectId,
area_id: selectedMapArea?.id ?? null,
area_id: areaIdForSelection(mapSelectionBbox) ?? null,
area_name: selectedMapArea?.name ?? null,
theme: activeTheme,
dataset_id: activeThemeDataset.id,
@@ -1238,12 +1249,54 @@ export function MapWorkspace({
copyText(JSON.stringify(temporalComparison ?? {}, null, 2))
}
const persistActiveResultAndOpenDownloads = async () => {
if (!selectedProjectId || !mapSelectionBbox) {
onOpenExports()
return
}
const areaId = areaIdForSelection(mapSelectionBbox)
const payload: MapResultExportRequest | null = analysisMode === 'evolution'
? temporalComparison && earlierDatasetId && laterDatasetId
? {
project_id: selectedProjectId,
mode: 'evolution',
bbox: { ...mapSelectionBbox, crs: 'EPSG:4326' },
earlier_dataset_id: earlierDatasetId,
later_dataset_id: laterDatasetId,
area_id: areaId,
theme_id: activeTheme.id,
name: `${activeTheme.id}-evolution`,
}
: null
: activeSelectionResult && activeThemeDataset
? {
project_id: selectedProjectId,
mode: 'current',
bbox: { ...mapSelectionBbox, crs: 'EPSG:4326' },
dataset_id: activeThemeDataset.id,
area_id: areaId,
partitioned: regionalRasterThemeActive,
product_key: String(activeThemeDataset.source_metadata?.['product_key'] ?? '') || undefined,
theme_id: activeTheme.id,
name: `${activeTheme.id}-analysis`,
}
: null
if (!payload) {
onOpenExports()
return
}
const persisted = await onPersistMapResult(payload)
if (persisted) {
onOpenExports()
}
}
const saveAreaSelectionExport = () => {
const bbox = parseBboxInput(bboxInput)
if (!bbox) {
return
}
onExportMapSelection(bbox)
onExportMapSelection(bbox, areaIdForSelection(bbox))
}
const saveAreaSelectionDataset = () => {
@@ -1251,7 +1304,7 @@ export function MapWorkspace({
if (!bbox) {
return
}
onDeriveMapSelectionDataset(bbox, selectedMapArea?.id)
onDeriveMapSelectionDataset(bbox, areaIdForSelection(bbox))
}
const openSelectedDatabaseLayer = (datasetId: string) => {
@@ -1319,7 +1372,12 @@ export function MapWorkspace({
if (!mapSelectionBbox || !earlierDatasetId || !laterDatasetId) {
return
}
void compareTemporalSnapshots(earlierDatasetId, laterDatasetId, mapSelectionBbox, selectedMapArea?.id)
void compareTemporalSnapshots(
earlierDatasetId,
laterDatasetId,
mapSelectionBbox,
areaIdForSelection(mapSelectionBbox),
)
}
const handleMapBboxPreview = (bbox: VectorSelectionBBox) => {
@@ -1329,7 +1387,7 @@ export function MapWorkspace({
const handleMapBboxSelect = (bbox: VectorSelectionBBox) => {
setFirstSelectionCorner(null)
setBboxSelectionMode(false)
void analyzeSelection(bbox, selectedMapArea?.id)
void analyzeSelection(bbox)
}
const runQuickAoiExtract = () => {
@@ -1338,7 +1396,7 @@ export function MapWorkspace({
return
}
setSelectionBbox(bbox)
void analyzeSelection(bbox, selectedMapArea?.id)
void analyzeSelection(bbox, areaIdForSelection(bbox))
}
const runFullGisWorkflow = async () => {
@@ -1377,7 +1435,8 @@ export function MapWorkspace({
try {
setFullWorkflowStatus('1/4 Querying persisted vector_features...')
setSelectionBbox(bbox)
const selection = await onRunMapSelectionExtract(bbox, selectedMapArea?.id)
const selectionAreaId = areaIdForSelection(bbox)
const selection = await onRunMapSelectionExtract(bbox, selectionAreaId)
if (!selection) {
setFullWorkflowError('Persisted vector query did not complete.')
setFullWorkflowStatus('Stopped at query.')
@@ -1385,7 +1444,7 @@ export function MapWorkspace({
}
setFullWorkflowStatus('2/4 Saving derived result dataset...')
const derived = await onDeriveMapSelectionDataset(bbox, selectedMapArea?.id)
const derived = await onDeriveMapSelectionDataset(bbox, selectionAreaId)
if (!derived) {
setFullWorkflowError('Derived result dataset was not created.')
setFullWorkflowStatus('Stopped at dataset save.')
@@ -1393,7 +1452,7 @@ export function MapWorkspace({
}
setFullWorkflowStatus('3/4 Saving GeoJSON export artifact...')
await onExportMapSelection(bbox)
await onExportMapSelection(bbox, selectionAreaId)
if (selectedMapQaReferenceDatasetId) {
setFullWorkflowStatus('4/4 Running QA/QC against selected reference...')
@@ -1982,7 +2041,14 @@ export function MapWorkspace({
<small>Stel een vraag over dit gebied of open je bewaarde resultaten.</small>
</span>
<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>
) : null}
</>
@@ -25,19 +25,19 @@ interface QualityResultsPanelProps {
function qualityMetricLabel(metricKey: string): string {
const labels: Record<string, string> = {
precision: 'Precision',
recall: 'Recall',
precision: 'Precisie',
recall: 'Teruggevonden aandeel',
f1: 'F1',
mean_iou: 'Mean IoU',
false_positive_count: 'False positives',
false_negative_count: 'False negatives',
mean_iou: 'Gemiddelde overlap',
false_positive_count: 'Onterecht gevonden',
false_negative_count: 'Gemiste objecten',
}
return labels[metricKey] ?? metricKey.replaceAll('_', ' ')
}
function qualityMetricValue(metric: MetricRead | undefined): string {
if (!metric || metric.metric_value === null || metric.metric_value === undefined) {
return 'n/a'
return 'n.v.t.'
}
const value = Number(metric.metric_value)
if (!Number.isFinite(value)) {
@@ -47,7 +47,15 @@ function qualityMetricValue(metric: MetricRead | undefined): string {
}
function qualityScoreValue(value: number | null | undefined): string {
return typeof value === 'number' && Number.isFinite(value) ? value.toFixed(3) : 'n/a'
return typeof value === 'number' && Number.isFinite(value) ? value.toFixed(3) : 'n.v.t.'
}
function qualityScoreInterpretation(value: number | null | undefined): string {
if (typeof value !== 'number' || !Number.isFinite(value)) return 'Nog geen score'
if (value >= 0.85) return 'Sterk resultaat'
if (value >= 0.70) return 'Bruikbaar na controle'
if (value >= 0.50) return 'Verkennend, controle vereist'
return 'Onvoldoende betrouwbaar'
}
function qualityStatusLabel(status: string | null | undefined): string {
@@ -76,7 +84,7 @@ function evidenceLabel(item: Record<string, unknown>): 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 {
@@ -176,8 +184,9 @@ export function QualityResultsPanel({
<strong>{completedChecks}</strong>
</div>
<div>
<span>Laatste score</span>
<span>Laatste score (0-1)</span>
<strong>{qualityScoreValue(latestCheck?.score)}</strong>
<small>{qualityScoreInterpretation(latestCheck?.score)}</small>
</div>
<div>
<span>Laatste status</span>
@@ -261,7 +270,7 @@ export function QualityResultsPanel({
<div>
<span>Te controleren laag</span>
<strong>{selectedCandidateName}</strong>
<p>{selectedQualityCheck.candidate_dataset_id ?? 'candidate not stored'}</p>
<p>{selectedQualityCheck.candidate_dataset_id ?? 'niet bewaard'}</p>
</div>
<div>
<span>Referentielaag</span>
@@ -270,8 +279,8 @@ export function QualityResultsPanel({
</div>
<div>
<span>Analyserun</span>
<strong>{selectedQualityCheck.analysis_run_id ?? 'n/a'}</strong>
<p>Job: {selectedQualityCheck.job_id ?? 'n/a'}</p>
<strong>{selectedQualityCheck.analysis_run_id ?? 'n.v.t.'}</strong>
<p>Taak: {selectedQualityCheck.job_id ?? 'n.v.t.'}</p>
</div>
<div>
<span>Status</span>
@@ -281,7 +290,7 @@ export function QualityResultsPanel({
<div>
<span>Afgerond</span>
<strong>{formatQualityTimestamp(selectedQualityCheck.completed_at)}</strong>
<p>Created: {formatQualityTimestamp(selectedQualityCheck.created_at)}</p>
<p>Gemaakt: {formatQualityTimestamp(selectedQualityCheck.created_at)}</p>
</div>
</div>
<div className="quality-evidence-token-grid">