feat: add temporal Mol explorer
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 15:19:42 +02:00
parent 0ec1ab4970
commit c0943fe7d4
39 changed files with 3065 additions and 163 deletions
+13
View File
@@ -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.
+4
View File
@@ -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',
+285 -55
View File
@@ -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 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>
{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 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 ? 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,
}
}
+24
View File
@@ -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> =>
+1
View File
@@ -9,3 +9,4 @@ export { segmentationApi } from './segmentation'
export { exportsApi } from './exports'
export { jobsApi } from './jobs'
export { projectsApi } from './projects'
export { temporalApi } from './temporal'
+13
View File
@@ -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
View File
@@ -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;
}
+109
View File
@@ -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 {