feat(scope): make Belgium and North Sea operational default
GeoIntel release gates / Compile, test, contracts and builds (push) Canceled after 0s
GeoIntel release gates / Python and npm vulnerability policy (push) Canceled after 0s
GeoIntel release gates / GIS image, SBOM and container scan (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-22 02:11:48 +02:00
parent 46884cbbc9
commit 0aff8e3b8c
61 changed files with 2499 additions and 194 deletions
+13 -3
View File
@@ -1,8 +1,12 @@
# GeoIntel Frontend (Sprint 4)
React + TypeScript + MapLibre workbench for regional geographic analysis.
React + TypeScript + MapLibre workbench for Belgium and the Belgian North Sea.
The persisted `Kempen Regional Workbench` is the automatic operational data context. Its datasets are loaded once for the complete official 28-municipality Vlaamse vervoerregio; the operator chooses Mol, another municipality or the complete region as a spatial work-area filter. The primary map no longer asks the user to choose a technical project or region before data becomes usable. Regional population and modern forest snapshots use the same current/evolution flow as Mol, while explicit project selection stays available under advanced management.
The persisted `Belgium and North Sea Workbench` is the unconditional primary
data context whenever it exists. The map opens at national extent and supports
bounded selections across Flanders, Wallonia, Brussels and the legally labelled
Belgian maritime zones. Mol and the Kempen remain selectable golden regression
areas, but are never used as an implicit product boundary or startup fallback.
The Status workspace includes one compact `Actualiteit en versiecontrole`
surface. It separates sources that are current, due for a catalogue review,
@@ -29,7 +33,13 @@ as historical observations.
The user-facing shell is task based: `Kaart`, `Bronnen`, `Kwaliteit`, `Beeldanalyse`, `Downloads`, `Status` and `Beheer`. Internal benchmark projects, raw dataset metadata, provider capabilities, model registry details and QA evidence remain accessible through labelled advanced disclosures instead of competing with the normal workflow.
Detection defaults to the configured local YOLO asset and automatically selects an available raster and active model asset where possible. The model registry and preflight remain honest when PyTorch, Ultralytics, a local model file or a tile manifest is unavailable. The active building profile is operational but remains review-required: its current coverage-aligned benchmark is approximately precision 0.614, recall 0.606 and F1 0.607 over seven positive AOIs, with zero detections in all three pure-empty controls. A reviewed challenger remains inactive because it produced two detections in empty Postel forest.
Detection defaults to the explicitly configured local YOLO asset and
automatically selects an available raster where possible. In production the
asset catalog exposes only the file matching `YOLO_MODEL_PATH`; training,
partial and smoke checkpoints remain on disk but do not become end-user model
choices. The current building benchmark covers seven Mol/Kempen AOIs and is
shown as local validation, not as proof of national model quality. Every other
Belgian or maritime context requires local reference QA before release.
Map-driven building analysis uses the documented footprint-IoU `0.25` and
distinguishes model candidates from verified buildings. It shows persisted
+1 -1
View File
@@ -842,7 +842,7 @@ function App(): JSX.Element {
>
{activeWorkspace !== 'map' ? <div className="workspace-heading">
<div className="workspace-heading-copy">
<p className="eyebrow">{selectedProject?.region ?? 'Mol, Kempen'}</p>
<p className="eyebrow">{selectedProject?.region ?? 'Belgie en Belgische Noordzee'}</p>
<h2>{activeWorkspaceItem.label}</h2>
</div>
<div className="workspace-heading-actions">
+3 -3
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import maplibregl from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import { PRIMARY_FOCUS_CENTER } from '../config/primaryFocus'
import { NATIONAL_MAP_CENTER, NATIONAL_MAP_ZOOM } from '../config/primaryFocus'
import { featureCollectionBounds } from '../lib/geojsonBounds'
import type { MapImageOverlay, MapViewportState, VectorSelectionBBox } from '../types'
@@ -221,8 +221,8 @@ function GeoMap({
const map = new maplibregl.Map({
container: containerRef.current,
style: defaultMapStyle(),
center: PRIMARY_FOCUS_CENTER,
zoom: 11,
center: NATIONAL_MAP_CENTER,
zoom: NATIONAL_MAP_ZOOM,
attributionControl: false,
})
const resizeObserver = new ResizeObserver(() => {
@@ -289,7 +289,7 @@ export function SourceCatalogPanel({
{Number(latestBuildingsRegister.source_metadata?.['building_unit_count'] ?? 0).toLocaleString('nl-BE')} eenheden · {' '}
{Number(latestBuildingsRegister.source_metadata?.['linked_address_count'] ?? 0).toLocaleString('nl-BE')} gekoppelde adressen
</span>
<p>Registerstatus en geaggregeerde koppelingen voor Mol; adreslabels en persoonsgegevens worden niet in de kaartlaag getoond.</p>
<p>Registerstatus en geaggregeerde koppelingen binnen de werkelijk ingeladen dekking; adreslabels en persoonsgegevens worden niet in de kaartlaag getoond.</p>
</article>
) : null}
{dhmvDatasets.length > 0 ? (
@@ -297,9 +297,9 @@ export function DetectionLab({
<p>{yoloRuntimeReady ? `${yoloPreflight?.runtime.cuda_available ? 'GPU' : 'CPU'} · lokaal model gevonden` : 'Controleer de modelconfiguratie onder beheer.'}</p>
</div>
<div className="ai-user-summary-card">
<span>Gevalideerde kwaliteit</span>
<span>Validatiescope</span>
<strong>{selectedOperatorProfile ? `F1 ${selectedOperatorProfile.f1.toFixed(3)}` : 'Nog niet gekoppeld'}</strong>
<p>{selectedOperatorProfile ? `${selectedOperatorProfile.positiveSampleCount} testgebieden · resultaten blijven controleplichtig` : 'Kies het goedgekeurde lokale profiel.'}</p>
<p>{selectedOperatorProfile ? selectedOperatorProfile.validationScope : 'Kies een modelprofiel met gedocumenteerd evaluatiebewijs.'}</p>
</div>
<div className={rasterDatasets.length > 0 ? 'ai-user-summary-card ai-user-summary-card-ready' : 'ai-user-summary-card'}>
<span>Beschikbare luchtbeelden</span>
@@ -310,6 +310,15 @@ export function DetectionLab({
<p className="ai-quality-guidance">
{detectionQualityInterpretation(selectedOperatorProfile?.f1)}
</p>
{selectedOperatorProfile && !selectedOperatorProfile.nationallyValidated ? (
<div className="result-state result-state-warning" role="status">
<strong>Nog niet nationaal gevalideerd</strong>
<p>
Dit model is operationeel voor gecontroleerde beeldanalyse, maar de gemeten kwaliteit geldt alleen voor {selectedOperatorProfile.validationScope}.
Resultaten elders in Belgie of op zee vereisen lokale referentiedata en QA voordat ze als betrouwbaar kunnen worden vrijgegeven.
</p>
</div>
) : null}
<DetectionModelManagement
detectionModels={detectionModels}
@@ -10,6 +10,8 @@ export interface DetectionOperatorProfile {
f1: number
positiveSampleCount: number
maxBackgroundDetections: number
validationScope: string
nationallyValidated: boolean
description: string
limitationMessage: string
}
@@ -27,6 +29,8 @@ export const DETECTION_OPERATOR_PROFILES: DetectionOperatorProfile[] = [
f1: 0.6068607646002744,
positiveSampleCount: 7,
maxBackgroundDetections: 0,
validationScope: '7 onafhankelijke testgebieden in Mol en de Kempen',
nationallyValidated: false,
description:
'Aanbevolen profiel met een evenwicht tussen gevonden en gemiste kleine gebouwen, opnieuw gemeten over zeven onafhankelijke testgebieden in Mol en de Kempen.',
limitationMessage:
@@ -44,6 +48,8 @@ export const DETECTION_OPERATOR_PROFILES: DetectionOperatorProfile[] = [
f1: 0.5432865390636915,
positiveSampleCount: 7,
maxBackgroundDetections: 0,
validationScope: '7 onafhankelijke testgebieden in Mol en de Kempen',
nationallyValidated: false,
description: 'Voorgaand profiel voor controles waarbij minder foutieve vondsten belangrijker zijn dan maximale dekking.',
limitationMessage:
'De lege-achtergrondtest is geslaagd. Dit profiel vindt minder onterechte objecten, maar mist meer kleine gebouwen dan het aanbevolen profiel.',
@@ -60,6 +66,8 @@ export const DETECTION_OPERATOR_PROFILES: DetectionOperatorProfile[] = [
f1: 0.32086574003576274,
positiveSampleCount: 7,
maxBackgroundDetections: 0,
validationScope: '7 onafhankelijke testgebieden in Mol en de Kempen',
nationallyValidated: false,
description: 'Profiel met hoge precisie voor controles waarbij zo weinig mogelijk foutieve vondsten zwaarder wegen dan volledige dekking.',
limitationMessage:
'Goedgekeurd na de lege-achtergrondtest. Resultaten in dun bebouwde context blijven altijd controlebewijs en geen automatische waarheid.',
+1 -1
View File
@@ -178,7 +178,7 @@ const DATA_THEMES: DataTheme[] = [
id: 'soil',
label: 'Bodem',
shortLabel: 'Bodemkaart',
description: 'Historische DOV-bodemkartering met bodemtype, textuur en drainageklasse voor Mol.',
description: 'Officiele bodemkartering met bodemtype, textuur en drainageklasse waar de geselecteerde zone door een gekoppelde bron wordt gedekt.',
tokens: ['soil', 'bodem', 'bodemkaart', 'bodemtype', 'dov_soil_map'],
},
{
@@ -4,6 +4,9 @@ import type { AreaRead, ProjectRead } from '../../types'
const AREA_CATALOG_PAGE_SIZE = 12
function projectDisplayName(project: ProjectRead | null): string {
if (project?.name === 'Belgium and North Sea Workbench') {
return 'Belgie en Belgische Noordzee'
}
if (project?.name === 'Kempen Regional Workbench') {
return 'Kempen · volledige regio'
}
@@ -81,7 +84,7 @@ export function AreaPanel({
<div className="data-selection-summary data-selection-summary-area">
<span>Getoond op de kaart</span>
<strong>{selectedArea?.name ?? 'Geen gebied geselecteerd'}</strong>
<small>{selectedArea?.area_m2 ? `${(selectedArea.area_m2 / 1_000_000).toLocaleString('nl-BE', { maximumFractionDigits: 2 })} km2` : 'Kies een gemeente of de volledige regio op de kaart.'}</small>
<small>{selectedArea?.area_m2 ? `${(selectedArea.area_m2 / 1_000_000).toLocaleString('nl-BE', { maximumFractionDigits: 2 })} km2` : 'Kies een gemeente, gewest, zeezone of teken een eigen selectie.'}</small>
</div>
<details className="data-panel-form-block">
@@ -136,7 +139,7 @@ export function AreaPanel({
setSearchQuery(event.target.value)
setPage(1)
}}
placeholder="Bijvoorbeeld Mol"
placeholder="Bijvoorbeeld Brussel, Namen of Noordzee"
/>
</label>
<div className="catalog-pagination" aria-label="Paginering van gebieden">
@@ -2,19 +2,23 @@ import type { FormEvent } from 'react'
import type { ProjectCreate, ProjectRead } from '../../types'
const TECHNICAL_PROJECT_PATTERN = /^(GeoIntel Detection Quality Matrix|GeoIntel hard-negative|GeoIntel training|Mol Building QA)/i
const NATIONAL_PROJECT_NAME = 'Belgium and North Sea Workbench'
const REGIONAL_PROJECT_NAME = 'Kempen Regional Workbench'
const LEGACY_MOL_PROJECT_NAME = 'Mol Municipality Workbench'
const PROTECTED_PROJECT_NAMES = new Set([REGIONAL_PROJECT_NAME, LEGACY_MOL_PROJECT_NAME])
const PROTECTED_PROJECT_NAMES = new Set([NATIONAL_PROJECT_NAME, REGIONAL_PROJECT_NAME, LEGACY_MOL_PROJECT_NAME])
function isTechnicalProject(project: ProjectRead): boolean {
return TECHNICAL_PROJECT_PATTERN.test(project.name)
}
function isAdvancedProject(project: ProjectRead): boolean {
return isTechnicalProject(project) || project.name === LEGACY_MOL_PROJECT_NAME
return isTechnicalProject(project) || project.name === LEGACY_MOL_PROJECT_NAME || project.name === REGIONAL_PROJECT_NAME
}
function projectDisplayName(project: ProjectRead): string {
if (project.name === NATIONAL_PROJECT_NAME) {
return 'Belgie en Belgische Noordzee'
}
if (project.name === REGIONAL_PROJECT_NAME) {
return 'Kempen · volledige regionale werkruimte'
}
@@ -126,7 +130,7 @@ export function ProjectPanel({
<label>
Regio
<input
value={projectForm.region ?? 'Kempen'}
value={projectForm.region ?? 'Belgie en Belgische Noordzee'}
onChange={(event) => onUpdateProjectForm({ ...projectForm, region: event.target.value })}
placeholder="Regio"
/>
@@ -70,6 +70,9 @@ function persistedSegmentationModelLabel(modelName: string | null | undefined):
if (modelName === 'fixture-segmenter') return 'Testsegmentatie'
if (modelName === 'sam-placeholder') return 'SAM-model niet geconfigureerd'
if (modelName === 'yolo-seg-placeholder') return 'YOLO-segmentatie niet geconfigureerd'
if (modelName === 'segmentation-placeholder') return 'Segmentatiemodel niet geconfigureerd'
if (modelName === 'yolo-seg-configured') return 'Lokaal YOLO-segmentatiemodel'
if (modelName === 'sam-configured') return 'Lokaal SAM-model'
return modelName
}
+3
View File
@@ -4,6 +4,9 @@ export const PRIMARY_FOCUS_LABEL = 'Mol'
export const PRIMARY_FOCUS_REGION = 'Mol, Kempen'
export const NATIONAL_WORKSPACE_PROJECT_NAME = 'Belgium and North Sea Workbench'
export const NATIONAL_WORKSPACE_LABEL = 'Belgie en Belgische Noordzee'
export const NATIONAL_WORKSPACE_REGION = 'Belgie en Belgische Noordzee'
export const NATIONAL_MAP_CENTER: [number, number] = [4.62, 50.72]
export const NATIONAL_MAP_ZOOM = 7.25
export const REGIONAL_WORKSPACE_PROJECT_NAME = 'Kempen Regional Workbench'
export const REGIONAL_WORKSPACE_LABEL = 'Kempen (28 gemeenten)'
export const FLANDERS_WORKSPACE_PROJECT_NAME = 'Flanders Regional Workbench'
@@ -54,7 +54,7 @@ function formatOrthophotoError(caught: unknown): string {
return 'Maak de rechthoek maximaal 1.024 bij 1.024 meter groot.'
}
if (code === 'ORTHOPHOTO_SELECTION_OUTSIDE_AREA') {
return 'Teken de rechthoek binnen de ingeladen regio Kempen.'
return 'Teken de rechthoek binnen het ingeladen Belgische land- of zeegebied.'
}
if (code === 'ORTHOPHOTO_PROVIDER_UNAVAILABLE' || code === 'ORTHOPHOTO_PROVIDER_INVALID_RESPONSE') {
return 'De officiële luchtbeeldbron is tijdelijk niet bereikbaar. Probeer later opnieuw.'
+9 -44
View File
@@ -1,14 +1,8 @@
import { FormEvent, useMemo, useRef, useState } from 'react'
import {
PRIMARY_FOCUS_AREA_GEOJSON,
PRIMARY_FOCUS_AREA_NAME,
PRIMARY_FOCUS_REGION,
NATIONAL_WORKSPACE_PROJECT_NAME,
NATIONAL_WORKSPACE_REGION,
REGIONAL_WORKSPACE_PROJECT_NAME,
isPrimaryFocusMunicipalityBoundaryDataset,
isPrimaryFocusMunicipalityProject,
isPrimaryFocusProject,
isPrimaryFocusProjectData,
} from '../config/primaryFocus'
import { areasApi } from '../services/api/areas'
import { datasetsApi } from '../services/api/datasets'
@@ -54,11 +48,11 @@ export function useProjectWorkspace() {
const [projectForm, setProjectForm] = useState<ProjectCreate>({
name: '',
description: '',
region: PRIMARY_FOCUS_REGION,
region: NATIONAL_WORKSPACE_REGION,
})
const [areaForm, setAreaForm] = useState({
name: PRIMARY_FOCUS_AREA_NAME,
geometry: PRIMARY_FOCUS_AREA_GEOJSON,
name: '',
geometry: '',
crs: 'EPSG:4326',
})
@@ -84,14 +78,7 @@ export function useProjectWorkspace() {
}
const nationalProject = items.find((project) => project.name === NATIONAL_WORKSPACE_PROJECT_NAME)
if (nationalProject) {
try {
const data = await fetchProjectData(nationalProject.id)
if (data.areas.length > 0 && data.datasets.some((dataset) => dataset.status === 'ready')) {
return nationalProject.id
}
} catch {
// Continue with regional and municipality fallbacks while national data is unavailable.
}
return nationalProject.id
}
const regionalProject = items.find((project) => project.name === REGIONAL_WORKSPACE_PROJECT_NAME)
if (regionalProject) {
@@ -104,22 +91,10 @@ export function useProjectWorkspace() {
// Continue with the municipality and completeness fallbacks when regional data is unavailable.
}
}
const municipalityProject = items.find(isPrimaryFocusMunicipalityProject)
if (municipalityProject) {
try {
const data = await fetchProjectData(municipalityProject.id)
if (data.areas.length > 0 && data.datasets.some(isPrimaryFocusMunicipalityBoundaryDataset)) {
return municipalityProject.id
}
} catch {
// Continue with the normal completeness ranking if the canonical workspace is temporarily unavailable.
}
}
const primaryProjects = items.filter(isPrimaryFocusProject)
const demoProjects = items.filter(isDemoProject)
const candidates = Array.from(
new Map(
[...primaryProjects, ...demoProjects, ...items].map((project) => [project.id, project]),
[...items, ...demoProjects].map((project) => [project.id, project]),
).values(),
).slice(0, 12)
const inspectedCandidates: Array<{
@@ -130,27 +105,17 @@ export function useProjectWorkspace() {
try {
const data = await fetchProjectData(project.id)
inspectedCandidates.push({ project, data })
if (
isPrimaryFocusProjectData(project, data.datasets) &&
hasMappedAnalysisContext(data)
) {
if (hasMappedAnalysisContext(data)) {
return project.id
}
} catch {
// Project list should still render if a candidate's detail endpoints are temporarily unavailable.
}
}
const primaryContext = inspectedCandidates.find(
({ project, data }) =>
data.datasets.length > 0 && isPrimaryFocusProjectData(project, data.datasets),
)
if (primaryContext) {
return primaryContext.project.id
}
const completeContext = inspectedCandidates.find(
({ data }) => data.areas.length > 0 && data.datasets.length > 0,
)
return completeContext?.project.id ?? primaryProjects[0]?.id ?? demoProjects[0]?.id ?? items[0]?.id ?? null
return completeContext?.project.id ?? demoProjects[0]?.id ?? items[0]?.id ?? null
}
const loadProjects = async (preferredProjectId?: string | null) => {
@@ -216,7 +181,7 @@ export function useProjectWorkspace() {
const createdProject = await projectsApi.create({
name: projectForm.name.trim(),
description: projectForm.description?.trim() || undefined,
region: projectForm.region?.trim() || PRIMARY_FOCUS_REGION,
region: projectForm.region?.trim() || NATIONAL_WORKSPACE_REGION,
})
setProjectForm((previous) => ({ ...previous, name: '', description: '' }))
setSelectedProjectId(createdProject.id)
+11 -2
View File
@@ -59,8 +59,17 @@ export function useSegmentationWorkflow({
try {
const response = await segmentationApi.listModels()
setSegmentationModels(response.models)
if (!response.models.some((model) => model.model_id === selectedSegmentationModelId) && response.models.length > 0) {
setSelectedSegmentationModelId(response.models[0].model_id)
const selectionStillAvailable = response.models.some((model) => model.model_id === selectedSegmentationModelId)
const selectionConfigured = response.models.some(
(model) => model.model_id === selectedSegmentationModelId && model.configured,
)
if ((!selectionStillAvailable || !selectionConfigured) && response.models.length > 0) {
const configuredModel = response.models.find(
(model) => model.configured && model.model_id !== 'fixture-segmenter',
)
setSelectedSegmentationModelId(
configuredModel?.model_id ?? (selectionStillAvailable ? selectedSegmentationModelId : response.models[0].model_id),
)
}
} catch (error) {
setSegmentationModelError(formatError(error, 'Failed to load segmentation models'))
+1 -1
View File
@@ -318,7 +318,7 @@ export const OFFICIAL_SOURCE_PORTFOLIO: OfficialSourceDefinition[] = [
name: 'Gemeente in cijfers',
owner: 'Vlaamse Milieumaatschappij',
coverage: 'Gemeentelijke klimaat- en leefomgevingsindicatoren',
value: 'Contextcijfers voor Mol en vergelijking met andere gemeenten.',
value: 'Contextcijfers en vergelijking tussen gemeenten binnen de beschikbare brondekking.',
metricExamples: 'hittegolfdagen, temperatuur en luchtkwaliteitsindex',
priority: 'later',
url: 'https://www.vlaanderen.be/datavindplaats/catalogus/gemeente-in-cijfers',