Complete operational map result workflow
This commit is contained in:
+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
|
||||
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
|
||||
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
|
||||
as JSON with their complete timeline and provenance. After a successful
|
||||
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
|
||||
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/premium.css'
|
||||
import { ChangeDetectionPanel } from './components/analysis/ChangeDetectionPanel'
|
||||
@@ -68,8 +68,9 @@ function App(): JSX.Element {
|
||||
const [inspectorOpen, setInspectorOpen] = useState(false)
|
||||
const [mapContentMode, setMapContentMode] = useState<'dataset' | 'analysis'>('dataset')
|
||||
const [mapContextSourceLabel, setMapContextSourceLabel] = useState<string | null>(null)
|
||||
const workbenchMainRef = useRef<HTMLElement | null>(null)
|
||||
useEffect(() => {
|
||||
window.scrollTo({ top: 0, left: 0 })
|
||||
workbenchMainRef.current?.scrollTo({ top: 0, left: 0 })
|
||||
setInspectorOpen(false)
|
||||
}, [activeWorkspace])
|
||||
const {
|
||||
@@ -411,6 +412,7 @@ function App(): JSX.Element {
|
||||
exportSelectedDetectionRunGeoJson,
|
||||
exportSelectedSegmentationRunGeoJson,
|
||||
exportMapSelectionGeoJson,
|
||||
persistMapResult,
|
||||
exportProjectMetadata,
|
||||
exportProjectReport,
|
||||
previewExportContent,
|
||||
@@ -805,7 +807,7 @@ function App(): JSX.Element {
|
||||
</nav>
|
||||
</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">
|
||||
<div className="workspace-heading-copy">
|
||||
<p className="eyebrow">{selectedProject?.region ?? 'Mol, Kempen'}</p>
|
||||
@@ -821,7 +823,7 @@ function App(): JSX.Element {
|
||||
aria-expanded={inspectorOpen}
|
||||
aria-controls="workbench-inspector"
|
||||
>
|
||||
{inspectorOpen ? 'Details sluiten' : 'Details openen'}
|
||||
{inspectorOpen ? 'Context sluiten' : 'Context bekijken'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -1072,6 +1074,7 @@ function App(): JSX.Element {
|
||||
onRunMapSelectionExtract={runMapSelectionExtract}
|
||||
onClearMapSelectionExtract={resetMapSelectionExtract}
|
||||
onExportMapSelection={exportMapSelectionGeoJson}
|
||||
onPersistMapResult={persistMapResult}
|
||||
onDeriveMapSelectionDataset={deriveMapSelectionDataset}
|
||||
onSelectMapQaReferenceDataset={setSelectedMapQaReferenceDatasetId}
|
||||
onRunMapSelectionQa={runMapSelectionQa}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { useState } from 'react'
|
||||
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'
|
||||
|
||||
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)) {
|
||||
setSelectionExportError('Select a vector dataset before saving an area export.')
|
||||
return null
|
||||
@@ -119,6 +125,7 @@ export function useExportWorkflow({
|
||||
try {
|
||||
const response = await exportsApi.exportGeojson({
|
||||
dataset_id: selectedDataset.id,
|
||||
area_id: areaId,
|
||||
export_kind: 'vector_selection',
|
||||
bbox: { ...bbox, crs: 'EPSG:4326' },
|
||||
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) => {
|
||||
setExportError(null)
|
||||
try {
|
||||
@@ -209,6 +233,7 @@ export function useExportWorkflow({
|
||||
exportSelectedDetectionRunGeoJson,
|
||||
exportSelectedSegmentationRunGeoJson,
|
||||
exportMapSelectionGeoJson,
|
||||
persistMapResult,
|
||||
exportProjectMetadata,
|
||||
exportProjectReport,
|
||||
previewExportContent,
|
||||
|
||||
@@ -97,14 +97,27 @@ export function useMapThemeSelectionInsights<TThemeId extends string>(
|
||||
})),
|
||||
)
|
||||
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) {
|
||||
return []
|
||||
}
|
||||
setThemeInsights(successful)
|
||||
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(
|
||||
`${failureCount} beschikbare databron${failureCount === 1 ? '' : 'nen'} kon niet worden bevraagd.`,
|
||||
`${failureCount} beschikbare databron${failureCount === 1 ? '' : 'nen'} kon niet worden bevraagd. ${details}${remainder}`,
|
||||
)
|
||||
}
|
||||
return successful
|
||||
|
||||
@@ -144,9 +144,17 @@ export function useProjectWorkspace() {
|
||||
setLoadingProjects(true)
|
||||
setErrorMessage(null)
|
||||
try {
|
||||
const response = await projectsApi.list()
|
||||
setProjects(response.items)
|
||||
const nextProjectId = await pickInitialProjectId(response.items, preferredProjectId)
|
||||
const [response, canonicalResponse] = await Promise.all([
|
||||
projectsApi.list(),
|
||||
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) {
|
||||
setSelectedProjectId(nextProjectId)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
ExportCreateResponse,
|
||||
ExportKind,
|
||||
ExportListResponse,
|
||||
MapResultExportRequest,
|
||||
ExportRead,
|
||||
VectorSelectionBBox,
|
||||
} from '../../types'
|
||||
@@ -14,6 +15,7 @@ export const exportsApi = {
|
||||
| {
|
||||
dataset_id?: string
|
||||
analysis_run_id?: string
|
||||
area_id?: string
|
||||
export_kind?: ExportKind
|
||||
name?: string
|
||||
bbox?: VectorSelectionBBox
|
||||
@@ -28,6 +30,8 @@ export const exportsApi = {
|
||||
apiPost<ExportCreateResponse>(`/api/v1/exports/metadata`, { project_id: projectId, name }),
|
||||
exportProjectReport: (projectId: string, name?: string): Promise<ExportCreateResponse> =>
|
||||
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> =>
|
||||
apiGet<ExportListResponse>(`/api/v1/exports/projects/${projectId}/exports`),
|
||||
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'
|
||||
|
||||
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),
|
||||
get: (id: string): Promise<ProjectRead> => apiGet<ProjectRead>(`/api/v1/projects/${id}`),
|
||||
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);
|
||||
gap: 0.72rem;
|
||||
align-items: stretch;
|
||||
height: clamp(34rem, calc(100dvh - 10rem), 58rem);
|
||||
min-width: 0;
|
||||
min-height: calc(100dvh - 13.8rem);
|
||||
min-height: 34rem;
|
||||
}
|
||||
|
||||
.geo-theme-panel,
|
||||
@@ -5618,6 +5619,7 @@ section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.7rem;
|
||||
overflow: hidden;
|
||||
padding: 0.78rem;
|
||||
}
|
||||
|
||||
@@ -5659,6 +5661,13 @@ section {
|
||||
.geo-theme-list {
|
||||
display: grid;
|
||||
gap: 0.36rem;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-right: 0.18rem;
|
||||
}
|
||||
|
||||
.geo-results-panel {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.geo-project-scope-select {
|
||||
@@ -6590,7 +6599,8 @@ section {
|
||||
@media (min-width: 1800px) {
|
||||
.geo-explorer-layout {
|
||||
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 {
|
||||
@@ -6598,9 +6608,11 @@ section {
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1320px) {
|
||||
@media (max-width: 1240px) {
|
||||
.geo-explorer-layout {
|
||||
grid-template-columns: minmax(14rem, 16rem) minmax(28rem, 1fr);
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.geo-results-panel {
|
||||
@@ -6644,6 +6656,8 @@ section {
|
||||
|
||||
.geo-theme-list {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
overflow: visible;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.geo-map-toolbar {
|
||||
@@ -6657,6 +6671,7 @@ section {
|
||||
|
||||
.geo-results-panel {
|
||||
display: flex;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -696,6 +696,11 @@ details.ai-lab-model-surface > summary strong {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.workspace-grid-ai {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.workspace-grid-exports {
|
||||
grid-template-columns: minmax(28rem, 1.2fr) minmax(22rem, 0.8fr);
|
||||
align-items: start;
|
||||
@@ -708,6 +713,11 @@ details.ai-lab-model-surface > summary strong {
|
||||
overflow: auto;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.workspace-grid-ai > section:nth-child(n) {
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
/* Overview */
|
||||
@@ -2007,6 +2017,23 @@ details.ai-lab-model-surface > summary strong {
|
||||
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 {
|
||||
margin-right: 0.25rem;
|
||||
color: var(--ink);
|
||||
|
||||
@@ -1392,6 +1392,20 @@ export interface ExportCreateResponse {
|
||||
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 {
|
||||
items: ExportRead[]
|
||||
total: number
|
||||
|
||||
Reference in New Issue
Block a user