feat: add source-grounded evolution and Ollama assistant
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-15 06:45:56 +02:00
parent 0baa9b069c
commit beacdf2560
37 changed files with 2246 additions and 58 deletions
+23 -2
View File
@@ -5,6 +5,7 @@ import { featureCollectionBounds } from '../../lib/geojsonBounds'
import { useMapThemeSelectionInsights } from '../../hooks/useMapThemeSelectionInsights'
import { useTemporalComparison } from '../../hooks/useTemporalComparison'
import { getDatasetDisplayName, getDatasetSourceDisplayName } from '../../lib/datasetDisplay'
import { TemporalTrendChart } from './TemporalTrendChart'
const DEFAULT_SELECTED_FEATURE_FILENAME = 'selected-feature.geojson'
const DEFAULT_AREA_SELECTION_FILENAME = 'area-selection.geojson'
@@ -860,7 +861,7 @@ export function MapWorkspace({
setSelectionBbox(bbox)
const tasks: Array<Promise<unknown>> = [onRunMapSelectionExtract(bbox, areaId), loadAllThemeResults(bbox, areaId)]
if (analysisMode === 'evolution' && earlierDatasetId && laterDatasetId) {
tasks.push(compareTemporalSnapshots(earlierDatasetId, laterDatasetId, bbox))
tasks.push(compareTemporalSnapshots(earlierDatasetId, laterDatasetId, bbox, areaId))
}
await Promise.all(tasks)
}
@@ -869,7 +870,8 @@ export function MapWorkspace({
if (!mapSelectionBbox || !earlierDatasetId || !laterDatasetId) {
return
}
void compareTemporalSnapshots(earlierDatasetId, laterDatasetId, mapSelectionBbox)
const areaId = selectedAreaBbox && bboxesEqual(mapSelectionBbox, selectedAreaBbox) ? selectedMapAreaId : undefined
void compareTemporalSnapshots(earlierDatasetId, laterDatasetId, mapSelectionBbox, areaId)
}
const handleMapBboxPreview = (bbox: VectorSelectionBBox) => {
@@ -1324,6 +1326,25 @@ export function MapWorkspace({
<strong>{temporalComparison.metric.is_estimate ? 'Ruimtelijke schatting' : 'Exact'}</strong>
</div>
</div>
<TemporalTrendChart
timeline={temporalComparison.timeline ?? []}
metricKey={temporalComparison.metric.metric_key}
/>
{(temporalComparison.metrics ?? []).filter((metric) => metric.metric_key !== temporalComparison.metric.metric_key).length > 0 ? (
<div className="geo-supporting-metrics" aria-label="Aanvullende historische metingen">
{(temporalComparison.metrics ?? [])
.filter((metric) => metric.metric_key !== temporalComparison.metric.metric_key)
.map((metric) => (
<div key={metric.metric_key}>
<span>{metric.label}</span>
<strong>
{metric.absolute_change >= 0 ? '+' : ''}
{formatTemporalMetric(metric.absolute_change, metric.unit)}
</strong>
</div>
))}
</div>
) : null}
{temporalComparison.object_changes.available ? (
<div className="geo-change-counts" aria-label="Objectwijzigingen">
<span><strong>{temporalComparison.object_changes.added_count ?? 0}</strong> nieuw</span>
@@ -0,0 +1,76 @@
import type { TemporalObservation } from '../../types'
interface TemporalTrendChartProps {
timeline: TemporalObservation[]
metricKey: string
}
const WIDTH = 560
const HEIGHT = 150
const PADDING_X = 34
const PADDING_Y = 24
function formatValue(value: number, unit: string): string {
const maximumFractionDigits = unit === 'inwoners' || unit === 'objecten' ? 0 : 2
return `${value.toLocaleString('nl-BE', { maximumFractionDigits })} ${unit}`
}
export function TemporalTrendChart({ timeline, metricKey }: TemporalTrendChartProps): JSX.Element | null {
const observations = timeline.flatMap((observation) => {
const metric = observation.metrics.find((item) => item.metric_key === metricKey)
return metric ? [{ observation, metric }] : []
})
if (observations.length < 2) {
return null
}
const values = observations.map((item) => item.metric.value)
const minimum = Math.min(...values)
const maximum = Math.max(...values)
const range = maximum - minimum
const innerWidth = WIDTH - PADDING_X * 2
const innerHeight = HEIGHT - PADDING_Y * 2
const points = observations.map((item, index) => {
const x = PADDING_X + (index / (observations.length - 1)) * innerWidth
const normalized = range === 0 ? 0.5 : (item.metric.value - minimum) / range
const y = HEIGHT - PADDING_Y - normalized * innerHeight
return { ...item, x, y }
})
const pointString = points.map((point) => `${point.x},${point.y}`).join(' ')
const label = `${points[0].metric.label}: ${formatValue(points[0].metric.value, points[0].metric.unit)} tot ${formatValue(
points[points.length - 1].metric.value,
points[points.length - 1].metric.unit,
)}`
return (
<div className="geo-temporal-chart" aria-label={label}>
<div className="geo-temporal-chart-heading">
<div>
<span>Volledige tijdreeks</span>
<strong>{points[0].metric.label}</strong>
</div>
<span>{points.length} meetmomenten</span>
</div>
<svg viewBox={`0 0 ${WIDTH} ${HEIGHT}`} role="img" aria-label={label} preserveAspectRatio="none">
<line x1={PADDING_X} x2={WIDTH - PADDING_X} y1={HEIGHT - PADDING_Y} y2={HEIGHT - PADDING_Y} />
<polyline points={pointString} />
{points.map((point) => (
<g key={point.observation.dataset.id}>
<circle cx={point.x} cy={point.y} r="4" />
<text x={point.x} y={HEIGHT - 6} textAnchor="middle">
{new Date(point.observation.dataset.observed_at).getUTCFullYear()}
</text>
</g>
))}
</svg>
<div className="geo-temporal-chart-values">
{points.map((point) => (
<div key={point.observation.dataset.id}>
<span>{new Date(point.observation.dataset.observed_at).getUTCFullYear()}</span>
<strong>{formatValue(point.metric.value, point.metric.unit)}</strong>
</div>
))}
</div>
</div>
)
}