- {DATA_THEMES.map((theme) => {
+ {visibleThemes.map((theme) => {
const dataset = themeDatasetMap[theme.id]
const onDemandProduct = onDemandProductMap.get(theme.id)
const temporalGroups = themeTemporalSeriesMap[theme.id]
diff --git a/frontend/src/components/models/ModelSelector.test.tsx b/frontend/src/components/models/ModelSelector.test.tsx
new file mode 100644
index 00000000..8c58788b
--- /dev/null
+++ b/frontend/src/components/models/ModelSelector.test.tsx
@@ -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(
)
+ 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(
)
+ 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)
+ })
+})
\ No newline at end of file
diff --git a/frontend/src/components/models/ModelSelector.tsx b/frontend/src/components/models/ModelSelector.tsx
new file mode 100644
index 00000000..fe990c77
--- /dev/null
+++ b/frontend/src/components/models/ModelSelector.tsx
@@ -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
= {
+ 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(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('[aria-checked="true"]')
+ selectedButton?.focus()
+ }, [showAdvanced])
+
+ const select = (option: ModelSelectionOption) => {
+ if (option.status !== 'available') return
+ onChange(option.id)
+ dialogRef.current?.close()
+ }
+
+ return (
+
+
{label}
+
+
+
+
+ )
+}
+
+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 (
+
+
+ {hasTechnicalDetails ? (
+
+ Technische informatie
+ {option.technicalName ? Model: {option.technicalName} : null}
+ {option.provider ? Runtime: {option.provider} : null}
+ {option.details?.map((detail) => {detail})}
+
+ ) : null}
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/components/models/modelOptions.ts b/frontend/src/components/models/modelOptions.ts
new file mode 100644
index 00000000..d784512d
--- /dev/null
+++ b/frontend/src/components/models/modelOptions.ts
@@ -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),
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/components/segmentation/SegmentationLab.tsx b/frontend/src/components/segmentation/SegmentationLab.tsx
index 7472a439..7a8e4fef 100644
--- a/frontend/src/components/segmentation/SegmentationLab.tsx
+++ b/frontend/src/components/segmentation/SegmentationLab.tsx
@@ -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({
))}
-
+