Add Belgium and North Sea coverage foundation
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-18 01:45:00 +02:00
parent a8fd6e5e9c
commit 1e527bf810
29 changed files with 2524 additions and 10 deletions
+21 -5
View File
@@ -19,6 +19,7 @@ import { ProviderPanel } from './components/providers/ProviderPanel'
import { SegmentationLab } from './components/segmentation/SegmentationLab'
import { useChangeDetectionWorkflow } from './hooks/useChangeDetectionWorkflow'
import { useDemoWorkflow } from './hooks/useDemoWorkflow'
import { useCoverageResolver } from './hooks/useCoverageResolver'
import { useDetectionWorkflow } from './hooks/useDetectionWorkflow'
import { useDatasetWorkflow } from './hooks/useDatasetWorkflow'
import { useExportWorkflow } from './hooks/useExportWorkflow'
@@ -39,6 +40,8 @@ import { getDatasetDisplayName } from './lib/datasetDisplay'
import {
FLANDERS_WORKSPACE_LABEL,
FLANDERS_WORKSPACE_PROJECT_NAME,
NATIONAL_WORKSPACE_LABEL,
NATIONAL_WORKSPACE_PROJECT_NAME,
REGIONAL_WORKSPACE_LABEL,
REGIONAL_WORKSPACE_PROJECT_NAME,
} from './config/primaryFocus'
@@ -475,6 +478,14 @@ function App(): JSX.Element {
selectedDataset,
isVectorDatasetType,
})
const {
coverage: mapCoverage,
loadingCoverage: mapCoverageLoading,
coverageError: mapCoverageError,
} = useCoverageResolver({
projectId: selectedProjectId,
bbox: mapSelectionBbox,
})
const {
selectionDatasetSaving,
selectionDatasetError,
@@ -668,11 +679,13 @@ function App(): JSX.Element {
|| Boolean(changeDetectionResult)
|| detectionItems.length > 0
|| segmentationItems.length > 0
const projectContextLabel = selectedProject?.name === REGIONAL_WORKSPACE_PROJECT_NAME
? REGIONAL_WORKSPACE_LABEL
: selectedProject?.name === FLANDERS_WORKSPACE_PROJECT_NAME
? FLANDERS_WORKSPACE_LABEL
: selectedProject?.name ?? 'Geen werkruimte'
const projectContextLabel = selectedProject?.name === NATIONAL_WORKSPACE_PROJECT_NAME
? NATIONAL_WORKSPACE_LABEL
: selectedProject?.name === REGIONAL_WORKSPACE_PROJECT_NAME
? REGIONAL_WORKSPACE_LABEL
: selectedProject?.name === FLANDERS_WORKSPACE_PROJECT_NAME
? FLANDERS_WORKSPACE_LABEL
: selectedProject?.name ?? 'Geen werkruimte'
const areaContextLabel = selectedArea?.name ?? (areas.length > 0 ? 'Kies een gebied' : 'Geen gebied')
const bathymetryContextActive = Boolean(
activeWorkspace === 'map'
@@ -927,6 +940,9 @@ function App(): JSX.Element {
mapSelectionResult={mapSelectionResult}
mapSelectionLoading={mapSelectionLoading}
mapSelectionError={mapSelectionError}
coverage={mapCoverage}
coverageLoading={mapCoverageLoading}
coverageError={mapCoverageError}
selectionExporting={selectionExporting}
selectionExportError={selectionExportError}
latestSelectionExportPath={latestSelectionExport?.path ?? null}
+103 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import GeoMap from '../GeoMap'
import type { AreaRead, DatasetCreateResponse, DetectionQaResult, MapResultExportRequest, MapViewportState, OrthophotoAcquisitionResult, OrthophotoProductRead, ProjectRead, QaComparisonResult, VectorSelectionBBox, VectorSelectionMetric, VectorSelectionResponse } from '../../types'
import type { AreaRead, CoverageResolveResponse, CoverageStatus, DatasetCreateResponse, DetectionQaResult, MapResultExportRequest, MapViewportState, OrthophotoAcquisitionResult, OrthophotoProductRead, ProjectRead, QaComparisonResult, VectorSelectionBBox, VectorSelectionMetric, VectorSelectionResponse } from '../../types'
import { useMapThemeSelectionInsights, type MapThemeAcquisition, type MapThemeQuery } from '../../hooks/useMapThemeSelectionInsights'
import { useOfficialMapProducts } from '../../hooks/useOfficialMapProducts'
import { useTemporalComparison } from '../../hooks/useTemporalComparison'
@@ -176,6 +176,49 @@ const DATA_THEMES: DataTheme[] = [
},
]
const COVERAGE_THEME_BY_MAP_THEME: Record<DataThemeId, string> = {
buildings: 'buildings',
space_occupation: 'land_cover_use',
open_space: 'land_cover_use',
population: 'population',
forest: 'land_cover_use',
nature_value: 'nature',
agriculture: 'land_cover_use',
soil: 'soil',
water: 'surface_water',
bathymetry: 'bathymetry',
flood_hazard: 'flood_climate',
elevation: 'elevation',
accessibility: 'roads',
services: 'population',
roads: 'roads',
parcels: 'parcels',
}
function coverageStatusLabel(status: CoverageStatus): string {
const labels: Record<CoverageStatus, string> = {
operational: 'Beschikbaar',
partial: 'Gedeeltelijk',
not_configured: 'Niet gekoppeld',
unsupported: 'Niet ondersteund',
}
return labels[status]
}
function coverageZoneLabel(zone: string): string {
const labels: Record<string, string> = {
belgium: 'Belgie',
flanders: 'Vlaanderen',
wallonia: 'Wallonie',
brussels: 'Brussel',
belgian_north_sea: 'Belgische Noordzee',
territorial_sea: 'Territoriale zee',
exclusive_economic_zone: 'EEZ',
continental_shelf: 'Continentaal plat',
}
return labels[zone] ?? zone
}
const DATA_THEME_MAP_STYLES: Record<DataThemeId, { fill: string; line: string }> = {
buildings: { fill: '#d45f3d', line: '#9f3e24' },
space_occupation: { fill: '#be3e33', line: '#8f2c24' },
@@ -516,6 +559,9 @@ interface MapWorkspaceProps {
mapSelectionResult: VectorSelectionResponse | null
mapSelectionLoading: boolean
mapSelectionError: string | null
coverage: CoverageResolveResponse | null
coverageLoading: boolean
coverageError: string | null
selectionExporting: boolean
selectionExportError: string | null
latestSelectionExportPath: string | null
@@ -602,6 +648,9 @@ export function MapWorkspace({
mapSelectionResult,
mapSelectionLoading,
mapSelectionError,
coverage,
coverageLoading,
coverageError,
selectionExporting,
selectionExportError,
latestSelectionExportPath,
@@ -819,6 +868,15 @@ export function MapWorkspace({
[availableMapDatasets, regionalScopeSelected, selectedMapAreaId, themeDatasetMap],
)
const activeTheme = DATA_THEMES.find((theme) => theme.id === activeThemeId) ?? DATA_THEMES[0]
const activeCoverageTheme = COVERAGE_THEME_BY_MAP_THEME[activeTheme.id]
const activeCoverageItems = coverage?.items.filter((item) => item.theme === activeCoverageTheme) ?? []
const coverageCounts = useMemo(
() => coverage?.items.reduce<Record<CoverageStatus, number>>(
(counts, item) => ({ ...counts, [item.status]: counts[item.status] + 1 }),
{ operational: 0, partial: 0, not_configured: 0, unsupported: 0 },
) ?? { operational: 0, partial: 0, not_configured: 0, unsupported: 0 },
[coverage],
)
const onDemandProductMap = useMemo(
() => {
const result = new Map<DataThemeId, OnDemandMapProduct>()
@@ -2527,6 +2585,50 @@ export function MapWorkspace({
</div>
) : null}
{mapSelectionBbox ? (
<section className="coverage-resolution-surface" aria-label="Datadekking van de kaartselectie">
<div className="panel-title-row">
<div>
<p className="eyebrow">Dekking van deze selectie</p>
<h3>{activeTheme.label}</h3>
</div>
{coverageLoading ? <span className="count-pill">controleren</span> : null}
</div>
{coverageError ? <p className="error">{coverageError}</p> : null}
{coverage ? (
<>
<div className="coverage-zone-row">
{coverage.intersected_zones.map((zone) => (
<span key={zone}>{coverageZoneLabel(zone)}</span>
))}
{coverage.outside_supported_scope ? <span className="coverage-zone-warning">deels buiten scope</span> : null}
</div>
<div className="coverage-active-theme-grid">
{activeCoverageItems.map((item) => (
<div className={`coverage-status-item coverage-status-${item.status}`} key={`${item.zone}:${item.theme}`}>
<span>{coverageZoneLabel(item.zone)}</span>
<strong>{coverageStatusLabel(item.status)}</strong>
<small>{item.source_names.join(', ') || 'Geen broncontract'}</small>
</div>
))}
{activeCoverageItems.length === 0 ? (
<p className="muted">Deze selectie raakt geen bewaarde Belgische land- of zeezone.</p>
) : null}
</div>
<div className="coverage-summary-row" aria-label="Samenvatting van alle themas">
<span>{coverageCounts.operational} beschikbaar</span>
<span>{coverageCounts.partial} gedeeltelijk</span>
<span>{coverageCounts.not_configured} niet gekoppeld</span>
<span>{coverageCounts.unsupported} niet ondersteund</span>
</div>
{coverage.warnings.map((warning) => <p className="muted" key={warning}>{warning}</p>)}
</>
) : !coverageLoading && !coverageError ? (
<p className="muted">De dekkingsmatrix wordt bepaald zodra de selectie volledig is.</p>
) : null}
</section>
) : null}
<div className="map-inspection-surface">
<div className="gis-test-run-surface" aria-label="Operationele GIS-controle">
<div className="panel-title-row">
+2
View File
@@ -2,6 +2,8 @@ import type { DatasetCreateResponse, ProjectRead } from '../types'
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 REGIONAL_WORKSPACE_PROJECT_NAME = 'Kempen Regional Workbench'
export const REGIONAL_WORKSPACE_LABEL = 'Kempen (28 gemeenten)'
export const FLANDERS_WORKSPACE_PROJECT_NAME = 'Flanders Regional Workbench'
+63
View File
@@ -0,0 +1,63 @@
import { useEffect, useState } from 'react'
import { externalApi } from '../services/api'
import type { CoverageResolveResponse, VectorSelectionBBox } from '../types'
interface CoverageResolverOptions {
projectId: string | null
bbox: VectorSelectionBBox | null
}
export function useCoverageResolver({ projectId, bbox }: CoverageResolverOptions) {
const [coverage, setCoverage] = useState<CoverageResolveResponse | null>(null)
const [loadingCoverage, setLoadingCoverage] = useState(false)
const [coverageError, setCoverageError] = useState<string | null>(null)
useEffect(() => {
if (!projectId || !bbox) {
setCoverage(null)
setCoverageError(null)
setLoadingCoverage(false)
return
}
let cancelled = false
const timer = window.setTimeout(() => {
setLoadingCoverage(true)
setCoverageError(null)
externalApi.resolveCoverage({
projectId,
bbox: {
minx: bbox.min_x,
miny: bbox.min_y,
maxx: bbox.max_x,
maxy: bbox.max_y,
},
})
.then((result) => {
if (!cancelled) {
setCoverage(result)
}
})
.catch((error) => {
if (!cancelled) {
setCoverage(null)
setCoverageError(error instanceof Error ? error.message : 'Dekking kon niet worden bepaald')
}
})
.finally(() => {
if (!cancelled) {
setLoadingCoverage(false)
}
})
}, 250)
return () => {
cancelled = true
window.clearTimeout(timer)
}
}, [projectId, bbox?.min_x, bbox?.min_y, bbox?.max_x, bbox?.max_y])
return {
coverage,
loadingCoverage,
coverageError,
}
}
+16 -2
View File
@@ -3,6 +3,7 @@ import {
PRIMARY_FOCUS_AREA_GEOJSON,
PRIMARY_FOCUS_AREA_NAME,
PRIMARY_FOCUS_REGION,
NATIONAL_WORKSPACE_PROJECT_NAME,
REGIONAL_WORKSPACE_PROJECT_NAME,
isPrimaryFocusMunicipalityBoundaryDataset,
isPrimaryFocusMunicipalityProject,
@@ -81,6 +82,17 @@ export function useProjectWorkspace() {
if (selectedProjectId && items.some((project) => project.id === selectedProjectId)) {
return selectedProjectId
}
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.
}
}
const regionalProject = items.find((project) => project.name === REGIONAL_WORKSPACE_PROJECT_NAME)
if (regionalProject) {
try {
@@ -145,13 +157,15 @@ export function useProjectWorkspace() {
setLoadingProjects(true)
setErrorMessage(null)
try {
const [response, canonicalResponse] = await Promise.all([
const [response, nationalResponse, canonicalResponse] = await Promise.all([
projectsApi.list(),
projectsApi.list({ name: NATIONAL_WORKSPACE_PROJECT_NAME, limit: 1 }),
projectsApi.list({ name: REGIONAL_WORKSPACE_PROJECT_NAME, limit: 1 }),
])
const regionalItems = [...canonicalResponse.items, ...response.items]
const items = Array.from(
new Map(
[...canonicalResponse.items, ...response.items].map((project) => [project.id, project]),
[...nationalResponse.items, ...regionalItems].map((project) => [project.id, project]),
).values(),
)
setProjects(items)
+14
View File
@@ -1,5 +1,7 @@
import { apiGet, apiPost } from './client'
import type {
CoverageCatalogResponse,
CoverageResolveResponse,
ProviderCapability,
ProviderCapabilitiesResponse,
ProviderImportResponse,
@@ -13,6 +15,18 @@ const normalize = (layers: string[] = []) => layers.filter((value) => value.trim
export const externalApi = {
listSystemCapabilities: (): Promise<SystemCapabilitiesResponse> =>
apiGet<SystemCapabilitiesResponse>('/api/v1/system/capabilities'),
getCoverageCatalog: (): Promise<CoverageCatalogResponse> =>
apiGet<CoverageCatalogResponse>('/api/v1/external/coverage/catalog'),
resolveCoverage: (payload: {
projectId: string
bbox: { minx: number; miny: number; maxx: number; maxy: number }
themes?: string[]
}): Promise<CoverageResolveResponse> =>
apiPost<CoverageResolveResponse>('/api/v1/external/coverage/resolve', {
project_id: payload.projectId,
bbox: payload.bbox,
themes: payload.themes ?? [],
}),
listProviders: (): Promise<ProviderCapabilitiesResponse> =>
apiGet<ProviderCapabilitiesResponse>('/api/v1/external/providers'),
listProviderCapabilities: (): Promise<ProviderCapabilitiesResponse> =>
+64
View File
@@ -7347,3 +7347,67 @@ section {
.workspace-persistent-map[hidden] {
display: none;
}
.coverage-resolution-surface {
padding: 18px 20px;
border-block: 1px solid var(--line);
background: color-mix(in srgb, var(--surface-raised) 94%, var(--accent-soft));
}
.coverage-zone-row,
.coverage-summary-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.coverage-zone-row span,
.coverage-summary-row span {
padding: 4px 8px;
border: 1px solid var(--line);
border-radius: 4px;
background: var(--surface-raised);
color: var(--muted);
font-size: 0.78rem;
}
.coverage-zone-row .coverage-zone-warning {
border-color: var(--warning);
color: var(--warning);
}
.coverage-active-theme-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 8px;
margin-top: 12px;
}
.coverage-status-item {
display: grid;
gap: 3px;
min-height: 76px;
padding: 10px 12px;
border: 1px solid var(--line);
border-left: 3px solid var(--muted);
border-radius: 6px;
background: var(--surface-raised);
}
.coverage-status-item span,
.coverage-status-item small {
color: var(--muted);
}
.coverage-status-operational {
border-left-color: var(--accent);
}
.coverage-status-partial {
border-left-color: var(--warning);
}
.coverage-status-not_configured,
.coverage-status-unsupported {
border-left-color: var(--muted);
}
+51
View File
@@ -957,6 +957,57 @@ export interface ProviderCapability {
not_configured_reason: string | null
}
export type CoverageStatus = 'operational' | 'partial' | 'not_configured' | 'unsupported'
export interface CoverageBBox {
minx: number
miny: number
maxx: number
maxy: number
}
export interface CoverageSourceContract {
source_name: string
display_name: string
authority_level: 'authoritative' | 'official_context' | 'contextual'
coverage_zones: string[]
themes: string[]
native_layers: string[]
supported_geometry_types: string[]
acquisition_mode: 'operator_archive' | 'operator_wfs' | 'bounded_api' | 'bounded_raster' | 'catalog_only'
integration_status: CoverageStatus
source_url: string
attribution: string
license_note: string
limitation_message: string
}
export interface CoverageCatalogResponse {
themes: string[]
zones: string[]
statuses: CoverageStatus[]
sources: CoverageSourceContract[]
}
export interface CoverageResolutionItem {
zone: string
theme: string
status: CoverageStatus
source_names: string[]
materialized_dataset_ids: string[]
limitation_message: string
}
export interface CoverageResolveResponse {
project_id: string
bbox: CoverageBBox
requested_themes: string[]
intersected_zones: string[]
outside_supported_scope: boolean
items: CoverageResolutionItem[]
warnings: string[]
}
export interface SystemCapabilitiesResponse {
status: string
service: string