feat: add official Kempen operating scope
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-14 18:32:32 +02:00
parent e9c75b2fd6
commit bb7310e015
17 changed files with 1020 additions and 9 deletions
+3 -3
View File
@@ -2,7 +2,7 @@
React + TypeScript + MapLibre foundation for project/area/dataset workflow.
Mol is the primary operating context. On a fresh session the application opens the map-first geographic explorer, prefers the persisted `Mol Municipality Workbench`, selects the official NIS `13025` municipality boundary and activates the largest available authoritative building layer. Explicit project and dataset selections remain authoritative, and all broader Kempen workflows remain available.
Mol is the primary operating context. On a fresh session the application opens the map-first geographic explorer, prefers the persisted `Mol Municipality Workbench`, selects the official NIS `13025` municipality boundary and activates the largest available authoritative building layer. A compact `Regio` selector switches between this workspace and the explicitly provisioned `Kempen Regional Workbench`, whose scope is the official 28-municipality Vlaamse vervoerregio. Explicit project and dataset selections remain authoritative.
The map-first explorer has two deliberate modes. `Latest state` selects the
latest explicitly dated source snapshot without claiming an old edition is
@@ -21,7 +21,7 @@ estimates clearly marked and land-cover sources show intersected hectares. 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 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 Mol or Kempen, 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 theme catalog currently recognizes buildings, population, forest/green, water, roads and parcels from dataset names and canonical `reference_layer_name` metadata. A theme is enabled only when a ready persisted vector dataset exists; otherwise it states `Bron nog niet ingeladen`. This prevents missing population or land-cover sources from appearing as zero-valued observations. The previous technical Map workspace remains available through `Geavanceerde werkbank` for derived datasets, QA/QC evidence and export operations.
@@ -35,7 +35,7 @@ Wide and ultrawide screens keep a readable sidebar and centered work area, expan
The Map workspace defaults to an OpenStreetMap road basemap with visible attribution so uploaded vectors, AOIs and QA overlays appear on a real street context. Set `VITE_MAP_STYLE_URL` to a managed MapLibre style URL to override this for production or high-volume deployments.
Municipality-scale GeoJSON bounds are scanned incrementally and memoized instead of materializing coordinate arrays. This supports the complete Mol boundary and roughly 37k persisted GRB buildings without JavaScript argument-spread failures. The official boundary uses a dark teal map treatment, GRB buildings use cyan/teal and existing detection/change-result colors remain distinct.
Municipality-scale GeoJSON bounds are scanned incrementally and memoized instead of materializing coordinate arrays. This supports the complete Mol boundary and roughly 37k persisted GRB buildings without JavaScript argument-spread failures. The official boundary uses a dark teal map treatment, while active source layers and their legends use consistent theme colors and existing detection/change-result colors remain distinct.
When the public OpenStreetMap fallback is active, the Map workspace shows a basemap usage notice. This keeps the local/demo default honest and reminds operators to configure a managed style URL before production or heavier tile traffic.
+2
View File
@@ -911,6 +911,7 @@ function App(): JSX.Element {
{activeWorkspace === 'map' ? (
<MapWorkspace
selectedProjectId={selectedProjectId}
projects={projects}
areas={areas}
selectedMapAreaId={selectedMapAreaId}
areaFeatureCollection={areaFeatureCollection}
@@ -957,6 +958,7 @@ function App(): JSX.Element {
selectedMapDatasetId={selectedDataset && isVectorDatasetType(selectedDataset.dataset_type) ? selectedDataset.id : ''}
selectedFeature={selectedMapFeature}
onSelectMapArea={setSelectedMapAreaId}
onSelectProject={selectProject}
onOpenDatasetInMap={openDatasetInMap}
onSetAreaLayerVisible={setAreaLayerVisible}
onSetAreaLayerOpacity={setAreaLayerOpacity}
+43 -4
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import GeoMap from '../GeoMap'
import type { AreaRead, DatasetCreateResponse, MapViewportState, QaComparisonResult, VectorSelectionBBox, VectorSelectionResponse } from '../../types'
import type { AreaRead, DatasetCreateResponse, MapViewportState, ProjectRead, QaComparisonResult, VectorSelectionBBox, VectorSelectionResponse } from '../../types'
import { featureCollectionBounds } from '../../lib/geojsonBounds'
import { useMapThemeSelectionInsights } from '../../hooks/useMapThemeSelectionInsights'
import { useTemporalComparison } from '../../hooks/useTemporalComparison'
@@ -8,6 +8,8 @@ import { useTemporalComparison } from '../../hooks/useTemporalComparison'
const DEFAULT_SELECTED_FEATURE_FILENAME = 'selected-feature.geojson'
const DEFAULT_AREA_SELECTION_FILENAME = 'area-selection.geojson'
const EMPTY_TEMPORAL_SERIES: DatasetCreateResponse[] = []
const MOL_PROJECT_NAME = 'Mol Municipality Workbench'
const KEMPEN_PROJECT_NAME = 'Kempen Regional Workbench'
type DataThemeId = 'buildings' | 'population' | 'forest' | 'water' | 'roads' | 'parcels'
@@ -163,6 +165,16 @@ function formatObservationDate(value: string | null | undefined): string {
return new Intl.DateTimeFormat('nl-BE', { year: 'numeric', month: 'short', day: 'numeric' }).format(new Date(value))
}
function operationalScopeProjectLabel(project: ProjectRead): string {
if (project.name === MOL_PROJECT_NAME) {
return 'Mol'
}
if (project.name === KEMPEN_PROJECT_NAME) {
return 'Kempen (28 gemeenten)'
}
return project.name
}
function selectionAreaSquareMetres(bbox: VectorSelectionBBox | null): number | null {
if (!bbox) {
return null
@@ -384,6 +396,7 @@ function downloadJsonFile(filename: string, payload: unknown): void {
interface MapWorkspaceProps {
selectedProjectId: string | null
projects: ProjectRead[]
areas: AreaRead[]
selectedMapAreaId: string
areaFeatureCollection: GeoJSON.FeatureCollection | null
@@ -430,6 +443,7 @@ interface MapWorkspaceProps {
availableMapDatasets: DatasetCreateResponse[]
selectedMapDatasetId: string
onSelectMapArea: (areaId: string) => void
onSelectProject: (projectId: string) => void
onOpenDatasetInMap: (dataset: DatasetCreateResponse) => void
onSetAreaLayerVisible: (visible: boolean) => void
onSetAreaLayerOpacity: (opacity: number) => void
@@ -451,6 +465,7 @@ interface MapWorkspaceProps {
export function MapWorkspace({
selectedProjectId,
projects,
areas,
selectedMapAreaId,
areaFeatureCollection,
@@ -497,6 +512,7 @@ export function MapWorkspace({
availableMapDatasets,
selectedMapDatasetId,
onSelectMapArea,
onSelectProject,
onOpenDatasetInMap,
onSetAreaLayerVisible,
onSetAreaLayerOpacity,
@@ -574,6 +590,16 @@ export function MapWorkspace({
const activeTheme = DATA_THEMES.find((theme) => theme.id === activeThemeId) ?? DATA_THEMES[0]
const activeThemeMapStyle = DATA_THEME_MAP_STYLES[activeTheme.id]
const activeThemeDataset = themeDatasetMap[activeTheme.id]
const operationalScopeProjects = useMemo(() => {
const scoped = projects.filter((project) => project.name === MOL_PROJECT_NAME || project.name === KEMPEN_PROJECT_NAME)
const selected = projects.find((project) => project.id === selectedProjectId)
if (selected && !scoped.some((project) => project.id === selected.id)) {
return [selected, ...scoped]
}
return scoped
}, [projects, selectedProjectId])
const activeScopeProject = projects.find((project) => project.id === selectedProjectId) ?? null
const activeScopeLabel = activeScopeProject ? operationalScopeProjectLabel(activeScopeProject) : 'Werkgebied'
const activeTemporalSeriesGroups = useMemo(
() => listThemeTemporalSeries(availableMapDatasets, activeTheme),
[activeTheme, availableMapDatasets],
@@ -892,10 +918,10 @@ export function MapWorkspace({
if (!advancedMode) {
return (
<section className="geo-explorer" data-testid="map-workspace" aria-label="Gebiedsverkenner Mol">
<section className="geo-explorer" data-testid="map-workspace" aria-label={`Gebiedsverkenner ${activeScopeLabel}`}>
<header className="geo-explorer-header">
<div>
<p className="eyebrow">Mol · geografische verkenner</p>
<p className="eyebrow">{activeScopeLabel} · geografische verkenner</p>
<h2>Wat bevindt zich in dit gebied?</h2>
<p>Kies een datathema, teken een rechthoek en lees de beschikbare gegevens meteen uit.</p>
</div>
@@ -933,6 +959,19 @@ export function MapWorkspace({
<p>Dit zijn databronnen, geen AI-modellen.</p>
</div>
</div>
<label className="geo-project-scope-select">
Regio
<select
aria-label="Regio"
value={selectedProjectId ?? ''}
onChange={(event) => onSelectProject(event.target.value)}
disabled={operationalScopeProjects.length < 2}
>
{operationalScopeProjects.map((project) => (
<option key={project.id} value={project.id}>{operationalScopeProjectLabel(project)}</option>
))}
</select>
</label>
<div className="geo-theme-list">
{DATA_THEMES.map((theme) => {
const dataset = themeDatasetMap[theme.id]
@@ -1055,7 +1094,7 @@ export function MapWorkspace({
type="button"
onClick={() => selectedAreaBbox && void analyzeSelection(selectedAreaBbox)}
>
Volledige gemeente
Volledig werkgebied
</button>
<button className="secondary-action" disabled={!mapSelectionBbox} type="button" onClick={clearAreaSelection}>
Wis selectie
+14
View File
@@ -5542,6 +5542,20 @@ section {
gap: 0.36rem;
}
.geo-project-scope-select {
display: grid;
gap: 0.24rem;
color: #4c5b56;
font-size: 0.66rem;
font-weight: 800;
}
.geo-project-scope-select select {
min-height: 2.25rem;
padding: 0.35rem 0.45rem;
font-size: 0.72rem;
}
.geo-theme-option {
display: grid;
grid-template-columns: 0.72rem minmax(0, 1fr) auto;