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
+19
View File
@@ -397,6 +397,25 @@ Raster metadata and raster ops may remain unavailable when backend raster stack
- status becomes `failed`
- backend returns explicit `RASTER_PROCESSING_UNAVAILABLE` responses for metadata/preview/clip/tile
## Source inventory, evolution and local questions
The Sources workspace starts with a compact inventory of loaded themes and
their real observation ranges. A collapsed follow-up catalogue distinguishes
official sources that exist from datasets that are already persisted in the
active project.
Evolution mode compares the exact selected persisted Area when the full
municipality/region action is used. It shows the selected before/after values,
all compatible supporting metrics and a chart/table for every observation in
the same source series. It never merges GRB current geometry with a differently
measured historical land-use series.
`AI-vragen` is a separate local Ollama workspace. Users select one of the
models actually installed on the server, ask about the active Area or drawn
rectangle and can inspect how many metrics, time series and source datasets
were supplied. Chat history remains in the browser session; source measurements
are recomputed by the backend from PostGIS for every question.
## Run locally
### Prerequisites
+17 -1
View File
@@ -2,7 +2,9 @@ import { useEffect, useMemo, useState } from 'react'
import './styles/app.css'
import './styles/premium.css'
import { ChangeDetectionPanel } from './components/analysis/ChangeDetectionPanel'
import { GeoAssistantPanel } from './components/assistant/GeoAssistantPanel'
import { DatasetPanel } from './components/datasets/DatasetPanel'
import { SourceCatalogPanel } from './components/datasets/SourceCatalogPanel'
import { DetectionLab } from './components/detection/DetectionLab'
import { ExportCenter } from './components/exports/ExportCenter'
import { ExportPreview } from './components/exports/ExportPreview'
@@ -38,12 +40,13 @@ function isVectorDatasetType(datasetType: string): boolean {
return datasetType === 'vector' || datasetType === 'geojson'
}
type WorkspaceKey = 'overview' | 'data' | 'map' | 'analysis' | 'ai' | 'exports' | 'system'
type WorkspaceKey = 'overview' | 'data' | 'map' | 'assistant' | 'analysis' | 'ai' | 'exports' | 'system'
const workspaceNavItems: Array<{ key: WorkspaceKey; label: string; description: string }> = [
{ key: 'overview', label: 'Status', description: 'Beschikbaarheid en aandachtspunten' },
{ key: 'data', label: 'Bronnen', description: 'Gebieden en ingeladen gegevens' },
{ key: 'map', label: 'Kaart', description: 'Selecteren, uitlezen en vergelijken' },
{ key: 'assistant', label: 'AI-vragen', description: 'Vraag de lokale assistent over het actieve gebied' },
{ key: 'analysis', label: 'Kwaliteit', description: 'Resultaten controleren' },
{ key: 'ai', label: 'Beeldanalyse', description: 'Gebouwen herkennen op luchtbeelden' },
{ key: 'exports', label: 'Downloads', description: 'Resultaten bewaren en delen' },
@@ -52,6 +55,7 @@ const workspaceNavItems: Array<{ key: WorkspaceKey; label: string; description:
const workspaceNavGroups: Array<{ label: string; keys: WorkspaceKey[] }> = [
{ label: 'Verkennen', keys: ['map', 'data'] },
{ label: 'Vragen', keys: ['assistant'] },
{ label: 'Analyseren', keys: ['analysis', 'ai'] },
{ label: 'Afronden', keys: ['exports'] },
{ label: 'Beheer', keys: ['overview', 'system'] },
@@ -918,6 +922,7 @@ function App(): JSX.Element {
{activeWorkspace === 'data' ? (
<div className="workspace-grid workspace-grid-data">
<SourceCatalogPanel datasets={datasets} />
<ProjectPanel
projects={projects}
selectedProjectId={selectedProjectId}
@@ -1075,6 +1080,17 @@ function App(): JSX.Element {
</div>
) : null}
{activeWorkspace === 'assistant' ? (
<div className="workspace-grid workspace-grid-assistant">
<GeoAssistantPanel
selectedProjectId={selectedProjectId}
selectedAreaId={mapSelectionResult?.selection_area_id ?? (!mapSelectionBbox ? selectedArea?.id ?? null : null)}
selectedAreaName={selectedArea?.name ?? null}
selectionBbox={mapSelectionBbox}
/>
</div>
) : null}
{activeWorkspace === 'ai' ? (
<div className="workspace-grid workspace-grid-ai">
<DetectionLab
@@ -0,0 +1,164 @@
import { useState } from 'react'
import { useGeoAssistant } from '../../hooks/useGeoAssistant'
import type { VectorSelectionBBox } from '../../types'
interface GeoAssistantPanelProps {
selectedProjectId: string | null
selectedAreaId: string | null
selectedAreaName: string | null
selectionBbox: VectorSelectionBBox | null
}
const SUGGESTIONS = [
'Vat de belangrijkste gebiedsmetingen samen.',
'Hoe evolueerden bevolking en bosoppervlakte?',
'Welke gegevens ontbreken nog voor een volledige wateranalyse?',
'Welke officiële bronnen zijn voor dit gebied beschikbaar?',
]
export function GeoAssistantPanel({
selectedProjectId,
selectedAreaId,
selectedAreaName,
selectionBbox,
}: GeoAssistantPanelProps): JSX.Element {
const [question, setQuestion] = useState('')
const {
status,
models,
selectedModel,
messages,
loading,
loadingModels,
error,
loadModels,
ask,
clear,
setSelectedModel,
} = useGeoAssistant({ selectedProjectId, selectedAreaId, selectionBbox })
const submitQuestion = async (value: string) => {
if (await ask(value)) setQuestion('')
}
const scopeLabel = selectedAreaId
? selectedAreaName ?? 'Volledig geselecteerd gebied'
: selectionBbox
? 'Getekende kaartselectie'
: selectedAreaName ?? 'Volledig werkgebied'
const ready = Boolean(selectedProjectId && status?.reachable && selectedModel)
return (
<section className="workspace-panel geo-assistant-panel" data-testid="geo-assistant-panel">
<div className="panel-title-row">
<div>
<span className="section-kicker">Lokale AI · Ollama</span>
<h2>Vraag GeoIntel</h2>
<p className="muted">Stel vragen over de gemeten kaartgegevens en officiële tijdreeksen van het actieve gebied.</p>
</div>
<span className={status?.reachable ? 'status-badge status-badge-ready' : 'status-badge'}>
{status?.reachable ? 'Lokaal verbonden' : loadingModels ? 'Verbinden…' : 'Niet bereikbaar'}
</span>
</div>
<div className="assistant-context-strip">
<div>
<span>Context</span>
<strong>{scopeLabel}</strong>
</div>
<label>
<span>Ollama-model</span>
<select value={selectedModel} onChange={(event) => setSelectedModel(event.target.value)} disabled={loadingModels || models.length === 0}>
{models.length === 0 ? <option value="">Geen model beschikbaar</option> : null}
{models.map((model) => (
<option value={model.name} key={model.name}>
{model.name}{model.parameter_size ? ` · ${model.parameter_size}` : ''}
</option>
))}
</select>
</label>
<button type="button" className="secondary-action" onClick={() => void loadModels()} disabled={loadingModels}>
Verbinding vernieuwen
</button>
</div>
{!selectedProjectId ? (
<div className="result-state result-state-empty">
<strong>Geen werkgebied actief.</strong>
<p>Open eerst de regionale werkruimte of een project.</p>
</div>
) : null}
{status && !status.reachable ? (
<div className="result-state result-state-error">
<strong>Ollama is niet bereikbaar.</strong>
<p>{status.limitation_message}</p>
</div>
) : null}
<div className="assistant-suggestions" aria-label="Voorbeeldvragen">
{SUGGESTIONS.map((suggestion) => (
<button type="button" key={suggestion} onClick={() => void submitQuestion(suggestion)} disabled={!ready || loading}>
{suggestion}
</button>
))}
</div>
<div className="assistant-conversation" aria-live="polite">
{messages.length === 0 ? (
<div className="assistant-empty-state">
<strong>Begin bij een concrete gebiedsvraag</strong>
<p>GeoIntel stuurt alleen samengevatte, persistente GIS-metingen en broninformatie naar het lokale model.</p>
</div>
) : null}
{messages.map((message) => (
<article className={`assistant-message assistant-message-${message.role}`} key={message.id}>
<span>{message.role === 'user' ? 'Jij' : 'GeoIntel'}</span>
<p>{message.content}</p>
{message.response ? (
<details>
<summary>Gebruikte gegevens</summary>
<div className="assistant-evidence-grid">
<span>{message.response.context_metrics.length} metriek</span>
<span>{message.response.temporal_series.length} tijdreeksen</span>
<span>{message.response.source_dataset_ids.length} brondatasets</span>
<span>{message.response.scope_label}</span>
</div>
{message.response.warnings.map((warning) => <p className="geo-data-notice" key={warning}>{warning}</p>)}
</details>
) : null}
</article>
))}
{loading ? (
<div className="assistant-thinking" role="status">
<span />
<strong>Lokale gegevens worden samengevat en beantwoord…</strong>
</div>
) : null}
</div>
{error ? <p className="error">{error}</p> : null}
<form
className="assistant-composer"
onSubmit={(event) => {
event.preventDefault()
void submitQuestion(question)
}}
>
<label htmlFor="geo-assistant-question">Vraag over het actieve gebied</label>
<textarea
id="geo-assistant-question"
value={question}
onChange={(event) => setQuestion(event.target.value)}
placeholder="Bijvoorbeeld: hoeveel bos verdween er sinds 2013?"
maxLength={2000}
rows={3}
disabled={!ready || loading}
/>
<div>
<p>Antwoorden zijn lokaal gegenereerd. Controleer beslissingen altijd tegen de vermelde brondata.</p>
<button type="button" className="secondary-action" onClick={clear} disabled={messages.length === 0 || loading}>Wis gesprek</button>
<button type="submit" className="primary-action" disabled={!ready || loading || question.trim().length < 2}>Stel vraag</button>
</div>
</form>
</section>
)
}
@@ -0,0 +1,134 @@
import type { DatasetCreateResponse } from '../../types'
interface SourceCatalogPanelProps {
datasets: DatasetCreateResponse[]
}
const THEME_LABELS: Record<string, string> = {
buildings: 'Bebouwing',
population: 'Bevolking',
forest: 'Bos',
water: 'Water',
roads: 'Wegen en transport',
parcels: 'Percelen',
}
const AVAILABLE_SOURCES = [
{
name: 'Historische orthofoto’s',
owner: 'Digitaal Vlaanderen',
coverage: '1971 en 1979-1990; aanvullende jaargangen bestaan afzonderlijk',
value: 'Visuele evolutie en toekomstige beeldvergelijking',
url: 'https://www.vlaanderen.be/datavindplaats/catalogus/orthofotomozaiek-kleinschalig-zomeropnamen',
},
{
name: 'Biologische Waarderingskaart / Natura 2000',
owner: 'INBO',
coverage: 'Toestand 2025',
value: 'Natuurwaarde, biotopen en habitattypen',
url: 'https://www.vlaanderen.be/datavindplaats/catalogus/biologische-waarderingskaart-en-natura-2000-habitatkaart-toestand-2025',
},
{
name: 'Landbouwgebruikspercelen',
owner: 'Agentschap Landbouw en Zeevisserij',
coverage: 'Jaarlijkse bestanden',
value: 'Landbouwoppervlakte, teelten en perceelevolutie',
url: 'https://www.vlaanderen.be/datavindplaats/catalogus/open-geodata-landbouwgebruikspercelen',
},
{
name: 'Gebouwen- en adressenregister',
owner: 'Digitaal Vlaanderen',
coverage: 'Continu geactualiseerd',
value: 'Gebouwstatus, levensloop en adressen als aanvulling op GRB',
url: 'https://www.vlaanderen.be/datavindplaats/catalogus/gebouwen-en-adressenregister',
},
{
name: 'Digitaal Hoogtemodel Vlaanderen II',
owner: 'Digitaal Vlaanderen',
coverage: 'LiDAR-opname 2013-2015, DTM/DSM 1 m en 5 m',
value: 'Hoogte, reliëf, helling en afstroming; geen waterdiepte',
url: 'https://www.vlaanderen.be/digitaal-vlaanderen/onze-diensten-en-platformen/earth-observation-data-science-eodas/het-digitaal-hoogtemodel/digitaal-hoogtemodel-vlaanderen-ii',
},
{
name: 'Waterinfo en VMM-metingen',
owner: 'Vlaamse Milieumaatschappij',
coverage: 'Meetpunten en tijdreeksen voor waterstand, debiet en neerslag',
value: 'Hydrologische toestand; geen gebiedsdekkend watervolume zonder bodemprofiel',
url: 'https://waterinfo.vlaanderen.be/',
},
]
function datasetTheme(dataset: DatasetCreateResponse): string | null {
const configured = String(dataset.source_metadata?.['theme'] ?? dataset.reference_layer_name ?? '').toLowerCase()
if (configured === 'built' || configured === 'building') return 'buildings'
if (configured === 'transport' || configured === 'road') return 'roads'
if (configured in THEME_LABELS) return configured
return null
}
export function SourceCatalogPanel({ datasets }: SourceCatalogPanelProps): JSX.Element {
const ready = datasets.filter((dataset) => dataset.status === 'ready')
const themes = Object.keys(THEME_LABELS).map((theme) => {
const matches = ready.filter((dataset) => datasetTheme(dataset) === theme)
const temporal = matches.filter((dataset) => dataset.temporal_series_key && dataset.observed_at)
const years = temporal.map((dataset) => new Date(dataset.observed_at as string).getUTCFullYear())
const latest = [...matches].sort(
(left, right) => new Date(right.observed_at ?? right.imported_at ?? 0).getTime() - new Date(left.observed_at ?? left.imported_at ?? 0).getTime(),
)[0]
return {
theme,
label: THEME_LABELS[theme],
datasetCount: matches.length,
temporalCount: temporal.length,
firstYear: years.length ? Math.min(...years) : null,
lastYear: years.length ? Math.max(...years) : null,
source: latest?.source_name ?? latest?.source ?? null,
}
})
return (
<section className="workspace-panel source-catalog-panel" aria-label="Beschikbare databronnen">
<div className="panel-title-row">
<div>
<span className="section-kicker">Broninventaris</span>
<h2>Wat is werkelijk beschikbaar?</h2>
<p className="muted">Ingeladen bronnen staan direct klaar voor de kaart. Andere officiële bronnen worden pas gebruikt na een gecontroleerde import.</p>
</div>
<span className="status-badge status-badge-ready">{ready.length} datasets klaar</span>
</div>
<div className="source-catalog-grid">
{themes.map((theme) => (
<article className="source-catalog-card" key={theme.theme}>
<div>
<strong>{theme.label}</strong>
<span>{theme.source ? theme.source.replaceAll('_', ' ') : 'Nog niet ingeladen'}</span>
</div>
<b>{theme.datasetCount > 0 ? 'Beschikbaar' : 'Ontbreekt'}</b>
<p>
{theme.temporalCount >= 2 && theme.firstYear && theme.lastYear
? `${theme.temporalCount} officiële meetmomenten · ${theme.firstYear}-${theme.lastYear}`
: 'Alleen de huidige toestand is vergelijkbaar beschikbaar.'}
</p>
</article>
))}
</div>
<details className="source-opportunity-list">
<summary>Officiële bronnen die hierna kunnen worden ingeladen</summary>
<div>
{AVAILABLE_SOURCES.map((source) => (
<article key={source.name}>
<div>
<strong>{source.name}</strong>
<span>{source.owner} · {source.coverage}</span>
</div>
<p>{source.value}</p>
<a href={source.url} target="_blank" rel="noreferrer">Bekijk officiële bron</a>
</article>
))}
</div>
</details>
</section>
)
}
+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>
)
}
+115
View File
@@ -0,0 +1,115 @@
import { useEffect, useState } from 'react'
import { formatError } from '../lib/formatError'
import { assistantApi } from '../services/api/assistant'
import type {
AssistantChatMessage,
AssistantModelRead,
AssistantQueryResponse,
AssistantStatus,
VectorSelectionBBox,
} from '../types'
export interface GeoAssistantMessage extends AssistantChatMessage {
id: string
response?: AssistantQueryResponse
}
interface UseGeoAssistantOptions {
selectedProjectId: string | null
selectedAreaId: string | null
selectionBbox: VectorSelectionBBox | null
}
export function useGeoAssistant({ selectedProjectId, selectedAreaId, selectionBbox }: UseGeoAssistantOptions) {
const [status, setStatus] = useState<AssistantStatus | null>(null)
const [models, setModels] = useState<AssistantModelRead[]>([])
const [selectedModel, setSelectedModel] = useState('')
const [messages, setMessages] = useState<GeoAssistantMessage[]>([])
const [loading, setLoading] = useState(false)
const [loadingModels, setLoadingModels] = useState(false)
const [error, setError] = useState<string | null>(null)
const loadModels = async () => {
setLoadingModels(true)
setError(null)
try {
const currentStatus = await assistantApi.status()
setStatus(currentStatus)
if (!currentStatus.enabled || !currentStatus.reachable) {
setModels([])
setSelectedModel('')
return
}
const result = await assistantApi.models()
setModels(result.items)
setSelectedModel((current) => {
if (current && result.items.some((model) => model.name === current)) return current
if (result.default_model && result.items.some((model) => model.name === result.default_model)) return result.default_model
return result.items[0]?.name ?? ''
})
} catch (requestError) {
setStatus(null)
setModels([])
setError(formatError(requestError, 'De lokale AI-assistent kon niet worden bereikt.'))
} finally {
setLoadingModels(false)
}
}
useEffect(() => {
void loadModels()
}, [])
useEffect(() => {
setMessages([])
setError(null)
}, [selectedProjectId])
const ask = async (question: string): Promise<boolean> => {
const trimmed = question.trim()
if (!selectedProjectId || !trimmed || !selectedModel) return false
const userMessage: GeoAssistantMessage = { id: crypto.randomUUID(), role: 'user', content: trimmed }
setMessages((current) => [...current, userMessage])
setLoading(true)
setError(null)
try {
const history = messages.slice(-6).map(({ role, content }) => ({ role, content }))
const result = await assistantApi.query(selectedProjectId, {
question: trimmed,
model: selectedModel,
bbox: selectionBbox,
area_id: selectedAreaId,
history,
})
setMessages((current) => [
...current,
{ id: crypto.randomUUID(), role: 'assistant', content: result.answer, response: result },
])
return true
} catch (requestError) {
setError(formatError(requestError, 'GeoIntel kon de vraag niet beantwoorden.'))
return false
} finally {
setLoading(false)
}
}
const clear = () => {
setMessages([])
setError(null)
}
return {
status,
models,
selectedModel,
messages,
loading,
loadingModels,
error,
loadModels,
ask,
clear,
setSelectedModel,
}
}
@@ -22,6 +22,7 @@ export function useTemporalComparison(selectedProjectId: string | null) {
earlierDatasetId: string,
laterDatasetId: string,
bbox: VectorSelectionBBox,
areaId?: string,
): Promise<TemporalComparisonResponse | null> => {
if (!selectedProjectId) {
setTemporalComparisonError('Open eerst een project om evoluties te vergelijken.')
@@ -39,6 +40,7 @@ export function useTemporalComparison(selectedProjectId: string | null) {
earlier_dataset_id: earlierDatasetId,
later_dataset_id: laterDatasetId,
bbox,
area_id: areaId || null,
preview_limit: 500,
})
setTemporalComparison(result)
+9
View File
@@ -0,0 +1,9 @@
import { apiGet, apiPost } from './client'
import type { AssistantModelsResponse, AssistantQueryRequest, AssistantQueryResponse, AssistantStatus } from '../../types'
export const assistantApi = {
status: (): Promise<AssistantStatus> => apiGet<AssistantStatus>('/api/v1/assistant/status'),
models: (): Promise<AssistantModelsResponse> => apiGet<AssistantModelsResponse>('/api/v1/assistant/models'),
query: (projectId: string, payload: AssistantQueryRequest): Promise<AssistantQueryResponse> =>
apiPost<AssistantQueryResponse>(`/api/v1/projects/${projectId}/assistant/query`, payload),
}
+1
View File
@@ -1,5 +1,6 @@
export { areasApi } from './areas'
export { analysisApi } from './analysis'
export { assistantApi } from './assistant'
export { datasetsApi } from './datasets'
export { demoApi } from './demo'
export { detectionApi } from './detection'
+399
View File
@@ -6212,6 +6212,81 @@ section {
white-space: nowrap;
}
.geo-temporal-chart {
margin-top: 0.7rem;
padding: 0.7rem;
border: 1px solid #e1e8e5;
border-radius: 5px;
background: #fbfcfc;
}
.geo-temporal-chart-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.geo-temporal-chart-heading > div {
display: grid;
gap: 0.1rem;
}
.geo-temporal-chart-heading span,
.geo-temporal-chart-values span {
color: #6a7773;
font-size: 0.64rem;
}
.geo-temporal-chart svg {
display: block;
width: 100%;
height: 150px;
margin-top: 0.4rem;
overflow: visible;
}
.geo-temporal-chart svg line {
stroke: #cbd6d2;
stroke-width: 1;
}
.geo-temporal-chart svg polyline {
fill: none;
stroke: #2676a8;
stroke-width: 3;
vector-effect: non-scaling-stroke;
}
.geo-temporal-chart svg circle {
fill: #ffffff;
stroke: #2676a8;
stroke-width: 3;
vector-effect: non-scaling-stroke;
}
.geo-temporal-chart svg text {
fill: #6a7773;
font-size: 11px;
}
.geo-temporal-chart-values {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(108px, 1fr));
gap: 0.35rem;
}
.geo-temporal-chart-values > div {
display: grid;
gap: 0.1rem;
padding-top: 0.4rem;
border-top: 1px solid #e1e8e5;
}
.geo-temporal-chart-values strong {
font-size: 0.72rem;
}
.geo-change-counts {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -6553,3 +6628,327 @@ section {
min-height: 24rem;
}
}
.source-catalog-panel {
grid-column: 1 / -1;
}
.source-catalog-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.65rem;
margin-top: 1rem;
}
.source-catalog-card {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.45rem 0.75rem;
min-width: 0;
padding: 0.85rem;
border: 1px solid #dfe7e4;
border-radius: 6px;
background: #fbfcfc;
}
.source-catalog-card > div {
display: grid;
gap: 0.15rem;
min-width: 0;
}
.source-catalog-card span,
.source-catalog-card p,
.source-opportunity-list span,
.source-opportunity-list p {
margin: 0;
color: #66746f;
font-size: 0.74rem;
}
.source-catalog-card b {
color: #286646;
font-size: 0.7rem;
}
.source-catalog-card p {
grid-column: 1 / -1;
}
.source-opportunity-list {
margin-top: 0.8rem;
border-top: 1px solid #dfe7e4;
padding-top: 0.75rem;
}
.source-opportunity-list > summary {
cursor: pointer;
color: #34433e;
font-weight: 750;
}
.source-opportunity-list > div {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.6rem;
margin-top: 0.7rem;
}
.source-opportunity-list article {
display: grid;
gap: 0.35rem;
padding: 0.75rem;
border: 1px solid #e2e8e6;
border-radius: 5px;
}
.source-opportunity-list article > div {
display: grid;
gap: 0.15rem;
}
.source-opportunity-list a {
width: fit-content;
color: #17638a;
font-size: 0.72rem;
font-weight: 700;
}
@media (max-width: 980px) {
.source-catalog-grid,
.source-opportunity-list > div {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.source-catalog-grid,
.source-opportunity-list > div {
grid-template-columns: 1fr;
}
}
.workspace-grid-assistant {
grid-template-columns: minmax(0, 1fr);
}
.geo-assistant-panel {
width: min(1120px, 100%);
margin: 0 auto;
}
.assistant-context-strip {
display: grid;
grid-template-columns: minmax(180px, 1fr) minmax(240px, 1.25fr) auto;
gap: 0.75rem;
align-items: end;
margin-top: 1rem;
padding: 0.8rem;
border: 1px solid #dfe7e4;
border-radius: 6px;
background: #f7faf9;
}
.assistant-context-strip > div,
.assistant-context-strip label {
display: grid;
gap: 0.25rem;
min-width: 0;
}
.assistant-context-strip span,
.assistant-composer label,
.assistant-message > span {
color: #66746f;
font-size: 0.68rem;
font-weight: 750;
}
.assistant-context-strip select {
width: 100%;
min-height: 2.5rem;
border: 1px solid #cfdad6;
border-radius: 5px;
padding: 0 0.65rem;
background: #ffffff;
color: #26332f;
}
.assistant-suggestions {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.5rem;
margin-top: 0.8rem;
}
.assistant-suggestions button {
min-height: 3.5rem;
border: 1px solid #d8e3df;
border-radius: 5px;
padding: 0.65rem;
background: #ffffff;
color: #34433e;
font-size: 0.75rem;
font-weight: 650;
text-align: left;
}
.assistant-suggestions button:hover:not(:disabled) {
border-color: #7fa99a;
background: #f4f9f7;
}
.assistant-conversation {
display: grid;
gap: 0.7rem;
min-height: 22rem;
max-height: 52vh;
margin-top: 0.8rem;
overflow-y: auto;
border: 1px solid #dfe7e4;
border-radius: 6px;
padding: 0.9rem;
background: #f7f9f8;
}
.assistant-empty-state {
align-self: center;
justify-self: center;
max-width: 34rem;
color: #66746f;
text-align: center;
}
.assistant-empty-state p {
margin: 0.35rem 0 0;
}
.assistant-message {
display: grid;
gap: 0.3rem;
width: min(82%, 48rem);
padding: 0.8rem 0.9rem;
border: 1px solid #dfe7e4;
border-radius: 6px;
background: #ffffff;
}
.assistant-message-user {
justify-self: end;
border-color: #c9ddd5;
background: #eef6f3;
}
.assistant-message p {
margin: 0;
color: #26332f;
line-height: 1.55;
white-space: pre-wrap;
}
.assistant-message details {
margin-top: 0.3rem;
border-top: 1px solid #e3e9e7;
padding-top: 0.45rem;
}
.assistant-message summary {
cursor: pointer;
color: #56655f;
font-size: 0.7rem;
font-weight: 700;
}
.assistant-evidence-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.3rem;
margin-top: 0.45rem;
}
.assistant-evidence-grid span {
padding: 0.35rem 0.45rem;
border-radius: 4px;
background: #f1f5f3;
color: #596761;
font-size: 0.68rem;
}
.assistant-thinking {
display: flex;
align-items: center;
gap: 0.55rem;
color: #53645e;
font-size: 0.76rem;
}
.assistant-thinking > span {
width: 0.8rem;
height: 0.8rem;
border: 2px solid #b9cbc4;
border-top-color: #347950;
border-radius: 50%;
animation: assistant-spin 0.8s linear infinite;
}
@keyframes assistant-spin {
to { transform: rotate(360deg); }
}
.assistant-composer {
display: grid;
gap: 0.4rem;
margin-top: 0.8rem;
}
.assistant-composer textarea {
width: 100%;
resize: vertical;
border: 1px solid #cfdad6;
border-radius: 6px;
padding: 0.75rem;
color: #26332f;
font: inherit;
line-height: 1.45;
}
.assistant-composer > div {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.55rem;
}
.assistant-composer > div p {
margin: 0 auto 0 0;
color: #66746f;
font-size: 0.68rem;
}
@media (max-width: 900px) {
.assistant-context-strip,
.assistant-suggestions {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.assistant-context-strip > button {
width: fit-content;
}
}
@media (max-width: 640px) {
.assistant-context-strip,
.assistant-suggestions,
.assistant-evidence-grid {
grid-template-columns: 1fr;
}
.assistant-message {
width: 100%;
}
.assistant-composer > div {
align-items: stretch;
flex-direction: column;
}
}
+88
View File
@@ -394,6 +394,7 @@ export interface TemporalComparisonRequest {
earlier_dataset_id: string
later_dataset_id: string
bbox: VectorSelectionBBox
area_id?: string | null
preview_limit?: number
}
@@ -405,6 +406,7 @@ export interface TemporalDatasetRef {
}
export interface TemporalMetricComparison {
metric_key: string
label: string
unit: string
aggregation_method: string
@@ -413,6 +415,21 @@ export interface TemporalMetricComparison {
absolute_change: number
percent_change?: number | null
is_estimate: boolean
warning?: string | null
}
export interface TemporalObservationMetric {
metric_key: string
label: string
value: number
unit: string
aggregation_method: string
is_estimate: boolean
}
export interface TemporalObservation {
dataset: TemporalDatasetRef
metrics: TemporalObservationMetric[]
}
export interface TemporalObjectChanges {
@@ -428,7 +445,10 @@ export interface TemporalComparisonResponse {
earlier: TemporalDatasetRef
later: TemporalDatasetRef
selection_bbox: VectorSelectionBBox
selection_area_id?: string | null
metric: TemporalMetricComparison
metrics: TemporalMetricComparison[]
timeline: TemporalObservation[]
object_changes: TemporalObjectChanges
geojson: GeoJSON.FeatureCollection
warnings: string[]
@@ -521,6 +541,74 @@ export interface SystemCapabilitiesResponse {
providers: ProviderCapability[]
}
export interface AssistantModelRead {
name: string
size_bytes?: number | null
parameter_size?: string | null
quantization_level?: string | null
capabilities: string[]
}
export interface AssistantModelsResponse {
items: AssistantModelRead[]
total: number
default_model?: string | null
}
export interface AssistantStatus {
enabled: boolean
reachable: boolean
status: string
base_url: string
default_model?: string | null
model_count: number
limitation_message: string
}
export interface AssistantChatMessage {
role: 'user' | 'assistant'
content: string
}
export interface AssistantQueryRequest {
question: string
model?: string | null
bbox?: VectorSelectionBBox | null
area_id?: string | null
history?: AssistantChatMessage[]
}
export interface AssistantContextMetric {
theme: string
label: string
value: number
unit: string
source: string
dataset_id: string
observed_at?: string | null
is_estimate: boolean
}
export interface AssistantTemporalSeries {
temporal_series_key: string
label: string
source: string
first_year: number
last_year: number
observation_count: number
}
export interface AssistantQueryResponse {
answer: string
model: string
scope_label: string
context_metrics: AssistantContextMetric[]
temporal_series: AssistantTemporalSeries[]
source_dataset_ids: string[]
warnings: string[]
generated_at: string
}
export interface ProviderCapabilitiesResponse {
providers: ProviderCapability[]
}