feat: add temporal Mol explorer
This commit is contained in:
@@ -4,6 +4,19 @@ 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.
|
||||
|
||||
The map-first explorer has two deliberate modes. `Latest state` selects the
|
||||
latest explicitly dated source snapshot without claiming an old edition is
|
||||
current, while `Evolution` lets the operator compare an earlier and later
|
||||
snapshot from the same series over a drawn rectangle. Results show units,
|
||||
absolute/percentage change, estimate status and source limitations.
|
||||
Added/removed/modified overlays only appear for stable source identities.
|
||||
|
||||
Selection results use dataset-specific PostGIS summaries. Object layers show
|
||||
intersecting counts, population shows inhabitants with partial-sector
|
||||
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 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.
|
||||
|
||||
@@ -319,6 +319,8 @@ function GeoMap({
|
||||
'#16a34a',
|
||||
'removed',
|
||||
'#dc2626',
|
||||
'modified',
|
||||
'#d97706',
|
||||
'unchanged',
|
||||
'#2563eb',
|
||||
'#f97316',
|
||||
@@ -345,6 +347,8 @@ function GeoMap({
|
||||
'#15803d',
|
||||
'removed',
|
||||
'#b91c1c',
|
||||
'modified',
|
||||
'#b45309',
|
||||
'unchanged',
|
||||
'#1d4ed8',
|
||||
'#ea580c',
|
||||
|
||||
@@ -3,6 +3,7 @@ import GeoMap from '../GeoMap'
|
||||
import type { AreaRead, DatasetCreateResponse, MapViewportState, QaComparisonResult, VectorSelectionBBox, VectorSelectionResponse } from '../../types'
|
||||
import { featureCollectionBounds } from '../../lib/geojsonBounds'
|
||||
import { useMapThemeSelectionInsights } from '../../hooks/useMapThemeSelectionInsights'
|
||||
import { useTemporalComparison } from '../../hooks/useTemporalComparison'
|
||||
|
||||
const DEFAULT_SELECTED_FEATURE_FILENAME = 'selected-feature.geojson'
|
||||
const DEFAULT_AREA_SELECTION_FILENAME = 'area-selection.geojson'
|
||||
@@ -90,12 +91,42 @@ function pickThemeDataset(datasets: DatasetCreateResponse[], theme: DataTheme):
|
||||
(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.observed_at ? new Date(dataset.observed_at).getTime() / 100_000_000 : 0) +
|
||||
(dataset.feature_count ?? dataset.vector_summary?.feature_count ?? 0)
|
||||
return score(right) - score(left)
|
||||
})
|
||||
return candidates[0] ?? null
|
||||
}
|
||||
|
||||
function pickThemeTemporalSeries(datasets: DatasetCreateResponse[], theme: DataTheme): DatasetCreateResponse[] {
|
||||
const groups = new Map<string, DatasetCreateResponse[]>()
|
||||
for (const dataset of datasets) {
|
||||
if (!datasetMatchesTheme(dataset, theme) || !dataset.temporal_series_key || !dataset.observed_at) {
|
||||
continue
|
||||
}
|
||||
const items = groups.get(dataset.temporal_series_key) ?? []
|
||||
items.push(dataset)
|
||||
groups.set(dataset.temporal_series_key, items)
|
||||
}
|
||||
return Array.from(groups.values())
|
||||
.filter((items) => items.length >= 2)
|
||||
.sort((left, right) => {
|
||||
if (right.length !== left.length) {
|
||||
return right.length - left.length
|
||||
}
|
||||
const latest = (items: DatasetCreateResponse[]) => Math.max(...items.map((item) => new Date(item.observed_at ?? 0).getTime()))
|
||||
return latest(right) - latest(left)
|
||||
})[0]
|
||||
?.sort((left, right) => new Date(left.observed_at ?? 0).getTime() - new Date(right.observed_at ?? 0).getTime()) ?? []
|
||||
}
|
||||
|
||||
function formatObservationDate(value: string | null | undefined): string {
|
||||
if (!value) {
|
||||
return 'Geen peildatum'
|
||||
}
|
||||
return new Intl.DateTimeFormat('nl-BE', { year: 'numeric', month: 'short', day: 'numeric' }).format(new Date(value))
|
||||
}
|
||||
|
||||
function selectionAreaSquareMetres(bbox: VectorSelectionBBox | null): number | null {
|
||||
if (!bbox) {
|
||||
return null
|
||||
@@ -134,6 +165,19 @@ function resultCountLabel(result: VectorSelectionResponse): string {
|
||||
return result.truncated && result.total_feature_count == null ? `${result.feature_count.toLocaleString('nl-BE')}+` : total.toLocaleString('nl-BE')
|
||||
}
|
||||
|
||||
function resultMetricLabel(result: VectorSelectionResponse): string {
|
||||
if (!result.summary) {
|
||||
return resultCountLabel(result)
|
||||
}
|
||||
const maximumFractionDigits = result.summary.metric_unit === 'inwoners' ? 0 : 2
|
||||
return `${result.summary.metric_value.toLocaleString('nl-BE', { maximumFractionDigits })} ${result.summary.metric_unit}`
|
||||
}
|
||||
|
||||
function formatTemporalMetric(value: number, unit: string): string {
|
||||
const maximumFractionDigits = unit === 'inwoners' || unit === 'objecten' ? 0 : 2
|
||||
return `${value.toLocaleString('nl-BE', { maximumFractionDigits })} ${unit}`
|
||||
}
|
||||
|
||||
function readablePropertyName(value: string): string {
|
||||
return value.replace(/_/g, ' ').replace(/\b\w/g, (character) => character.toUpperCase())
|
||||
}
|
||||
@@ -444,6 +488,16 @@ export function MapWorkspace({
|
||||
loadThemeInsights,
|
||||
clearThemeInsights,
|
||||
} = useMapThemeSelectionInsights<DataThemeId>(selectedProjectId)
|
||||
const {
|
||||
temporalComparison,
|
||||
temporalComparisonLoading,
|
||||
temporalComparisonError,
|
||||
compareTemporalSnapshots,
|
||||
clearTemporalComparison,
|
||||
} = useTemporalComparison(selectedProjectId)
|
||||
const [analysisMode, setAnalysisMode] = useState<'current' | 'evolution'>('current')
|
||||
const [earlierDatasetId, setEarlierDatasetId] = useState('')
|
||||
const [laterDatasetId, setLaterDatasetId] = useState('')
|
||||
const [bboxSelectionMode, setBboxSelectionMode] = useState(false)
|
||||
const [firstSelectionCorner, setFirstSelectionCorner] = useState<[number, number] | null>(null)
|
||||
const [bboxInput, setBboxInput] = useState(bboxToInputState(mapSelectionBbox))
|
||||
@@ -482,6 +536,10 @@ export function MapWorkspace({
|
||||
)
|
||||
const activeTheme = DATA_THEMES.find((theme) => theme.id === activeThemeId) ?? DATA_THEMES[0]
|
||||
const activeThemeDataset = themeDatasetMap[activeTheme.id]
|
||||
const activeTemporalSeries = useMemo(
|
||||
() => pickThemeTemporalSeries(availableMapDatasets, activeTheme),
|
||||
[activeTheme, availableMapDatasets],
|
||||
)
|
||||
const themeResults = useMemo(
|
||||
() =>
|
||||
themeInsights.flatMap((insight) => {
|
||||
@@ -502,6 +560,14 @@ export function MapWorkspace({
|
||||
const selectedDensity = selectedAreaSquareMetres && selectedAreaSquareMetres > 0
|
||||
? selectedResultTotal / (selectedAreaSquareMetres / 1_000_000)
|
||||
: null
|
||||
const activeMetricValue = activeSelectionResult?.summary?.metric_value ?? selectedResultTotal
|
||||
const activeMetricUnit = activeSelectionResult?.summary?.metric_unit ?? 'objecten'
|
||||
const activeMetricLabel = activeSelectionResult?.summary?.metric_label ?? activeTheme.shortLabel
|
||||
const activeSecondaryMetric = selectedAreaSquareMetres && selectedAreaSquareMetres > 0
|
||||
? activeMetricUnit === 'ha'
|
||||
? `${((activeMetricValue * 10_000) / selectedAreaSquareMetres * 100).toLocaleString('nl-BE', { maximumFractionDigits: 1 })}% dekking`
|
||||
: `${(activeMetricValue / (selectedAreaSquareMetres / 1_000_000)).toLocaleString('nl-BE', { maximumFractionDigits: 1 })} ${activeMetricUnit} / km2`
|
||||
: null
|
||||
const selectedResultProperties = useMemo(() => {
|
||||
const keys = new Map<string, Set<string>>()
|
||||
for (const feature of activeSelectionResult?.geojson.features ?? []) {
|
||||
@@ -526,6 +592,14 @@ export function MapWorkspace({
|
||||
setBboxInput(bboxToInputState(mapSelectionBbox))
|
||||
}, [mapSelectionBbox])
|
||||
|
||||
useEffect(() => {
|
||||
const first = activeTemporalSeries[0]
|
||||
const last = activeTemporalSeries[activeTemporalSeries.length - 1]
|
||||
setEarlierDatasetId(first?.id ?? '')
|
||||
setLaterDatasetId(last?.id ?? '')
|
||||
clearTemporalComparison()
|
||||
}, [activeTemporalSeries])
|
||||
|
||||
useEffect(() => {
|
||||
if (advancedMode || !activeThemeDataset || (selectedMapDataset && datasetMatchesTheme(selectedMapDataset, activeTheme))) {
|
||||
return
|
||||
@@ -562,6 +636,7 @@ export function MapWorkspace({
|
||||
const startBboxSelection = () => {
|
||||
setFirstSelectionCorner(null)
|
||||
clearThemeInsights()
|
||||
clearTemporalComparison()
|
||||
setBboxSelectionMode(true)
|
||||
}
|
||||
|
||||
@@ -590,6 +665,7 @@ export function MapWorkspace({
|
||||
setFirstSelectionCorner(null)
|
||||
setBboxInput(bboxToInputState(null))
|
||||
clearThemeInsights()
|
||||
clearTemporalComparison()
|
||||
onClearMapSelectionExtract()
|
||||
}
|
||||
|
||||
@@ -644,9 +720,15 @@ export function MapWorkspace({
|
||||
return
|
||||
}
|
||||
setActiveThemeId(theme.id)
|
||||
clearTemporalComparison()
|
||||
onOpenDatasetInMap(dataset)
|
||||
}
|
||||
|
||||
const setExplorerMode = (mode: 'current' | 'evolution') => {
|
||||
setAnalysisMode(mode)
|
||||
clearTemporalComparison()
|
||||
}
|
||||
|
||||
const loadAllThemeResults = async (bbox: VectorSelectionBBox) => {
|
||||
const availableThemes = DATA_THEMES.flatMap((theme) => {
|
||||
const dataset = themeDatasetMap[theme.id]
|
||||
@@ -657,7 +739,18 @@ export function MapWorkspace({
|
||||
|
||||
const analyzeSelection = async (bbox: VectorSelectionBBox) => {
|
||||
setSelectionBbox(bbox)
|
||||
await Promise.all([onRunMapSelectionExtract(bbox), loadAllThemeResults(bbox)])
|
||||
const tasks: Array<Promise<unknown>> = [onRunMapSelectionExtract(bbox), loadAllThemeResults(bbox)]
|
||||
if (analysisMode === 'evolution' && earlierDatasetId && laterDatasetId) {
|
||||
tasks.push(compareTemporalSnapshots(earlierDatasetId, laterDatasetId, bbox))
|
||||
}
|
||||
await Promise.all(tasks)
|
||||
}
|
||||
|
||||
const runTemporalComparison = () => {
|
||||
if (!mapSelectionBbox || !earlierDatasetId || !laterDatasetId) {
|
||||
return
|
||||
}
|
||||
void compareTemporalSnapshots(earlierDatasetId, laterDatasetId, mapSelectionBbox)
|
||||
}
|
||||
|
||||
const handleMapBboxPreview = (bbox: VectorSelectionBBox) => {
|
||||
@@ -757,6 +850,26 @@ export function MapWorkspace({
|
||||
<h2>Wat bevindt zich in dit gebied?</h2>
|
||||
<p>Kies een datathema, teken een rechthoek en lees de beschikbare gegevens meteen uit.</p>
|
||||
</div>
|
||||
<div className="geo-analysis-mode" role="tablist" aria-label="Analyseperiode">
|
||||
<button
|
||||
className={analysisMode === 'current' ? 'active' : ''}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={analysisMode === 'current'}
|
||||
onClick={() => setExplorerMode('current')}
|
||||
>
|
||||
Laatste toestand
|
||||
</button>
|
||||
<button
|
||||
className={analysisMode === 'evolution' ? 'active' : ''}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={analysisMode === 'evolution'}
|
||||
onClick={() => setExplorerMode('evolution')}
|
||||
>
|
||||
Evolutie
|
||||
</button>
|
||||
</div>
|
||||
<button className="secondary-action geo-explorer-advanced" type="button" onClick={() => setAdvancedMode(true)}>
|
||||
Geavanceerde werkbank
|
||||
</button>
|
||||
@@ -796,15 +909,52 @@ export function MapWorkspace({
|
||||
</div>
|
||||
|
||||
<div className="geo-source-summary">
|
||||
<span>Actieve bron</span>
|
||||
<strong>{activeThemeDataset?.name ?? 'Geen databron beschikbaar'}</strong>
|
||||
<span>{analysisMode === 'evolution' ? 'Tijdreeks' : 'Actieve bron'}</span>
|
||||
<strong>
|
||||
{analysisMode === 'evolution'
|
||||
? activeTemporalSeries[0]?.temporal_series_key ?? 'Geen tijdreeks beschikbaar'
|
||||
: activeThemeDataset?.name ?? 'Geen databron beschikbaar'}
|
||||
</strong>
|
||||
<small>
|
||||
{activeThemeDataset
|
||||
? `${activeThemeDataset.source_name ?? activeThemeDataset.source} · ${activeThemeDataset.dataset_role ?? 'source'} · EPSG:4326`
|
||||
: activeTheme.description}
|
||||
{analysisMode === 'evolution'
|
||||
? activeTemporalSeries.length >= 2
|
||||
? `${activeTemporalSeries.length} officiële meetmomenten · ${formatObservationDate(activeTemporalSeries[0].observed_at)} tot ${formatObservationDate(activeTemporalSeries[activeTemporalSeries.length - 1].observed_at)}`
|
||||
: 'Minstens twee expliciet gedateerde snapshots zijn vereist.'
|
||||
: activeThemeDataset
|
||||
? `${activeThemeDataset.source_name ?? activeThemeDataset.source} · ${activeThemeDataset.dataset_role ?? 'source'} · ${formatObservationDate(activeThemeDataset.observed_at)}`
|
||||
: activeTheme.description}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
{analysisMode === 'evolution' ? (
|
||||
<div className="geo-time-controls" aria-label="Meetmomenten vergelijken">
|
||||
<label>
|
||||
Van
|
||||
<select value={earlierDatasetId} onChange={(event) => { setEarlierDatasetId(event.target.value); clearTemporalComparison() }} disabled={activeTemporalSeries.length < 2}>
|
||||
{activeTemporalSeries.map((dataset) => (
|
||||
<option key={dataset.id} value={dataset.id}>{formatObservationDate(dataset.observed_at)}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Naar
|
||||
<select value={laterDatasetId} onChange={(event) => { setLaterDatasetId(event.target.value); clearTemporalComparison() }} disabled={activeTemporalSeries.length < 2}>
|
||||
{activeTemporalSeries.map((dataset) => (
|
||||
<option key={dataset.id} value={dataset.id}>{formatObservationDate(dataset.observed_at)}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
className="primary-action"
|
||||
type="button"
|
||||
disabled={!mapSelectionBbox || !earlierDatasetId || !laterDatasetId || temporalComparisonLoading}
|
||||
onClick={runTemporalComparison}
|
||||
>
|
||||
{temporalComparisonLoading ? 'Vergelijken…' : 'Vergelijk periode'}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<label className="geo-scope-select">
|
||||
Werkgebied
|
||||
<select value={selectedMapAreaId} onChange={(event) => onSelectMapArea(event.target.value)} disabled={areas.length === 0}>
|
||||
@@ -828,7 +978,7 @@ export function MapWorkspace({
|
||||
<div className="geo-map-actions">
|
||||
<button
|
||||
className={bboxSelectionMode ? 'primary-action geo-draw-active' : 'primary-action'}
|
||||
disabled={!activeThemeDataset || mapSelectionLoading || themeResultsLoading}
|
||||
disabled={!activeThemeDataset || (analysisMode === 'evolution' && activeTemporalSeries.length < 2) || mapSelectionLoading || themeResultsLoading}
|
||||
type="button"
|
||||
onClick={startBboxSelection}
|
||||
>
|
||||
@@ -836,7 +986,7 @@ export function MapWorkspace({
|
||||
</button>
|
||||
<button
|
||||
className="secondary-action"
|
||||
disabled={!activeThemeDataset || !selectedAreaBbox || mapSelectionLoading || themeResultsLoading}
|
||||
disabled={!activeThemeDataset || (analysisMode === 'evolution' && activeTemporalSeries.length < 2) || !selectedAreaBbox || mapSelectionLoading || themeResultsLoading}
|
||||
type="button"
|
||||
onClick={() => selectedAreaBbox && void analyzeSelection(selectedAreaBbox)}
|
||||
>
|
||||
@@ -850,10 +1000,10 @@ export function MapWorkspace({
|
||||
|
||||
<div className={bboxSelectionMode ? 'geo-map-canvas geo-map-canvas-drawing' : 'geo-map-canvas'}>
|
||||
<GeoMap
|
||||
data={mapFeatureCollection}
|
||||
data={analysisMode === 'evolution' && temporalComparison?.geojson.features.length ? temporalComparison.geojson : mapFeatureCollection}
|
||||
areaData={areaFeatureCollection}
|
||||
selectedFeature={selectedFeature}
|
||||
selectionData={mapSelectionResult?.geojson ?? null}
|
||||
selectionData={analysisMode === 'current' ? mapSelectionResult?.geojson ?? null : null}
|
||||
selectionBbox={mapSelectionBbox}
|
||||
bboxSelectionMode={bboxSelectionMode}
|
||||
visible={mapLayerVisible}
|
||||
@@ -869,8 +1019,18 @@ export function MapWorkspace({
|
||||
/>
|
||||
<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>
|
||||
{analysisMode === 'evolution' && temporalComparison?.object_changes.available ? (
|
||||
<>
|
||||
<span><i className="geo-legend-added" /> Nieuw</span>
|
||||
<span><i className="geo-legend-removed" /> Verdwenen</span>
|
||||
<span><i className="geo-legend-modified" /> Gewijzigd</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">
|
||||
@@ -900,56 +1060,117 @@ export function MapWorkspace({
|
||||
<strong>Nog geen gebied geselecteerd</strong>
|
||||
<p>Teken een rechthoek op de kaart. De analyse start automatisch zodra je loslaat.</p>
|
||||
</div>
|
||||
) : mapSelectionLoading || themeResultsLoading ? (
|
||||
) : mapSelectionLoading || themeResultsLoading || temporalComparisonLoading ? (
|
||||
<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>{activeSelectionResult ? resultCountLabel(activeSelectionResult) : '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>
|
||||
{analysisMode === 'evolution' ? (
|
||||
temporalComparison ? (
|
||||
<>
|
||||
<div className="geo-primary-metrics geo-temporal-metrics">
|
||||
<div>
|
||||
<span>{formatObservationDate(temporalComparison.earlier.observed_at)}</span>
|
||||
<strong>{formatTemporalMetric(temporalComparison.metric.earlier_value, temporalComparison.metric.unit)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{formatObservationDate(temporalComparison.later.observed_at)}</span>
|
||||
<strong>{formatTemporalMetric(temporalComparison.metric.later_value, temporalComparison.metric.unit)}</strong>
|
||||
</div>
|
||||
<div className={temporalComparison.metric.absolute_change >= 0 ? 'positive' : 'negative'}>
|
||||
<span>Verschil</span>
|
||||
<strong>
|
||||
{temporalComparison.metric.absolute_change >= 0 ? '+' : ''}
|
||||
{formatTemporalMetric(temporalComparison.metric.absolute_change, temporalComparison.metric.unit)}
|
||||
</strong>
|
||||
<small>
|
||||
{temporalComparison.metric.percent_change == null
|
||||
? 'geen percentage bij nulwaarde'
|
||||
: `${temporalComparison.metric.percent_change >= 0 ? '+' : ''}${temporalComparison.metric.percent_change.toLocaleString('nl-BE', { maximumFractionDigits: 1 })}%`}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="geo-temporal-summary">
|
||||
<div>
|
||||
<span>Gebied</span>
|
||||
<strong>{formatArea(selectedAreaSquareMetres)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Meting</span>
|
||||
<strong>{temporalComparison.metric.label}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Methode</span>
|
||||
<strong>{temporalComparison.metric.is_estimate ? 'Ruimtelijke schatting' : 'Exact'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
{temporalComparison.object_changes.available ? (
|
||||
<div className="geo-change-counts" aria-label="Objectwijzigingen">
|
||||
<span><strong>{temporalComparison.object_changes.added_count ?? 0}</strong> nieuw</span>
|
||||
<span><strong>{temporalComparison.object_changes.removed_count ?? 0}</strong> verdwenen</span>
|
||||
<span><strong>{temporalComparison.object_changes.modified_count ?? 0}</strong> gewijzigd</span>
|
||||
</div>
|
||||
) : null}
|
||||
{temporalComparison.warnings.map((warning) => (
|
||||
<p className="geo-data-notice" key={warning}>{warning}</p>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="geo-results-empty">
|
||||
<strong>Klaar om te vergelijken</strong>
|
||||
<p>Kies twee meetmomenten en gebruik “Vergelijk periode”. Bij een nieuwe rechthoek wordt de vergelijking automatisch herhaald.</p>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<div className="geo-primary-metrics">
|
||||
<div>
|
||||
<span>Oppervlakte selectie</span>
|
||||
<strong>{formatArea(selectedAreaSquareMetres)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{activeMetricLabel}</span>
|
||||
<strong>{activeSelectionResult ? resultMetricLabel(activeSelectionResult) : 'Geen resultaat'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{activeMetricUnit === 'ha' ? 'Aandeel selectie' : 'Dichtheid'}</span>
|
||||
<strong>{activeSecondaryMetric ?? (selectedDensity === null ? 'n.v.t.' : `${selectedDensity.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} / km2`)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeSelectionResult?.truncated ? (
|
||||
<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 ? resultMetricLabel(item.result) : dataset ? 'Niet bevraagd' : 'Bron ontbreekt'}</b>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{analysisMode === 'current' && activeSelectionResult?.truncated ? (
|
||||
<p className="geo-data-notice">De telling is volledig; op de kaart en in de tabel worden maximaal {activeSelectionResult.limit.toLocaleString('nl-BE')} objecten getoond.</p>
|
||||
) : null}
|
||||
{mapSelectionError ? <p className="error">{mapSelectionError}</p> : null}
|
||||
{themeResultsError ? <p className="error">{themeResultsError}</p> : null}
|
||||
{temporalComparisonError ? <p className="error">{temporalComparisonError}</p> : null}
|
||||
|
||||
{selectedResultProperties.length > 0 ? (
|
||||
{analysisMode === 'current' && selectedResultProperties.length > 0 ? (
|
||||
<details className="geo-result-details">
|
||||
<summary>Kenmerken van de gevonden objecten</summary>
|
||||
<dl>
|
||||
@@ -963,7 +1184,7 @@ export function MapWorkspace({
|
||||
</details>
|
||||
) : null}
|
||||
|
||||
{selectedMapFeature ? (
|
||||
{analysisMode === 'current' && selectedMapFeature ? (
|
||||
<div className="geo-selected-feature">
|
||||
<span>Geselecteerd object</span>
|
||||
<strong>{String(selectedMapFeature.properties?.['name'] ?? selectedMapFeature.properties?.['source_feature_id'] ?? selectedMapFeature.id ?? 'Object')}</strong>
|
||||
@@ -971,10 +1192,12 @@ export function MapWorkspace({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="geo-result-actions">
|
||||
<button className="secondary-action" disabled={!activeSelectionResult} type="button" onClick={downloadActiveThemeSelection}>Download GeoJSON</button>
|
||||
<button className="secondary-action" disabled={!activeSelectionResult} type="button" onClick={copyActiveThemeSelection}>Kopieer gegevens</button>
|
||||
</div>
|
||||
{analysisMode === 'current' ? (
|
||||
<div className="geo-result-actions">
|
||||
<button className="secondary-action" disabled={!activeSelectionResult} type="button" onClick={downloadActiveThemeSelection}>Download GeoJSON</button>
|
||||
<button className="secondary-action" disabled={!activeSelectionResult} type="button" onClick={copyActiveThemeSelection}>Kopieer gegevens</button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
@@ -982,7 +1205,14 @@ export function MapWorkspace({
|
||||
|
||||
<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>
|
||||
<span>
|
||||
<strong>Bron:</strong>{' '}
|
||||
{analysisMode === 'evolution'
|
||||
? activeTemporalSeries[0]?.temporal_series_key ?? 'geen vergelijkbare tijdreeks'
|
||||
: activeThemeDataset
|
||||
? `${activeThemeDataset.source_name ?? activeThemeDataset.source} · ${activeThemeDataset.name}`
|
||||
: 'niet beschikbaar'}
|
||||
</span>
|
||||
{usesDefaultOsmBasemap ? <span><strong>Ondergrond:</strong> OpenStreetMap</span> : null}
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { formatError } from '../lib/formatError'
|
||||
import { temporalApi } from '../services/api/temporal'
|
||||
import type { TemporalComparisonResponse, VectorSelectionBBox } from '../types'
|
||||
|
||||
export function useTemporalComparison(selectedProjectId: string | null) {
|
||||
const [temporalComparison, setTemporalComparison] = useState<TemporalComparisonResponse | null>(null)
|
||||
const [temporalComparisonLoading, setTemporalComparisonLoading] = useState(false)
|
||||
const [temporalComparisonError, setTemporalComparisonError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setTemporalComparison(null)
|
||||
setTemporalComparisonError(null)
|
||||
}, [selectedProjectId])
|
||||
|
||||
const clearTemporalComparison = () => {
|
||||
setTemporalComparison(null)
|
||||
setTemporalComparisonError(null)
|
||||
}
|
||||
|
||||
const compareTemporalSnapshots = async (
|
||||
earlierDatasetId: string,
|
||||
laterDatasetId: string,
|
||||
bbox: VectorSelectionBBox,
|
||||
): Promise<TemporalComparisonResponse | null> => {
|
||||
if (!selectedProjectId) {
|
||||
setTemporalComparisonError('Open eerst een project om evoluties te vergelijken.')
|
||||
return null
|
||||
}
|
||||
if (!earlierDatasetId || !laterDatasetId) {
|
||||
setTemporalComparisonError('Kies twee meetmomenten uit dezelfde tijdreeks.')
|
||||
return null
|
||||
}
|
||||
|
||||
setTemporalComparisonLoading(true)
|
||||
setTemporalComparisonError(null)
|
||||
try {
|
||||
const result = await temporalApi.compare(selectedProjectId, {
|
||||
earlier_dataset_id: earlierDatasetId,
|
||||
later_dataset_id: laterDatasetId,
|
||||
bbox,
|
||||
preview_limit: 500,
|
||||
})
|
||||
setTemporalComparison(result)
|
||||
return result
|
||||
} catch (error) {
|
||||
setTemporalComparison(null)
|
||||
setTemporalComparisonError(formatError(error, 'De evolutieanalyse is mislukt.'))
|
||||
return null
|
||||
} finally {
|
||||
setTemporalComparisonLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
temporalComparison,
|
||||
temporalComparisonLoading,
|
||||
temporalComparisonError,
|
||||
compareTemporalSnapshots,
|
||||
clearTemporalComparison,
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,12 @@ export const datasetsApi = {
|
||||
sourceMetadataJson?: string
|
||||
provenanceMetadataJson?: string
|
||||
areaId?: string
|
||||
temporalSeriesKey?: string
|
||||
observedAt?: string
|
||||
validFrom?: string
|
||||
validTo?: string
|
||||
temporalGranularity?: string
|
||||
sourceVersion?: string
|
||||
},
|
||||
): Promise<DatasetCreateResponse> => {
|
||||
const form = new FormData()
|
||||
@@ -55,6 +61,24 @@ export const datasetsApi = {
|
||||
if (payload.areaId) {
|
||||
form.append('area_id', payload.areaId)
|
||||
}
|
||||
if (payload.temporalSeriesKey) {
|
||||
form.append('temporal_series_key', payload.temporalSeriesKey)
|
||||
}
|
||||
if (payload.observedAt) {
|
||||
form.append('observed_at', payload.observedAt)
|
||||
}
|
||||
if (payload.validFrom) {
|
||||
form.append('valid_from', payload.validFrom)
|
||||
}
|
||||
if (payload.validTo) {
|
||||
form.append('valid_to', payload.validTo)
|
||||
}
|
||||
if (payload.temporalGranularity) {
|
||||
form.append('temporal_granularity', payload.temporalGranularity)
|
||||
}
|
||||
if (payload.sourceVersion) {
|
||||
form.append('source_version', payload.sourceVersion)
|
||||
}
|
||||
return apiMultipart<DatasetCreateResponse>(`/api/v1/projects/${projectId}/datasets/upload`, form)
|
||||
},
|
||||
refreshMetadata: (projectId: string, datasetId: string): Promise<DatasetCreateResponse> =>
|
||||
|
||||
@@ -9,3 +9,4 @@ export { segmentationApi } from './segmentation'
|
||||
export { exportsApi } from './exports'
|
||||
export { jobsApi } from './jobs'
|
||||
export { projectsApi } from './projects'
|
||||
export { temporalApi } from './temporal'
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { apiGet, apiPost } from './client'
|
||||
import type {
|
||||
TemporalComparisonRequest,
|
||||
TemporalComparisonResponse,
|
||||
TemporalSeriesListResponse,
|
||||
} from '../../types'
|
||||
|
||||
export const temporalApi = {
|
||||
listSeries: (projectId: string): Promise<TemporalSeriesListResponse> =>
|
||||
apiGet<TemporalSeriesListResponse>(`/api/v1/projects/${projectId}/temporal/series`),
|
||||
compare: (projectId: string, payload: TemporalComparisonRequest): Promise<TemporalComparisonResponse> =>
|
||||
apiPost<TemporalComparisonResponse>(`/api/v1/projects/${projectId}/temporal/compare`, payload),
|
||||
}
|
||||
+157
-1
@@ -5448,6 +5448,33 @@ section {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.geo-analysis-mode {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid #cdd8d4;
|
||||
border-radius: 6px;
|
||||
padding: 0.18rem;
|
||||
background: #f3f6f5;
|
||||
}
|
||||
|
||||
.geo-analysis-mode button {
|
||||
min-height: 2.15rem;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
padding: 0.38rem 0.68rem;
|
||||
background: transparent;
|
||||
color: #5a6964;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.geo-analysis-mode button.active {
|
||||
background: #ffffff;
|
||||
color: #174f45;
|
||||
box-shadow: 0 1px 3px rgba(23, 33, 30, 0.12);
|
||||
}
|
||||
|
||||
.geo-explorer-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(15.5rem, 17rem) minmax(30rem, 1fr) minmax(18rem, 20rem);
|
||||
@@ -5627,6 +5654,32 @@ section {
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.geo-time-controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.45rem;
|
||||
border-top: 1px solid #e3e9e6;
|
||||
padding-top: 0.65rem;
|
||||
}
|
||||
|
||||
.geo-time-controls label {
|
||||
color: #4c5b56;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.geo-time-controls select {
|
||||
min-height: 2.25rem;
|
||||
margin-top: 0.24rem;
|
||||
padding: 0.35rem;
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.geo-time-controls button {
|
||||
grid-column: 1 / -1;
|
||||
min-height: 2.25rem;
|
||||
}
|
||||
|
||||
.geo-scope-select {
|
||||
margin-top: auto;
|
||||
color: #4c5b56;
|
||||
@@ -5736,6 +5789,21 @@ section {
|
||||
background: rgba(107, 74, 170, 0.18);
|
||||
}
|
||||
|
||||
.geo-map-legend .geo-legend-added {
|
||||
border-color: #15803d;
|
||||
background: rgba(22, 163, 74, 0.2);
|
||||
}
|
||||
|
||||
.geo-map-legend .geo-legend-removed {
|
||||
border-color: #b91c1c;
|
||||
background: rgba(220, 38, 38, 0.18);
|
||||
}
|
||||
|
||||
.geo-map-legend .geo-legend-modified {
|
||||
border-color: #b45309;
|
||||
background: rgba(217, 119, 6, 0.2);
|
||||
}
|
||||
|
||||
.geo-draw-instruction,
|
||||
.geo-viewport-status {
|
||||
position: absolute;
|
||||
@@ -5841,6 +5909,78 @@ section {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.geo-temporal-metrics > div.positive {
|
||||
border-color: #b8d8c5;
|
||||
background: #f1faf4;
|
||||
}
|
||||
|
||||
.geo-temporal-metrics > div.negative {
|
||||
border-color: #e2c1bd;
|
||||
background: #fff6f5;
|
||||
}
|
||||
|
||||
.geo-temporal-metrics small {
|
||||
color: #64736d;
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.geo-temporal-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
border: 1px solid #e1e8e5;
|
||||
border-radius: 5px;
|
||||
background: #fbfcfc;
|
||||
}
|
||||
|
||||
.geo-temporal-summary > div {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
border-left: 1px solid #e1e8e5;
|
||||
padding: 0.45rem;
|
||||
}
|
||||
|
||||
.geo-temporal-summary > div:first-child {
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.geo-temporal-summary span {
|
||||
color: #6a7773;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.geo-temporal-summary strong {
|
||||
overflow: hidden;
|
||||
color: #26332f;
|
||||
font-size: 0.7rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.geo-change-counts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.geo-change-counts span {
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
border: 1px solid #e1e8e5;
|
||||
border-radius: 5px;
|
||||
padding: 0.4rem;
|
||||
color: #687570;
|
||||
font-size: 0.64rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.geo-change-counts strong {
|
||||
color: #26332f;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.geo-theme-results {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
@@ -6034,6 +6174,7 @@ section {
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.geo-explorer-header {
|
||||
flex-wrap: wrap;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@@ -6075,11 +6216,26 @@ section {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.geo-analysis-mode {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.geo-theme-list,
|
||||
.geo-primary-metrics {
|
||||
.geo-primary-metrics,
|
||||
.geo-temporal-summary,
|
||||
.geo-change-counts {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.geo-temporal-summary > div {
|
||||
border-top: 1px solid #e1e8e5;
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.geo-temporal-summary > div:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.geo-map-toolbar {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
@@ -83,6 +83,12 @@ export interface DatasetCreateResponse {
|
||||
source_metadata?: Record<string, unknown> | null
|
||||
provenance_metadata?: Record<string, unknown> | null
|
||||
imported_at?: string | null
|
||||
temporal_series_key?: string | null
|
||||
observed_at?: string | null
|
||||
valid_from?: string | null
|
||||
valid_to?: string | null
|
||||
temporal_granularity?: string | null
|
||||
source_version?: string | null
|
||||
project_id: string
|
||||
area_id?: string | null
|
||||
storage_path?: string | null
|
||||
@@ -313,6 +319,109 @@ export interface VectorSelectionResponse {
|
||||
limit: number
|
||||
truncated: boolean
|
||||
geojson: GeoJSON.FeatureCollection
|
||||
summary?: VectorSelectionSummary | null
|
||||
}
|
||||
|
||||
export interface VectorSelectionSummary {
|
||||
metric_label: string
|
||||
metric_value: number
|
||||
metric_unit: string
|
||||
aggregation_method: string
|
||||
feature_count: number
|
||||
is_estimate: boolean
|
||||
warning?: string | null
|
||||
}
|
||||
|
||||
export interface DatasetTemporalUpdate {
|
||||
temporal_series_key: string
|
||||
observed_at: string
|
||||
valid_from?: string | null
|
||||
valid_to?: string | null
|
||||
temporal_granularity?: string
|
||||
source_version?: string | null
|
||||
}
|
||||
|
||||
export interface DatasetVersionRead {
|
||||
id: string
|
||||
dataset_id: string
|
||||
version: number
|
||||
storage_path?: string | null
|
||||
source_version?: string | null
|
||||
observed_at?: string | null
|
||||
valid_from?: string | null
|
||||
valid_to?: string | null
|
||||
checksum_sha256?: string | null
|
||||
source_metadata?: Record<string, unknown> | null
|
||||
provenance_metadata?: Record<string, unknown> | null
|
||||
created_at?: string | null
|
||||
}
|
||||
|
||||
export interface TemporalComparisonRequest {
|
||||
earlier_dataset_id: string
|
||||
later_dataset_id: string
|
||||
bbox: VectorSelectionBBox
|
||||
preview_limit?: number
|
||||
}
|
||||
|
||||
export interface TemporalDatasetRef {
|
||||
id: string
|
||||
name: string
|
||||
observed_at: string
|
||||
source_version?: string | null
|
||||
}
|
||||
|
||||
export interface TemporalMetricComparison {
|
||||
label: string
|
||||
unit: string
|
||||
aggregation_method: string
|
||||
earlier_value: number
|
||||
later_value: number
|
||||
absolute_change: number
|
||||
percent_change?: number | null
|
||||
is_estimate: boolean
|
||||
}
|
||||
|
||||
export interface TemporalObjectChanges {
|
||||
available: boolean
|
||||
added_count?: number | null
|
||||
removed_count?: number | null
|
||||
modified_count?: number | null
|
||||
unchanged_count?: number | null
|
||||
}
|
||||
|
||||
export interface TemporalComparisonResponse {
|
||||
temporal_series_key: string
|
||||
earlier: TemporalDatasetRef
|
||||
later: TemporalDatasetRef
|
||||
selection_bbox: VectorSelectionBBox
|
||||
metric: TemporalMetricComparison
|
||||
object_changes: TemporalObjectChanges
|
||||
geojson: GeoJSON.FeatureCollection
|
||||
warnings: string[]
|
||||
generated_at: string
|
||||
}
|
||||
|
||||
export interface TemporalSeriesDataset {
|
||||
id: string
|
||||
name: string
|
||||
observed_at: string
|
||||
source_version?: string | null
|
||||
feature_count?: number | null
|
||||
}
|
||||
|
||||
export interface TemporalSeriesRead {
|
||||
temporal_series_key: string
|
||||
source_name?: string | null
|
||||
reference_layer_name?: string | null
|
||||
dataset_count: number
|
||||
first_observed_at: string
|
||||
last_observed_at: string
|
||||
datasets: TemporalSeriesDataset[]
|
||||
}
|
||||
|
||||
export interface TemporalSeriesListResponse {
|
||||
items: TemporalSeriesRead[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface DatasetListResponse {
|
||||
|
||||
Reference in New Issue
Block a user