Overhaul GeoIntel model selection and map UX

This commit is contained in:
Jens
2026-08-01 15:18:06 +02:00
parent ce3cd20356
commit 96db4c966d
13 changed files with 663 additions and 76 deletions
@@ -1,5 +1,6 @@
import { useState } from 'react'
import { useGeoAssistant } from '../../hooks/useGeoAssistant'
import { ModelSelector, type ModelSelectionOption } from '../models/ModelSelector'
import type { VectorSelectionBBox } from '../../types'
interface GeoAssistantPanelProps {
@@ -9,6 +10,32 @@ interface GeoAssistantPanelProps {
selectionBbox: VectorSelectionBBox | null
}
function friendlyModelName(name: string): string {
const base = name.split(':')[0].replace(/[-_]+/g, ' ')
return base.replace(/\b\w/g, (character) => character.toUpperCase())
}
function toAssistantModelOption(model: import('../../types').AssistantModelRead): ModelSelectionOption {
const parameterText = model.parameter_size?.toLowerCase() ?? ''
const parameterCount = Number.parseFloat(parameterText)
const tone = Number.isFinite(parameterCount) && parameterCount <= 9 ? 'fast' : 'analytical'
return {
id: model.name,
name: friendlyModelName(model.name),
description: model.parameter_size ? `Lokaal taalmodel van ${model.parameter_size}.` : 'Lokaal beschikbaar taalmodel.',
recommendation: tone === 'fast' ? 'Voor vlotte samenvattingen en gerichte gebiedsvragen.' : 'Voor uitgebreidere interpretatie van meerdere bronnen.',
status: 'available',
statusLabel: 'Beschikbaar',
tone,
provider: 'Ollama',
environment: 'Lokale uitvoering',
speed: tone === 'fast' ? 'Vlot' : undefined,
capability: tone === 'analytical' ? 'Grondig' : 'Gebalanceerd',
technicalName: model.name,
details: [model.parameter_size ? `Omvang: ${model.parameter_size}` : '', model.quantization_level ? `Quantisatie: ${model.quantization_level}` : ''].filter(Boolean),
}
}
const SUGGESTIONS = [
'Vat de belangrijkste gebiedsmetingen samen.',
'Hoe evolueerden bevolking en bosoppervlakte?',
@@ -27,6 +54,8 @@ export function GeoAssistantPanel({
status,
models,
selectedModel,
selectedModelChoice,
defaultModel,
messages,
loading,
loadingModels,
@@ -65,20 +94,27 @@ export function GeoAssistantPanel({
<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>
<ModelSelector
label="AI-model"
value={selectedModelChoice}
options={models.map(toAssistantModelOption)}
automaticOption={defaultModel ? {
id: 'automatic',
name: 'Automatisch aanbevolen',
description: `GeoIntel gebruikt ${friendlyModelName(defaultModel)}, de beschikbare serverstandaard.`,
recommendation: 'Geschikt wanneer u geen technische modelkeuze wilt maken.',
status: 'available',
statusLabel: 'Aanbevolen',
tone: 'recommended',
environment: 'Lokale server',
} : undefined}
onChange={setSelectedModel}
loading={loadingModels}
error={error}
disabled={!status?.reachable}
onRefresh={() => void loadModels()}
advancedLabel="Geavanceerde modelkeuze"
/>
</div>
{!selectedProjectId ? (
@@ -14,6 +14,8 @@ import type { DetectionCalibrationRunRow, DetectionWorkflowStage } from '../../h
import { DETECTION_OPERATOR_PROFILES, type DetectionOperatorProfile } from './detectionProfiles'
import { DetectionModelManagement, detectionModelLabel } from './DetectionModelManagement'
import { AiPipelineIllustration } from './AiPipelineIllustration'
import { ModelSelector } from '../models/ModelSelector'
import { toAnalysisModelOption } from '../models/modelOptions'
const DETECTION_PAGE_SIZE_OPTIONS = [25, 50, 100] as const
const DEFAULT_DETECTION_PAGE_SIZE = 50
@@ -461,16 +463,15 @@ export function DetectionLab({
))}
</select>
</label>
<label>
Analysemodel
<select value={selectedDetectionModelId} onChange={(event) => onSelectModel(event.target.value)}>
{detectionModels.map((model) => (
<option key={model.model_id} value={model.model_id}>
{detectionModelLabel(model)}
</option>
))}
</select>
</label>
<ModelSelector
label="Analysemodel"
value={selectedDetectionModelId}
options={detectionModels.map(toAnalysisModelOption)}
onChange={onSelectModel}
loading={loadingDetectionModels}
error={detectionModelError}
onRefresh={onLoadModels}
/>
<label>
Minimale zekerheid
<input
+18 -2
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
import { BoxSelect, ChevronLeft, ChevronRight, MapPinned, Play, SlidersHorizontal, Trash2 } from 'lucide-react'
import { BoxSelect, ChevronLeft, ChevronRight, MapPinned, Play, Search, SlidersHorizontal, Trash2 } from 'lucide-react'
import GeoMap from '../GeoMap'
import type { AreaRead, CoverageResolveResponse, CoverageStatus, DatasetCreateResponse, DetectionQaResult, MapResultExportRequest, MapViewportState, OrthophotoAcquisitionResult, OrthophotoProductRead, ProjectRead, QaComparisonResult, VectorSelectionBBox, VectorSelectionMetric, VectorSelectionResponse } from '../../types'
import { useMapThemeSelectionInsights, type MapThemeAcquisition, type MapThemeQuery } from '../../hooks/useMapThemeSelectionInsights'
@@ -841,6 +841,7 @@ export function MapWorkspace({
return themeIdForDataset(selectedDataset) ?? 'buildings'
})
const [selectedThemeIds, setSelectedThemeIds] = useState<DataThemeId[]>([])
const [themeFilter, setThemeFilter] = useState('')
const [resultsPanelOpen, setResultsPanelOpen] = useState(false)
const activeScopeProject = projects.find((project) => project.id === selectedProjectId) ?? null
const activeScopeLabel = activeScopeProject ? operationalScopeProjectLabel(activeScopeProject) : 'Werkgebied'
@@ -1313,6 +1314,11 @@ export function MapWorkspace({
const regionalOnDemandThemeActive = regionalScopeSelected && onDemandThemeActive
const activeThemeAvailable = Boolean(activeThemeDataset) || onDemandThemeActive
const visibleThemes = useMemo(() => {
const query = themeFilter.trim().toLocaleLowerCase('nl-BE')
if (!query) return DATA_THEMES
return DATA_THEMES.filter((theme) => theme.label.toLocaleLowerCase('nl-BE').includes(query))
}, [themeFilter])
const selectedThemes = useMemo(
() => DATA_THEMES.filter((theme) => selectedThemeIds.includes(theme.id)),
[selectedThemeIds],
@@ -2296,8 +2302,18 @@ export function MapWorkspace({
: 'Zoek optioneel een gemeente of teken vrij op de kaart'}
</small>
</div>
<label className="geo-theme-search">
<span className="sr-only">Zoek een thema of gegevensbron</span>
<Search aria-hidden="true" />
<input
type="search"
value={themeFilter}
onChange={(event) => setThemeFilter(event.target.value)}
placeholder="Zoek thema’s"
/>
</label>
<div className="geo-theme-list">
{DATA_THEMES.map((theme) => {
{visibleThemes.map((theme) => {
const dataset = themeDatasetMap[theme.id]
const onDemandProduct = onDemandProductMap.get(theme.id)
const temporalGroups = themeTemporalSeriesMap[theme.id]
@@ -0,0 +1,33 @@
import { cleanup, render } from '@testing-library/react'
import { fireEvent, screen } from '@testing-library/dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ModelSelector, type ModelSelectionOption } from './ModelSelector'
const options: ModelSelectionOption[] = [
{ id: 'fast', name: 'Snel lokaal model', description: 'Voor korte vragen.', status: 'available', tone: 'fast', technicalName: 'qwen:9b' },
{ id: 'offline', name: 'Niet geconfigureerd', status: 'unavailable', statusLabel: 'Niet beschikbaar' },
]
describe('ModelSelector', () => {
beforeEach(() => {
HTMLDialogElement.prototype.showModal = function showModal() { this.setAttribute('open', '') }
HTMLDialogElement.prototype.close = function close() { this.removeAttribute('open') }
})
afterEach(() => cleanup())
it('opens the selector and returns an available model choice', () => {
const onChange = vi.fn()
render(<ModelSelector label="AI-model" value="automatic" options={options} onChange={onChange} automaticOption={{ id: 'automatic', name: 'Automatisch aanbevolen', status: 'available', tone: 'recommended' }} />)
fireEvent.click(screen.getByRole('button', { name: /Automatisch aanbevolen/ }))
fireEvent.click(screen.getByText('Concrete modellen'))
fireEvent.click(screen.getByRole('radio', { name: /Snel lokaal model/ }))
expect(onChange).toHaveBeenCalledWith('fast')
})
it('keeps unavailable runtime models disabled', () => {
render(<ModelSelector label="Analysemodel" value="fast" options={options} onChange={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: /Snel lokaal model/ }))
fireEvent.click(screen.getByText('Concrete modellen'))
expect((screen.getByRole('radio', { name: /Niet geconfigureerd/ }) as HTMLButtonElement).disabled).toBe(true)
})
})
@@ -0,0 +1,172 @@
import { useEffect, useId, useMemo, useRef, useState } from 'react'
import { Bot, Check, ChevronDown, Cpu, Gauge, RefreshCw, ShieldCheck, Sparkles, X } from 'lucide-react'
export type ModelSelectionTone = 'recommended' | 'fast' | 'analytical' | 'specialized'
export interface ModelSelectionOption {
id: string
name: string
description?: string
recommendation?: string
status: 'available' | 'unavailable' | 'loading'
statusLabel?: string
tone?: ModelSelectionTone
provider?: string
environment?: string
speed?: string
capability?: string
context?: string
technicalName?: string
details?: string[]
}
interface ModelSelectorProps {
label: string
value: string
options: ModelSelectionOption[]
onChange: (value: string) => void
loading?: boolean
error?: string | null
disabled?: boolean
onRefresh?: () => void
automaticOption?: ModelSelectionOption
advancedLabel?: string
}
const toneLabels: Record<ModelSelectionTone, string> = {
recommended: 'Aanbevolen',
fast: 'Snel',
analytical: 'Analytisch',
specialized: 'Gespecialiseerd',
}
export function ModelSelector({
label,
value,
options,
onChange,
loading = false,
error = null,
disabled = false,
onRefresh,
automaticOption,
advancedLabel = 'Concrete modellen',
}: ModelSelectorProps): JSX.Element {
const dialogRef = useRef<HTMLDialogElement>(null)
const titleId = useId()
const [showAdvanced, setShowAdvanced] = useState(false)
const allOptions = useMemo(
() => automaticOption ? [automaticOption, ...options] : options,
[automaticOption, options],
)
const selected = allOptions.find((option) => option.id === value)
?? allOptions.find((option) => option.status === 'available')
?? null
useEffect(() => {
if (!dialogRef.current?.open) return
const selectedButton = dialogRef.current.querySelector<HTMLElement>('[aria-checked="true"]')
selectedButton?.focus()
}, [showAdvanced])
const select = (option: ModelSelectionOption) => {
if (option.status !== 'available') return
onChange(option.id)
dialogRef.current?.close()
}
return (
<div className="model-selector">
<span className="model-selector-label">{label}</span>
<button
type="button"
className="model-selector-trigger"
aria-haspopup="dialog"
aria-expanded={dialogRef.current?.open ?? false}
disabled={disabled || loading || allOptions.length === 0}
onClick={() => dialogRef.current?.showModal()}
>
<span className="model-selector-trigger-icon"><Bot aria-hidden="true" /></span>
<span>
<strong>{loading ? 'Modellen controleren…' : selected?.name ?? 'Geen model beschikbaar'}</strong>
<small>{selected?.description ?? (error ? 'Modelinformatie kon niet worden geladen' : 'Kies een beschikbaar model')}</small>
</span>
<ChevronDown aria-hidden="true" />
</button>
<dialog ref={dialogRef} className="model-selector-dialog" aria-labelledby={titleId}>
<div className="model-selector-dialog-header">
<div>
<span className="section-kicker">Taakgerichte modelkeuze</span>
<h2 id={titleId}>Kies hoe GeoIntel analyseert</h2>
<p>GeoIntel toont alleen modellen die door de huidige omgeving worden gerapporteerd.</p>
</div>
<button type="button" className="icon-action" aria-label="Modelkeuze sluiten" onClick={() => dialogRef.current?.close()}>
<X aria-hidden="true" />
</button>
</div>
{error ? <div className="result-state result-state-error" role="alert"><strong>Modellen niet beschikbaar</strong><p>{error}</p></div> : null}
{loading ? <div className="model-selector-loading" role="status"><RefreshCw aria-hidden="true" /><span>Beschikbaarheid wordt gecontroleerd…</span></div> : null}
{!loading && !error && allOptions.length === 0 ? <div className="result-state result-state-empty"><strong>Geen bruikbaar model gevonden</strong><p>Controleer de lokale modelomgeving en probeer opnieuw.</p></div> : null}
<div className="model-selector-options" role="radiogroup" aria-label={label}>
{automaticOption ? <ModelOptionCard option={automaticOption} checked={value === automaticOption.id} onSelect={select} /> : null}
{options.length > 0 ? (
<details className="model-selector-advanced" open={showAdvanced} onToggle={(event) => setShowAdvanced(event.currentTarget.open)}>
<summary><span>{advancedLabel}</span><small>{options.filter((option) => option.status === 'available').length} beschikbaar</small></summary>
<div className="model-selector-advanced-grid">
{options.map((option) => <ModelOptionCard key={option.id} option={option} checked={value === option.id} onSelect={select} />)}
</div>
</details>
) : null}
</div>
<div className="model-selector-dialog-footer">
<span><ShieldCheck aria-hidden="true" /> Beschikbaarheid komt rechtstreeks uit de GeoIntel-runtime.</span>
{onRefresh ? <button type="button" className="secondary-action" onClick={onRefresh} disabled={loading}><RefreshCw aria-hidden="true" /> Opnieuw controleren</button> : null}
</div>
</dialog>
</div>
)
}
function ModelOptionCard({ option, checked, onSelect }: { option: ModelSelectionOption; checked: boolean; onSelect: (option: ModelSelectionOption) => void }): JSX.Element {
const unavailable = option.status !== 'available'
const tone = option.tone ?? 'specialized'
const hasTechnicalDetails = Boolean(option.technicalName || option.provider || option.details?.length)
return (
<div className={`model-option-card model-option-${tone}${checked ? ' model-option-selected' : ''}`}>
<button
type="button"
className="model-option-select"
role="radio"
aria-checked={checked}
disabled={unavailable}
onClick={() => onSelect(option)}
>
<span className="model-option-icon">{tone === 'recommended' ? <Sparkles aria-hidden="true" /> : tone === 'fast' ? <Gauge aria-hidden="true" /> : <Cpu aria-hidden="true" />}</span>
<span className="model-option-copy">
<span className="model-option-heading"><strong>{option.name}</strong><em>{option.statusLabel ?? (unavailable ? 'Niet beschikbaar' : toneLabels[tone])}</em></span>
{option.description ? <span>{option.description}</span> : null}
{option.recommendation ? <small>{option.recommendation}</small> : null}
<span className="model-option-facts">
{option.speed ? <small>Snelheid: {option.speed}</small> : null}
{option.capability ? <small>Analyse: {option.capability}</small> : null}
{option.context ? <small>Context: {option.context}</small> : null}
{option.environment ? <small>{option.environment}</small> : null}
</span>
</span>
<span className="model-option-check">{checked ? <Check aria-hidden="true" /> : null}</span>
</button>
{hasTechnicalDetails ? (
<details className="model-option-technical">
<summary>Technische informatie</summary>
{option.technicalName ? <small>Model: {option.technicalName}</small> : null}
{option.provider ? <small>Runtime: {option.provider}</small> : null}
{option.details?.map((detail) => <small key={detail}>{detail}</small>)}
</details>
) : null}
</div>
)
}
@@ -0,0 +1,27 @@
import type { DetectionModelCapability } from '../../types'
import type { ModelSelectionOption } from './ModelSelector'
export function toAnalysisModelOption(model: DetectionModelCapability): ModelSelectionOption {
const task = model.task_type === 'segmentation' ? 'segmentatie' : 'objectdetectie'
const configured = model.configured && model.status !== 'not_configured'
return {
id: model.model_id,
name: model.display_name,
description: configured
? `Beschikbaar voor lokale ${task}${model.supported_classes.length ? ` van ${model.supported_classes.join(', ')}` : ''}.`
: model.limitation_message,
recommendation: model.validation_scope ? `Gevalideerd voor ${model.validation_scope}.` : undefined,
status: configured ? 'available' : 'unavailable',
statusLabel: configured ? 'Beschikbaar' : 'Niet geconfigureerd',
tone: model.nationally_validated ? 'recommended' : model.operator_review_required ? 'specialized' : 'analytical',
provider: model.framework,
environment: 'Servermodel',
capability: model.training_scope ?? task,
technicalName: model.model_id,
details: [
model.version ? `Versie: ${model.version}` : '',
model.validated_regions.length ? `Gevalideerde regio’s: ${model.validated_regions.join(', ')}` : '',
model.operator_review_required ? 'Operatorcontrole vereist' : '',
].filter(Boolean),
}
}
@@ -6,6 +6,8 @@ import type {
SegmentationRunRead,
SegmentationRunResponse,
} from '../../types'
import { ModelSelector } from '../models/ModelSelector'
import { toAnalysisModelOption } from '../models/modelOptions'
interface SegmentationLabProps {
segmentationModels: SegmentationModelCapability[]
@@ -253,16 +255,15 @@ export function SegmentationLab({
))}
</select>
</label>
<label>
Analysemodel
<select value={selectedSegmentationModelId} onChange={(event) => onSelectModel(event.target.value)}>
{segmentationModels.map((model) => (
<option key={model.model_id} value={model.model_id}>
{model.display_name}
</option>
))}
</select>
</label>
<ModelSelector
label="Analysemodel"
value={selectedSegmentationModelId}
options={segmentationModels.map(toAnalysisModelOption)}
onChange={onSelectModel}
loading={loadingSegmentationModels}
error={segmentationModelError}
onRefresh={onLoadModels}
/>
<label>
Minimale zekerheid
<input