feat: make Mol explorer map-first
This commit is contained in:
@@ -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 thema’s</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>
|
||||
|
||||
Reference in New Issue
Block a user