feat: make Mol explorer map-first
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 13:21:53 +02:00
parent e340b7f1ec
commit 2958c25b6f
24 changed files with 1901 additions and 14 deletions
+2 -1
View File
@@ -54,7 +54,7 @@ const workspaceNavGroups: Array<{ label: string; keys: WorkspaceKey[] }> = [
]
function App(): JSX.Element {
const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceKey>('overview')
const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceKey>('map')
const [inspectorOpen, setInspectorOpen] = useState(false)
const [mapContentMode, setMapContentMode] = useState<'dataset' | 'analysis'>('dataset')
useEffect(() => {
@@ -910,6 +910,7 @@ function App(): JSX.Element {
{activeWorkspace === 'map' ? (
<MapWorkspace
selectedProjectId={selectedProjectId}
areas={areas}
selectedMapAreaId={selectedMapAreaId}
areaFeatureCollection={areaFeatureCollection}
+60 -1
View File
@@ -3,7 +3,7 @@ import maplibregl from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import { PRIMARY_FOCUS_CENTER } from '../config/primaryFocus'
import { featureCollectionBounds } from '../lib/geojsonBounds'
import type { MapViewportState } from '../types'
import type { MapViewportState, VectorSelectionBBox } from '../types'
interface GeoMapProps {
data: GeoJSON.FeatureCollection | null
@@ -20,6 +20,8 @@ interface GeoMapProps {
fitDataOnChange?: boolean
onFeatureSelect?: (feature: GeoJSON.Feature | null) => void
onMapCoordinateSelect?: (coordinate: [number, number]) => void
onMapBboxPreview?: (bbox: VectorSelectionBBox) => void
onMapBboxSelect?: (bbox: VectorSelectionBBox) => void
onViewportChange?: (viewport: MapViewportState) => void
}
@@ -114,12 +116,16 @@ function GeoMap({
fitDataOnChange = true,
onFeatureSelect,
onMapCoordinateSelect,
onMapBboxPreview,
onMapBboxSelect,
onViewportChange,
}: GeoMapProps): JSX.Element {
const containerRef = useRef<HTMLDivElement | null>(null)
const mapRef = useRef<maplibregl.Map | null>(null)
const onFeatureSelectRef = useRef<GeoMapProps['onFeatureSelect']>(onFeatureSelect)
const onMapCoordinateSelectRef = useRef<GeoMapProps['onMapCoordinateSelect']>(onMapCoordinateSelect)
const onMapBboxPreviewRef = useRef<GeoMapProps['onMapBboxPreview']>(onMapBboxPreview)
const onMapBboxSelectRef = useRef<GeoMapProps['onMapBboxSelect']>(onMapBboxSelect)
const onViewportChangeRef = useRef<GeoMapProps['onViewportChange']>(onViewportChange)
const bboxSelectionModeRef = useRef(bboxSelectionMode)
const lastFittedAreaRef = useRef<GeoJSON.FeatureCollection | null>(null)
@@ -133,6 +139,14 @@ function GeoMap({
onMapCoordinateSelectRef.current = onMapCoordinateSelect
}, [onMapCoordinateSelect])
useEffect(() => {
onMapBboxPreviewRef.current = onMapBboxPreview
}, [onMapBboxPreview])
useEffect(() => {
onMapBboxSelectRef.current = onMapBboxSelect
}, [onMapBboxSelect])
useEffect(() => {
onViewportChangeRef.current = onViewportChange
}, [onViewportChange])
@@ -141,6 +155,11 @@ function GeoMap({
bboxSelectionModeRef.current = bboxSelectionMode
if (mapRef.current) {
mapRef.current.getCanvas().style.cursor = bboxSelectionMode ? 'crosshair' : ''
if (bboxSelectionMode) {
mapRef.current.dragPan.disable()
} else {
mapRef.current.dragPan.enable()
}
}
}, [bboxSelectionMode])
@@ -181,8 +200,48 @@ function GeoMap({
emitViewport()
})
map.on('moveend', emitViewport)
let dragStart: [number, number] | null = null
let draggedSelection = false
const bboxFromCoordinates = (start: [number, number], end: [number, number]): VectorSelectionBBox => ({
min_x: Math.min(start[0], end[0]),
min_y: Math.min(start[1], end[1]),
max_x: Math.max(start[0], end[0]),
max_y: Math.max(start[1], end[1]),
crs: 'EPSG:4326',
})
map.on('mousedown', (event) => {
if (!bboxSelectionModeRef.current || event.originalEvent.button !== 0) {
return
}
event.preventDefault()
dragStart = [event.lngLat.lng, event.lngLat.lat]
draggedSelection = false
map.getCanvas().style.cursor = 'crosshair'
})
map.on('mousemove', (event) => {
if (!bboxSelectionModeRef.current || !dragStart) {
return
}
draggedSelection = true
onMapBboxPreviewRef.current?.(bboxFromCoordinates(dragStart, [event.lngLat.lng, event.lngLat.lat]))
})
map.on('mouseup', (event) => {
if (!bboxSelectionModeRef.current || !dragStart) {
return
}
const start = dragStart
dragStart = null
const bbox = bboxFromCoordinates(start, [event.lngLat.lng, event.lngLat.lat])
if (bbox.max_x > bbox.min_x && bbox.max_y > bbox.min_y) {
onMapBboxSelectRef.current?.(bbox)
}
})
map.on('click', (event) => {
if (bboxSelectionModeRef.current) {
if (draggedSelection) {
draggedSelection = false
return
}
onMapCoordinateSelectRef.current?.([event.lngLat.lng, event.lngLat.lat])
return
}
+469 -2
View File
@@ -2,10 +2,129 @@ import { useEffect, useMemo, useState } from 'react'
import GeoMap from '../GeoMap'
import type { AreaRead, DatasetCreateResponse, MapViewportState, QaComparisonResult, VectorSelectionBBox, VectorSelectionResponse } from '../../types'
import { featureCollectionBounds } from '../../lib/geojsonBounds'
import { useMapThemeSelectionInsights } from '../../hooks/useMapThemeSelectionInsights'
const DEFAULT_SELECTED_FEATURE_FILENAME = 'selected-feature.geojson'
const DEFAULT_AREA_SELECTION_FILENAME = 'area-selection.geojson'
type DataThemeId = 'buildings' | 'population' | 'forest' | 'water' | 'roads' | 'parcels'
interface DataTheme {
id: DataThemeId
label: string
shortLabel: string
description: string
tokens: string[]
}
const DATA_THEMES: DataTheme[] = [
{
id: 'buildings',
label: 'Bebouwing',
shortLabel: 'Gebouwen',
description: 'Gebouwen en gebouwcontouren uit GRB of een andere persistente bron.',
tokens: ['buildings', 'building', 'gebouwen', 'gebouw', 'bebouwing', 'gbg'],
},
{
id: 'population',
label: 'Bevolking',
shortLabel: 'Inwoners',
description: 'Bevolkingscijfers of statistische raster- en vectorzones.',
tokens: ['population', 'bevolking', 'inwoners', 'inhabitants', 'census'],
},
{
id: 'forest',
label: 'Bos & groen',
shortLabel: 'Bos',
description: 'Bos, natuur en groenbedekking uit een ingeladen vectorbron.',
tokens: ['forest', 'forestry', 'woodland', 'bos', 'groen', 'vegetation'],
},
{
id: 'water',
label: 'Water',
shortLabel: 'Water',
description: 'Waterlopen, grachten, kanalen en wateroppervlakken.',
tokens: ['waterways', 'waterway', 'water', 'hydro', 'river', 'stream', 'canal', 'waterloop'],
},
{
id: 'roads',
label: 'Wegen',
shortLabel: 'Wegen',
description: 'Wegen en wegsegmenten uit een persistente bron.',
tokens: ['roads', 'road', 'wegen', 'wegsegment', 'street'],
},
{
id: 'parcels',
label: 'Percelen',
shortLabel: 'Percelen',
description: 'Kadastrale of administratieve perceelcontouren.',
tokens: ['parcels', 'parcel', 'percelen', 'perceel', 'cadastre', 'kadaster'],
},
]
function datasetSearchText(dataset: DatasetCreateResponse): string {
return [
dataset.name,
dataset.original_filename,
dataset.source,
dataset.source_name,
dataset.reference_layer_name,
dataset.metadata_json?.['layer_name'],
dataset.source_metadata?.['layer_name'],
dataset.source_metadata?.['theme'],
]
.filter(Boolean)
.join(' ')
.toLowerCase()
}
function datasetMatchesTheme(dataset: DatasetCreateResponse, theme: DataTheme): boolean {
const searchText = datasetSearchText(dataset)
return theme.tokens.some((token) => searchText.includes(token))
}
function pickThemeDataset(datasets: DatasetCreateResponse[], theme: DataTheme): DatasetCreateResponse | null {
const candidates = datasets.filter((dataset) => datasetMatchesTheme(dataset, theme))
candidates.sort((left, right) => {
const score = (dataset: DatasetCreateResponse) =>
(dataset.reference_layer_name && theme.tokens.includes(dataset.reference_layer_name.toLowerCase()) ? 1_000_000 : 0) +
(dataset.source_name === 'grb' ? 100_000 : 0) +
(dataset.dataset_role === 'reference' ? 10_000 : 0) +
(dataset.feature_count ?? dataset.vector_summary?.feature_count ?? 0)
return score(right) - score(left)
})
return candidates[0] ?? null
}
function selectionAreaSquareMetres(bbox: VectorSelectionBBox | null): number | null {
if (!bbox) {
return null
}
const middleLatitudeRadians = ((bbox.min_y + bbox.max_y) / 2) * (Math.PI / 180)
const widthMetres = (bbox.max_x - bbox.min_x) * 111_320 * Math.cos(middleLatitudeRadians)
const heightMetres = (bbox.max_y - bbox.min_y) * 110_574
return Math.max(0, widthMetres * heightMetres)
}
function formatArea(areaSquareMetres: number | null): string {
if (areaSquareMetres === null) {
return 'Nog niet geselecteerd'
}
if (areaSquareMetres >= 1_000_000) {
return `${(areaSquareMetres / 1_000_000).toLocaleString('nl-BE', { maximumFractionDigits: 2 })} km2`
}
return `${(areaSquareMetres / 10_000).toLocaleString('nl-BE', { maximumFractionDigits: 2 })} ha`
}
function resultCountLabel(result: VectorSelectionResponse): string {
const total = result.total_feature_count ?? result.feature_count
return result.truncated && result.total_feature_count == null ? `${result.feature_count.toLocaleString('nl-BE')}+` : total.toLocaleString('nl-BE')
}
function readablePropertyName(value: string): string {
return value.replace(/_/g, ' ').replace(/\b\w/g, (character) => character.toUpperCase())
}
function collectGeometryPoints(geometry: GeoJSON.Geometry | null | undefined): Array<[number, number]> {
const points: Array<[number, number]> = []
const walk = (coords: unknown) => {
@@ -171,6 +290,7 @@ function downloadJsonFile(filename: string, payload: unknown): void {
}
interface MapWorkspaceProps {
selectedProjectId: string | null
areas: AreaRead[]
selectedMapAreaId: string
areaFeatureCollection: GeoJSON.FeatureCollection | null
@@ -237,6 +357,7 @@ interface MapWorkspaceProps {
}
export function MapWorkspace({
selectedProjectId,
areas,
selectedMapAreaId,
areaFeatureCollection,
@@ -301,6 +422,15 @@ export function MapWorkspace({
onOpenMapSelectionQualityEvidence,
onClearQualityEvidence,
}: MapWorkspaceProps): JSX.Element {
const [advancedMode, setAdvancedMode] = useState(false)
const [activeThemeId, setActiveThemeId] = useState<DataThemeId>('buildings')
const {
themeInsights,
themeInsightsLoading: themeResultsLoading,
themeInsightsError: themeResultsError,
loadThemeInsights,
clearThemeInsights,
} = useMapThemeSelectionInsights<DataThemeId>(selectedProjectId)
const [bboxSelectionMode, setBboxSelectionMode] = useState(false)
const [firstSelectionCorner, setFirstSelectionCorner] = useState<[number, number] | null>(null)
const [bboxInput, setBboxInput] = useState(bboxToInputState(mapSelectionBbox))
@@ -330,11 +460,69 @@ export function MapWorkspace({
const selectedFeatureFilename = selectedFeatureStem === 'selected-feature' ? DEFAULT_SELECTED_FEATURE_FILENAME : `${selectedFeatureStem}.geojson`
const selectedMapDataset = availableMapDatasets.find((dataset) => dataset.id === selectedMapDatasetId) ?? null
const usesDefaultOsmBasemap = !import.meta.env.VITE_MAP_STYLE_URL
const themeDatasetMap = useMemo(
() =>
Object.fromEntries(
DATA_THEMES.map((theme) => [theme.id, pickThemeDataset(availableMapDatasets, theme)]),
) as Record<DataThemeId, DatasetCreateResponse | null>,
[availableMapDatasets],
)
const activeTheme = DATA_THEMES.find((theme) => theme.id === activeThemeId) ?? DATA_THEMES[0]
const activeThemeDataset = themeDatasetMap[activeTheme.id]
const selectedAreaSquareMetres = useMemo(() => selectionAreaSquareMetres(mapSelectionBbox), [mapSelectionBbox])
const selectedResultTotal = mapSelectionResult?.total_feature_count ?? mapSelectionResult?.feature_count ?? 0
const selectedDensity = selectedAreaSquareMetres && selectedAreaSquareMetres > 0
? selectedResultTotal / (selectedAreaSquareMetres / 1_000_000)
: null
const selectedResultProperties = useMemo(() => {
const keys = new Map<string, Set<string>>()
for (const feature of mapSelectionResult?.geojson.features ?? []) {
for (const [key, value] of Object.entries(feature.properties ?? {})) {
if (value === null || value === undefined || typeof value === 'object' || key.endsWith('_id')) {
continue
}
const values = keys.get(key) ?? new Set<string>()
if (values.size < 4) {
values.add(String(value))
}
keys.set(key, values)
}
}
return Array.from(keys.entries())
.filter(([, values]) => values.size > 0)
.slice(0, 8)
.map(([key, values]) => ({ key, values: Array.from(values) }))
}, [mapSelectionResult])
const themeResults = useMemo(
() =>
themeInsights.flatMap((insight) => {
const theme = DATA_THEMES.find((candidate) => candidate.id === insight.themeId)
return theme ? [{ theme, dataset: insight.dataset, result: insight.result }] : []
}),
[themeInsights],
)
useEffect(() => {
setBboxInput(bboxToInputState(mapSelectionBbox))
}, [mapSelectionBbox])
useEffect(() => {
if (selectedMapDatasetId || !themeDatasetMap.buildings) {
return
}
onOpenDatasetInMap(themeDatasetMap.buildings)
}, [onOpenDatasetInMap, selectedMapDatasetId, themeDatasetMap.buildings])
useEffect(() => {
if (!selectedMapDataset) {
return
}
const matchingTheme = DATA_THEMES.find((theme) => datasetMatchesTheme(selectedMapDataset, theme))
if (matchingTheme) {
setActiveThemeId(matchingTheme.id)
}
}, [selectedMapDataset])
const downloadSelectedMapFeature = () => {
if (!selectedFeatureGeoJson) {
return
@@ -353,6 +541,7 @@ export function MapWorkspace({
const startBboxSelection = () => {
setFirstSelectionCorner(null)
clearThemeInsights()
setBboxSelectionMode(true)
}
@@ -365,6 +554,7 @@ export function MapWorkspace({
setSelectionBbox(bbox)
setFirstSelectionCorner(null)
setBboxSelectionMode(false)
void analyzeSelection(bbox)
}
const runAreaExtract = () => {
@@ -372,13 +562,14 @@ export function MapWorkspace({
if (!bbox) {
return
}
onRunMapSelectionExtract(bbox)
void analyzeSelection(bbox)
}
const clearAreaSelection = () => {
setBboxSelectionMode(false)
setFirstSelectionCorner(null)
setBboxInput(bboxToInputState(null))
clearThemeInsights()
onClearMapSelectionExtract()
}
@@ -416,13 +607,45 @@ export function MapWorkspace({
}
}
const selectDataTheme = (theme: DataTheme) => {
const dataset = themeDatasetMap[theme.id]
if (!dataset) {
return
}
setActiveThemeId(theme.id)
onOpenDatasetInMap(dataset)
}
const loadAllThemeResults = async (bbox: VectorSelectionBBox) => {
const availableThemes = DATA_THEMES.flatMap((theme) => {
const dataset = themeDatasetMap[theme.id]
return dataset ? [{ themeId: theme.id, dataset }] : []
})
await loadThemeInsights(bbox, availableThemes)
}
const analyzeSelection = async (bbox: VectorSelectionBBox) => {
setSelectionBbox(bbox)
await Promise.all([onRunMapSelectionExtract(bbox), loadAllThemeResults(bbox)])
}
const handleMapBboxPreview = (bbox: VectorSelectionBBox) => {
setSelectionBbox(bbox)
}
const handleMapBboxSelect = (bbox: VectorSelectionBBox) => {
setFirstSelectionCorner(null)
setBboxSelectionMode(false)
void analyzeSelection(bbox)
}
const runQuickAoiExtract = () => {
const bbox = selectedAreaBbox ?? activeLayerBbox
if (!bbox) {
return
}
setSelectionBbox(bbox)
onRunMapSelectionExtract(bbox)
void analyzeSelection(bbox)
}
const runFullGisWorkflow = async () => {
@@ -494,8 +717,252 @@ export function MapWorkspace({
}
}
if (!advancedMode) {
return (
<section className="geo-explorer" data-testid="map-workspace" aria-label="Gebiedsverkenner Mol">
<header className="geo-explorer-header">
<div>
<p className="eyebrow">Mol · 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>
<button className="secondary-action geo-explorer-advanced" type="button" onClick={() => setAdvancedMode(true)}>
Geavanceerde werkbank
</button>
</header>
<div className="geo-explorer-layout">
<aside className="geo-theme-panel" aria-label="Datathema kiezen">
<div className="geo-panel-heading">
<span>1</span>
<div>
<h3>Kies een datathema</h3>
<p>Dit zijn databronnen, geen AI-modellen.</p>
</div>
</div>
<div className="geo-theme-list">
{DATA_THEMES.map((theme) => {
const dataset = themeDatasetMap[theme.id]
const active = activeThemeId === theme.id
return (
<button
className={active ? 'geo-theme-option geo-theme-option-active' : 'geo-theme-option'}
disabled={!dataset}
key={theme.id}
type="button"
onClick={() => selectDataTheme(theme)}
aria-pressed={active}
>
<span className={`geo-theme-symbol geo-theme-symbol-${theme.id}`} aria-hidden="true" />
<span>
<strong>{theme.label}</strong>
<small>{dataset ? `${(dataset.feature_count ?? dataset.vector_summary?.feature_count ?? 0).toLocaleString('nl-BE')} objecten beschikbaar` : 'Bron nog niet ingeladen'}</small>
</span>
<i>{dataset ? 'Beschikbaar' : 'Ontbreekt'}</i>
</button>
)
})}
</div>
<div className="geo-source-summary">
<span>Actieve bron</span>
<strong>{activeThemeDataset?.name ?? 'Geen databron beschikbaar'}</strong>
<small>
{activeThemeDataset
? `${activeThemeDataset.source_name ?? activeThemeDataset.source} · ${activeThemeDataset.dataset_role ?? 'source'} · EPSG:4326`
: activeTheme.description}
</small>
</div>
<label className="geo-scope-select">
Werkgebied
<select value={selectedMapAreaId} onChange={(event) => onSelectMapArea(event.target.value)} disabled={areas.length === 0}>
<option value="">Geen werkgebied</option>
{areas.map((area) => (
<option key={area.id} value={area.id}>{area.name}</option>
))}
</select>
</label>
</aside>
<div className="geo-map-stage">
<div className="geo-map-toolbar" aria-label="Gebied selecteren">
<div className="geo-panel-heading geo-map-step">
<span>2</span>
<div>
<h3>Selecteer een gebied</h3>
<p>{bboxSelectionMode ? 'Sleep nu een rechthoek op de kaart.' : 'Sleep een rechthoek of analyseer de volledige gemeente.'}</p>
</div>
</div>
<div className="geo-map-actions">
<button
className={bboxSelectionMode ? 'primary-action geo-draw-active' : 'primary-action'}
disabled={!activeThemeDataset || mapSelectionLoading || themeResultsLoading}
type="button"
onClick={startBboxSelection}
>
{bboxSelectionMode ? 'Teken op de kaart…' : 'Teken rechthoek'}
</button>
<button
className="secondary-action"
disabled={!activeThemeDataset || !selectedAreaBbox || mapSelectionLoading || themeResultsLoading}
type="button"
onClick={() => selectedAreaBbox && void analyzeSelection(selectedAreaBbox)}
>
Volledige gemeente
</button>
<button className="secondary-action" disabled={!mapSelectionBbox} type="button" onClick={clearAreaSelection}>
Wis selectie
</button>
</div>
</div>
<div className={bboxSelectionMode ? 'geo-map-canvas geo-map-canvas-drawing' : 'geo-map-canvas'}>
<GeoMap
data={mapFeatureCollection}
areaData={areaFeatureCollection}
selectedFeature={selectedFeature}
selectionData={mapSelectionResult?.geojson ?? null}
selectionBbox={mapSelectionBbox}
bboxSelectionMode={bboxSelectionMode}
visible={mapLayerVisible}
opacity={mapLayerOpacity}
areaVisible={areaLayerVisible}
areaOpacity={areaLayerOpacity}
fitDataOnChange={fitMapDataOnChange}
onFeatureSelect={onSelectMapFeature}
onMapCoordinateSelect={handleMapCoordinateSelect}
onMapBboxPreview={handleMapBboxPreview}
onMapBboxSelect={handleMapBboxSelect}
onViewportChange={onMapViewportChange}
/>
<div className="geo-map-legend" aria-label="Kaartlegende">
<span><i className="geo-legend-area" /> Gemeentegrens</span>
<span><i className="geo-legend-layer" /> {activeTheme.shortLabel}</span>
<span><i className="geo-legend-selection" /> Selectie</span>
</div>
{bboxSelectionMode ? (
<div className="geo-draw-instruction" role="status">
<strong>Rechthoek tekenen</strong>
<span>Houd de linkermuisknop ingedrukt, sleep over het gewenste gebied en laat los.</span>
</div>
) : null}
{viewportVectorEnabled && viewportVectorStatus ? (
<div className={`geo-viewport-status geo-viewport-status-${viewportVectorTone}`} role={viewportVectorTone === 'error' ? 'alert' : 'status'}>
{viewportVectorStatus}
</div>
) : null}
</div>
</div>
<aside className="geo-results-panel" aria-label="Gebiedsanalyse">
<div className="geo-panel-heading">
<span>3</span>
<div>
<h3>Resultaten</h3>
<p>Alleen gemeten gegevens uit beschikbare bronnen.</p>
</div>
</div>
{!mapSelectionBbox ? (
<div className="geo-results-empty">
<strong>Nog geen gebied geselecteerd</strong>
<p>Teken een rechthoek op de kaart. De analyse start automatisch zodra je loslaat.</p>
</div>
) : mapSelectionLoading || themeResultsLoading ? (
<div className="geo-results-loading" role="status">
<span />
<strong>Gegevens worden uit PostGIS gelezen</strong>
</div>
) : (
<>
<div className="geo-primary-metrics">
<div>
<span>Oppervlakte selectie</span>
<strong>{formatArea(selectedAreaSquareMetres)}</strong>
</div>
<div>
<span>{activeTheme.shortLabel}</span>
<strong>{mapSelectionResult ? resultCountLabel(mapSelectionResult) : 'Geen resultaat'}</strong>
</div>
<div>
<span>Dichtheid</span>
<strong>{selectedDensity === null ? 'n.v.t.' : `${selectedDensity.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} / km2`}</strong>
</div>
</div>
<div className="geo-theme-results">
<div className="geo-results-title-row">
<h4>Alle beschikbare themas</h4>
<span>{themeResults.length} bevraagd</span>
</div>
{DATA_THEMES.map((theme) => {
const dataset = themeDatasetMap[theme.id]
const item = themeResults.find((result) => result.theme.id === theme.id)
return (
<div className="geo-theme-result-row" key={theme.id}>
<span className={`geo-theme-symbol geo-theme-symbol-${theme.id}`} aria-hidden="true" />
<span>
<strong>{theme.label}</strong>
<small>{dataset?.source_name ?? dataset?.source ?? 'Geen bron gekoppeld'}</small>
</span>
<b>{item ? resultCountLabel(item.result) : dataset ? 'Niet bevraagd' : 'Bron ontbreekt'}</b>
</div>
)
})}
</div>
{mapSelectionResult?.truncated ? (
<p className="geo-data-notice">De telling is volledig; op de kaart en in de tabel worden maximaal {mapSelectionResult.limit.toLocaleString('nl-BE')} objecten getoond.</p>
) : null}
{mapSelectionError ? <p className="error">{mapSelectionError}</p> : null}
{themeResultsError ? <p className="error">{themeResultsError}</p> : null}
{selectedResultProperties.length > 0 ? (
<details className="geo-result-details">
<summary>Kenmerken van de gevonden objecten</summary>
<dl>
{selectedResultProperties.map(({ key, values }) => (
<div key={key}>
<dt>{readablePropertyName(key)}</dt>
<dd>{values.join(', ')}</dd>
</div>
))}
</dl>
</details>
) : null}
{selectedMapFeature ? (
<div className="geo-selected-feature">
<span>Geselecteerd object</span>
<strong>{String(selectedMapFeature.properties?.['name'] ?? selectedMapFeature.properties?.['source_feature_id'] ?? selectedMapFeature.id ?? 'Object')}</strong>
<small>Klik elders op de kaart om een ander object uit te lezen.</small>
</div>
) : null}
<div className="geo-result-actions">
<button className="secondary-action" disabled={!mapSelectionResult} type="button" onClick={downloadAreaSelection}>Download GeoJSON</button>
<button className="secondary-action" disabled={!mapSelectionResult} type="button" onClick={copyAreaSelection}>Kopieer gegevens</button>
</div>
</>
)}
</aside>
</div>
<footer className="geo-explorer-footer">
<span><strong>Werkgebied:</strong> {selectedMapArea?.name ?? 'Geen gemeentegrens geselecteerd'}</span>
<span><strong>Bron:</strong> {activeThemeDataset ? `${activeThemeDataset.source_name ?? activeThemeDataset.source} · ${activeThemeDataset.name}` : 'niet beschikbaar'}</span>
{usesDefaultOsmBasemap ? <span><strong>Ondergrond:</strong> OpenStreetMap</span> : null}
</footer>
</section>
)
}
return (
<section className="map-workspace-shell" data-testid="map-workspace">
<button className="secondary-action" type="button" onClick={() => setAdvancedMode(false)}>
Terug naar gebiedsverkenner
</button>
<div className="panel-title-row">
<div>
<p className="eyebrow">Spatial review</p>
+4 -1
View File
@@ -21,6 +21,9 @@ export function useMapSelectionExtract({
useEffect(() => {
setMapSelectionBbox(null)
}, [selectedProjectId])
useEffect(() => {
setMapSelectionResult(null)
setMapSelectionError(null)
}, [selectedProjectId, selectedDataset?.id])
@@ -41,7 +44,7 @@ export function useMapSelectionExtract({
try {
const response = await datasetsApi.selectVectorFeatures(selectedProjectId, selectedDataset.id, {
bbox: { ...bbox, crs: 'EPSG:4326' },
limit: 250,
limit: 1000,
})
setMapSelectionResult(response)
return response
@@ -0,0 +1,80 @@
import { useEffect, useState } from 'react'
import { formatError } from '../lib/formatError'
import { datasetsApi } from '../services/api/datasets'
import type { DatasetCreateResponse, VectorSelectionBBox, VectorSelectionResponse } from '../types'
export interface MapThemeQuery<TThemeId extends string> {
themeId: TThemeId
dataset: DatasetCreateResponse
}
export interface MapThemeInsight<TThemeId extends string> extends MapThemeQuery<TThemeId> {
result: VectorSelectionResponse
}
export function useMapThemeSelectionInsights<TThemeId extends string>(
selectedProjectId: string | null,
) {
const [themeInsights, setThemeInsights] = useState<Array<MapThemeInsight<TThemeId>>>([])
const [themeInsightsLoading, setThemeInsightsLoading] = useState(false)
const [themeInsightsError, setThemeInsightsError] = useState<string | null>(null)
useEffect(() => {
setThemeInsights([])
setThemeInsightsError(null)
}, [selectedProjectId])
const clearThemeInsights = () => {
setThemeInsights([])
setThemeInsightsError(null)
}
const loadThemeInsights = async (
bbox: VectorSelectionBBox,
queries: Array<MapThemeQuery<TThemeId>>,
): Promise<Array<MapThemeInsight<TThemeId>>> => {
if (!selectedProjectId) {
setThemeInsights([])
setThemeInsightsError('Open eerst een project om de selectie te analyseren.')
return []
}
setThemeInsightsLoading(true)
setThemeInsightsError(null)
try {
const settled = await Promise.allSettled(
queries.map(async ({ themeId, dataset }) => ({
themeId,
dataset,
result: await datasetsApi.selectVectorFeatures(selectedProjectId, dataset.id, {
bbox,
limit: 1000,
}),
})),
)
const successful = settled.flatMap((item) => (item.status === 'fulfilled' ? [item.value] : []))
const failureCount = settled.length - successful.length
setThemeInsights(successful)
if (failureCount > 0) {
setThemeInsightsError(
`${failureCount} beschikbare databron${failureCount === 1 ? '' : 'nen'} kon niet worden bevraagd.`,
)
}
return successful
} catch (error) {
setThemeInsights([])
setThemeInsightsError(formatError(error, 'De gebiedsanalyse is mislukt.'))
return []
} finally {
setThemeInsightsLoading(false)
}
}
return {
themeInsights,
themeInsightsLoading,
themeInsightsError,
loadThemeInsights,
clearThemeInsights,
}
}
+11 -1
View File
@@ -31,7 +31,17 @@ export function useMapWorkspaceState({
if (areas.length === 0) {
setSelectedMapAreaId('')
} else if (!selectedMapAreaId || !areas.some((area) => area.id === selectedMapAreaId)) {
setSelectedMapAreaId(areas[0].id)
const municipalityArea = areas.find((area) => /gemeente mol|municipality/i.test(area.name))
if (municipalityArea) {
setSelectedMapAreaId(municipalityArea.id)
} else {
const largestArea = [...areas].sort((left, right) => (right.area_m2 ?? 0) - (left.area_m2 ?? 0))[0]
if (largestArea?.area_m2) {
setSelectedMapAreaId(largestArea.id)
} else {
setSelectedMapAreaId(areas[0].id)
}
}
}
}, [areas, selectedMapAreaId])
+693
View File
@@ -5412,6 +5412,699 @@ section {
}
}
/* Map-first geographic explorer: one calm path from theme to selection to evidence. */
.geo-explorer {
display: grid;
gap: 0.75rem;
min-width: 0;
}
.geo-explorer-header {
display: flex;
gap: 1rem;
align-items: end;
justify-content: space-between;
min-width: 0;
border-bottom: 1px solid #d7e0dc;
padding: 0 0 0.75rem;
}
.geo-explorer-header h2 {
margin: 0.12rem 0 0;
color: #17211e;
font-size: clamp(1.35rem, 2vw, 1.85rem);
letter-spacing: 0;
line-height: 1.12;
}
.geo-explorer-header p:last-child {
max-width: 48rem;
margin: 0.32rem 0 0;
color: #5a6964;
font-size: 0.88rem;
}
.geo-explorer-advanced {
flex: 0 0 auto;
}
.geo-explorer-layout {
display: grid;
grid-template-columns: minmax(15.5rem, 17rem) minmax(30rem, 1fr) minmax(18rem, 20rem);
gap: 0.72rem;
align-items: stretch;
min-width: 0;
min-height: calc(100dvh - 13.8rem);
}
.geo-theme-panel,
.geo-results-panel,
.geo-map-stage {
min-width: 0;
border: 1px solid #d7e0dc;
border-radius: 7px;
background: #ffffff;
box-shadow: 0 1px 2px rgba(23, 33, 30, 0.05);
}
.geo-theme-panel,
.geo-results-panel {
display: flex;
flex-direction: column;
gap: 0.7rem;
padding: 0.78rem;
}
.geo-panel-heading {
display: grid;
grid-template-columns: 1.65rem minmax(0, 1fr);
gap: 0.55rem;
align-items: start;
}
.geo-panel-heading > span {
display: grid;
place-items: center;
width: 1.65rem;
height: 1.65rem;
border-radius: 5px;
background: #173e38;
color: #ffffff;
font-size: 0.76rem;
font-weight: 850;
}
.geo-panel-heading h3,
.geo-results-title-row h4 {
margin: 0;
color: #17211e;
font-size: 0.9rem;
letter-spacing: 0;
line-height: 1.2;
}
.geo-panel-heading p {
margin: 0.16rem 0 0;
color: #6a7773;
font-size: 0.72rem;
line-height: 1.35;
}
.geo-theme-list {
display: grid;
gap: 0.36rem;
}
.geo-theme-option {
display: grid;
grid-template-columns: 0.72rem minmax(0, 1fr) auto;
gap: 0.48rem;
align-items: center;
width: 100%;
min-height: 3.25rem;
border: 1px solid #e0e7e4;
border-radius: 6px;
padding: 0.48rem 0.52rem;
background: #ffffff;
color: #26332f;
text-align: left;
}
.geo-theme-option:not(:disabled):hover {
border-color: #8eb8ac;
background: #f5faf8;
}
.geo-theme-option-active {
border-color: #397c6e;
background: #edf7f4;
box-shadow: inset 3px 0 0 #176a5c;
}
.geo-theme-option:disabled {
cursor: not-allowed;
background: #f6f8f7;
color: #7c8884;
opacity: 0.72;
}
.geo-theme-option > span:nth-child(2) {
display: grid;
min-width: 0;
}
.geo-theme-option strong,
.geo-theme-option small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.geo-theme-option strong {
font-size: 0.79rem;
}
.geo-theme-option small {
margin-top: 0.12rem;
color: #65736e;
font-size: 0.66rem;
}
.geo-theme-option i {
color: #357061;
font-size: 0.58rem;
font-style: normal;
font-weight: 800;
text-transform: uppercase;
}
.geo-theme-option:disabled i {
color: #89938f;
}
.geo-theme-symbol {
display: block;
width: 0.6rem;
height: 1.85rem;
border-radius: 2px;
background: #5c7c73;
}
.geo-theme-symbol-buildings { background: #d45f3d; }
.geo-theme-symbol-population { background: #7559a6; }
.geo-theme-symbol-forest { background: #347950; }
.geo-theme-symbol-water { background: #2676a8; }
.geo-theme-symbol-roads { background: #6b7280; }
.geo-theme-symbol-parcels { background: #a7792f; }
.geo-source-summary {
display: grid;
gap: 0.18rem;
border-top: 1px solid #e3e9e6;
padding-top: 0.65rem;
}
.geo-source-summary span,
.geo-primary-metrics span,
.geo-selected-feature > span {
color: #6a7773;
font-size: 0.62rem;
font-weight: 800;
text-transform: uppercase;
}
.geo-source-summary strong {
overflow: hidden;
color: #26332f;
font-size: 0.76rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.geo-source-summary small {
color: #697671;
font-size: 0.68rem;
line-height: 1.35;
}
.geo-scope-select {
margin-top: auto;
color: #4c5b56;
font-size: 0.7rem;
font-weight: 750;
}
.geo-scope-select select {
min-height: 2.35rem;
margin-top: 0.28rem;
}
.geo-map-stage {
display: grid;
grid-template-rows: auto minmax(30rem, 1fr);
overflow: hidden;
}
.geo-map-toolbar {
display: flex;
gap: 0.7rem;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #d7e0dc;
padding: 0.62rem 0.7rem;
background: #fbfcfc;
}
.geo-map-step {
flex: 1 1 auto;
}
.geo-map-actions {
display: flex;
flex: 0 0 auto;
gap: 0.35rem;
}
.geo-map-actions button,
.geo-result-actions button {
min-height: 2.15rem;
padding: 0.4rem 0.58rem;
font-size: 0.72rem;
}
.geo-draw-active {
background: #9a4d24;
}
.geo-map-canvas {
position: relative;
min-height: 0;
background: #e7ece9;
}
.geo-map-canvas .map-container {
width: 100%;
height: 100%;
min-height: 30rem;
border: 0;
border-radius: 0;
}
.geo-map-canvas-drawing .map-container {
box-shadow: inset 0 0 0 3px #b5572d;
}
.geo-map-legend {
position: absolute;
z-index: 2;
right: 0.65rem;
bottom: 0.65rem;
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
align-items: center;
border: 1px solid rgba(23, 33, 30, 0.18);
border-radius: 5px;
padding: 0.38rem 0.5rem;
background: rgba(255, 255, 255, 0.94);
color: #42504b;
font-size: 0.64rem;
box-shadow: 0 2px 8px rgba(23, 33, 30, 0.1);
}
.geo-map-legend span {
display: inline-flex;
gap: 0.3rem;
align-items: center;
}
.geo-map-legend i {
display: inline-block;
width: 0.9rem;
height: 0.55rem;
border: 2px solid #176a5c;
background: rgba(23, 106, 92, 0.15);
}
.geo-map-legend .geo-legend-layer {
border-color: #d45f3d;
background: rgba(212, 95, 61, 0.24);
}
.geo-map-legend .geo-legend-selection {
border-color: #6b4aaa;
background: rgba(107, 74, 170, 0.18);
}
.geo-draw-instruction,
.geo-viewport-status {
position: absolute;
z-index: 3;
left: 50%;
display: grid;
width: min(31rem, calc(100% - 2rem));
transform: translateX(-50%);
border-radius: 6px;
padding: 0.58rem 0.72rem;
box-shadow: 0 4px 16px rgba(23, 33, 30, 0.18);
}
.geo-draw-instruction {
top: 0.72rem;
border: 1px solid #d8936c;
background: rgba(255, 248, 242, 0.96);
color: #6f3218;
}
.geo-draw-instruction strong,
.geo-draw-instruction span {
font-size: 0.75rem;
}
.geo-viewport-status {
bottom: 2.8rem;
border: 1px solid #b7ccc5;
background: rgba(248, 252, 250, 0.95);
color: #38534b;
font-size: 0.68rem;
text-align: center;
}
.geo-viewport-status-error {
border-color: #e3a6a6;
background: rgba(255, 246, 246, 0.96);
color: #8b2d2d;
}
.geo-results-empty,
.geo-results-loading {
display: grid;
place-content: center;
flex: 1 1 auto;
min-height: 15rem;
border: 1px dashed #ccd7d3;
border-radius: 6px;
padding: 1rem;
background: #fafcfa;
text-align: center;
}
.geo-results-empty strong,
.geo-results-loading strong {
color: #31413b;
font-size: 0.82rem;
}
.geo-results-empty p {
max-width: 15rem;
margin: 0.35rem auto 0;
color: #6a7773;
font-size: 0.72rem;
line-height: 1.45;
}
.geo-results-loading span {
width: 1.4rem;
height: 1.4rem;
margin: 0 auto 0.6rem;
border: 2px solid #c6d6d0;
border-top-color: #176a5c;
border-radius: 50%;
animation: geo-spin 0.8s linear infinite;
}
@keyframes geo-spin {
to { transform: rotate(360deg); }
}
.geo-primary-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.35rem;
}
.geo-primary-metrics > div {
display: grid;
gap: 0.18rem;
min-width: 0;
border: 1px solid #e1e8e5;
border-radius: 5px;
padding: 0.48rem;
background: #f8faf9;
}
.geo-primary-metrics strong {
overflow: hidden;
color: #1e302a;
font-size: 0.86rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.geo-theme-results {
display: grid;
gap: 0;
border: 1px solid #e1e8e5;
border-radius: 6px;
overflow: hidden;
}
.geo-results-title-row {
display: flex;
justify-content: space-between;
padding: 0.48rem 0.55rem;
background: #f4f7f5;
}
.geo-results-title-row span {
color: #697671;
font-size: 0.65rem;
}
.geo-theme-result-row {
display: grid;
grid-template-columns: 0.55rem minmax(0, 1fr) auto;
gap: 0.45rem;
align-items: center;
min-height: 2.65rem;
border-top: 1px solid #e7ecea;
padding: 0.38rem 0.5rem;
}
.geo-theme-result-row .geo-theme-symbol {
width: 0.45rem;
height: 1.5rem;
}
.geo-theme-result-row > span:nth-child(2) {
display: grid;
min-width: 0;
}
.geo-theme-result-row strong {
font-size: 0.73rem;
}
.geo-theme-result-row small {
overflow: hidden;
color: #71807a;
font-size: 0.61rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.geo-theme-result-row b {
color: #24342e;
font-size: 0.7rem;
text-align: right;
}
.geo-data-notice {
margin: 0;
border-left: 3px solid #b17a31;
padding: 0.45rem 0.55rem;
background: #fffbeb;
color: #76501d;
font-size: 0.68rem;
line-height: 1.4;
}
.geo-result-details {
border: 1px solid #e1e8e5;
border-radius: 6px;
}
.geo-result-details summary {
padding: 0.5rem 0.55rem;
color: #40514b;
cursor: pointer;
font-size: 0.72rem;
font-weight: 750;
}
.geo-result-details dl {
display: grid;
gap: 0;
margin: 0;
border-top: 1px solid #e7ecea;
}
.geo-result-details dl > div {
display: grid;
grid-template-columns: minmax(6rem, 0.8fr) minmax(0, 1.2fr);
gap: 0.5rem;
border-top: 1px solid #edf1ef;
padding: 0.36rem 0.5rem;
}
.geo-result-details dl > div:first-child {
border-top: 0;
}
.geo-result-details dt,
.geo-result-details dd {
overflow-wrap: anywhere;
font-size: 0.65rem;
}
.geo-result-details dt {
color: #66736f;
}
.geo-result-details dd {
margin: 0;
color: #283832;
}
.geo-selected-feature {
display: grid;
gap: 0.16rem;
border-left: 3px solid #a7792f;
padding: 0.48rem 0.55rem;
background: #fffaf0;
}
.geo-selected-feature strong {
overflow-wrap: anywhere;
color: #4d3d22;
font-size: 0.75rem;
}
.geo-selected-feature small {
color: #786a51;
font-size: 0.65rem;
}
.geo-result-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.35rem;
margin-top: auto;
}
.geo-explorer-footer {
display: flex;
flex-wrap: wrap;
gap: 0.4rem 1rem;
align-items: center;
color: #687570;
font-size: 0.68rem;
}
.geo-explorer-footer strong {
color: #45534f;
}
@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);
}
.geo-map-canvas .map-container {
min-height: 38rem;
}
}
@media (max-width: 1320px) {
.geo-explorer-layout {
grid-template-columns: minmax(14rem, 16rem) minmax(28rem, 1fr);
}
.geo-results-panel {
grid-column: 1 / -1;
display: grid;
grid-template-columns: minmax(12rem, 0.6fr) repeat(2, minmax(15rem, 1fr));
align-items: start;
}
.geo-results-panel > .geo-panel-heading,
.geo-results-panel > .error,
.geo-results-panel > .geo-data-notice,
.geo-results-panel > .geo-result-actions {
grid-column: 1 / -1;
}
.geo-results-empty,
.geo-results-loading {
grid-column: 2 / -1;
min-height: 9rem;
}
}
@media (max-width: 920px) {
.geo-explorer-header {
align-items: start;
}
.geo-explorer-layout {
display: block;
min-height: 0;
}
.geo-theme-panel,
.geo-map-stage,
.geo-results-panel {
margin-bottom: 0.65rem;
}
.geo-theme-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.geo-map-toolbar {
align-items: start;
}
.geo-map-actions {
flex-wrap: wrap;
justify-content: flex-end;
}
.geo-results-panel {
display: flex;
}
}
@media (max-width: 620px) {
.geo-explorer-header {
display: grid;
}
.geo-explorer-advanced {
width: 100%;
}
.geo-theme-list,
.geo-primary-metrics {
grid-template-columns: 1fr;
}
.geo-map-toolbar {
display: grid;
}
.geo-map-actions,
.geo-result-actions {
display: grid;
grid-template-columns: 1fr;
}
.geo-map-actions button {
width: 100%;
}
.geo-map-canvas .map-container {
min-height: 24rem;
}
.geo-map-legend {
right: 0.4rem;
bottom: 0.4rem;
left: 0.4rem;
}
}
@media (max-width: 1180px) {
.workspace-grid-data,
.map-inspection-surface {
+1
View File
@@ -309,6 +309,7 @@ export interface VectorSelectionDeriveRequest extends VectorSelectionRequest {
export interface VectorSelectionResponse {
selection_bbox: VectorSelectionBBox
feature_count: number
total_feature_count?: number | null
limit: number
truncated: boolean
geojson: GeoJSON.FeatureCollection