Overhaul GeoIntel model selection and map UX
This commit is contained in:
@@ -12195,4 +12195,26 @@ Open:
|
||||
- Production smoke passed: container healthy, PostGIS 3.6, Alembic `202607260001 (head)`, frontend/API/icon runtime verification green.
|
||||
- Mobile production route at 415 x 899 verified: selecting the work area does not start analysis; `Analyseer selectie` remains disabled until a theme/model is chosen; one chosen theme yields `1/1 uitgelezen`; the slide-out results drawer opens and closes through its accessible toggle.
|
||||
- Layout acceptance: document horizontal overflow `0`; theme-panel horizontal overflow `0`; browser console errors and warnings `0`.
|
||||
- Corrected a global `button:active` transform collision that moved the mobile drawer toggle between pointer-down and pointer-up.
|
||||
- Corrected a global `button:active` transform collision that moved the mobile drawer toggle between pointer-down and pointer-up.
|
||||
## 2026-08-01 - Sprint 236 platformbrede UI/UX-herwerking
|
||||
|
||||
### Gewijzigd
|
||||
- Centrale `ModelSelector` met native dialoog, eenvoudige aanbevolen keuze, geavanceerde concrete modellen, echte runtimebeschikbaarheid en optionele technische details.
|
||||
- AI-vragen bewaren een versiegebonden voorkeur; een verdwenen model valt veilig terug op de beschikbare serverstandaard zonder een ongeldige model-ID te verzenden.
|
||||
- Detectie en segmentatie gebruiken hetzelfde selectiepatroon en behouden hun bestaande API-contracten en `not_configured`-gedrag.
|
||||
- Kaartwerkruimte kreeg themazoeken, een bredere leesbare configuratiekolom, rustigere contextbalk en responsive panelafmetingen.
|
||||
- Interactieve elementen zijn semantisch gescheiden; focus, Escape, native dialoogfocus, reduced motion en mobiele bottom-sheetpresentatie zijn voorzien.
|
||||
|
||||
### Getest
|
||||
- Frontend: 16 testbestanden, 51 tests geslaagd.
|
||||
- Frontend: TypeScript- en Vite-productiebuild geslaagd; `git diff --check` geslaagd.
|
||||
- Backend: 1.178 tests geslaagd; 19 bestaande stringgebaseerde contracttests falen op eerder gewijzigde repositoryverwachtingen. Meerdere verwachten opnieuw automatische kaartanalyse en mogen daarom niet worden hersteld zonder de actuele expliciete-startbeslissing te breken.
|
||||
|
||||
### Open / beperking
|
||||
- Visuele browseracceptatie en productie-uitrol volgen op de gecommitte wijziging tegen de echte serverruntime; de lokale frontend kan zonder backend-sessie alleen de voorbereidingsstatus tonen.
|
||||
### Visuele acceptatie
|
||||
- Echte serverdata via de lokale frontendpreview gevalideerd op 1920x1080, 1024x768 en 390x844.
|
||||
- Desktop, tablet en mobiel: documentoverflow `0`; themapaneeloverflow `0`.
|
||||
- Mobiele hoofdflow: thema zoeken, kiezen, volledig werkgebied selecteren, expliciet analyseren, resultatenlade openen en sluiten geslaagd.
|
||||
- Browserconsole: `0` waarschuwingen en `0` fouten.
|
||||
- Bewijsbeelden: `docs/screenshots/ui-ux-map-desktop-2026-08-01.jpg` en `docs/screenshots/ui-ux-map-mobile-results-2026-08-01.jpg`.
|
||||
+12
-1
@@ -1054,4 +1054,15 @@ This file now starts with the current implementation status. Older preparation/b
|
||||
- [x] Beperk de analysequery tot uitsluitend de gekozen thema’s.
|
||||
- [x] Herstel Inzichten als open- en sluitbare lade boven de kaart.
|
||||
- [x] Verkort bronteksten en voorkom overflow in de themakolom.
|
||||
- [x] Verifieer TypeScript-build, frontend-unitset en responsive runtime visueel.
|
||||
- [x] Verifieer TypeScript-build, frontend-unitset en responsive runtime visueel.
|
||||
## Sprint 236 - Platformbrede UI/UX-herwerking (2026-08-01)
|
||||
|
||||
- [x] Eén herbruikbare taakgerichte modelselector voor lokale AI, detectie en segmentatie.
|
||||
- [x] Automatisch aanbevolen lokale assistentkeuze met persistente, gevalideerde voorkeur en runtimefallback.
|
||||
- [x] Alleen werkelijk door backend/runtime gerapporteerde modellen en metadata tonen.
|
||||
- [x] Loading-, empty-, unavailable- en errorstates plus toetsenbord- en dialogbediening.
|
||||
- [x] Zoekbare en bredere kaartthemalijst met volledig leesbare labels.
|
||||
- [x] Compacte analysecontextbalk en rustige desktop/tablet/mobiele hiërarchie.
|
||||
- [x] Uitschuifbare inzichten behouden; analyse blijft uitsluitend expliciet na themakeuze.
|
||||
- [x] 51 frontendtests en productiebuild groen.
|
||||
- [ ] 19 verouderde broncode-stringtests herijken; meerdere eisen daarin (automatische analyse) conflicteren bewust met de actuele productbeslissing.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 279 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { formatError } from '../lib/formatError'
|
||||
import { assistantApi } from '../services/api/assistant'
|
||||
import type {
|
||||
@@ -20,6 +20,7 @@ interface UseGeoAssistantOptions {
|
||||
selectionBbox: VectorSelectionBBox | null
|
||||
}
|
||||
|
||||
const ASSISTANT_MODEL_PREFERENCE_KEY = 'geointel.assistant.model-preference.v1'
|
||||
let assistantMessageSequence = 0
|
||||
|
||||
function nextAssistantMessageId(role: AssistantChatMessage['role']): string {
|
||||
@@ -27,15 +28,38 @@ function nextAssistantMessageId(role: AssistantChatMessage['role']): string {
|
||||
return `${role}-${Date.now()}-${assistantMessageSequence}`
|
||||
}
|
||||
|
||||
function readStoredPreference(): string {
|
||||
try {
|
||||
return window.localStorage.getItem(ASSISTANT_MODEL_PREFERENCE_KEY) || 'automatic'
|
||||
} catch {
|
||||
return 'automatic'
|
||||
}
|
||||
}
|
||||
|
||||
export function useGeoAssistant({ selectedProjectId, selectedAreaId, selectionBbox }: UseGeoAssistantOptions) {
|
||||
const [status, setStatus] = useState<AssistantStatus | null>(null)
|
||||
const [models, setModels] = useState<AssistantModelRead[]>([])
|
||||
const [selectedModel, setSelectedModel] = useState('')
|
||||
const [selectedModelChoice, setSelectedModelChoice] = useState(readStoredPreference)
|
||||
const [defaultModel, setDefaultModel] = useState('')
|
||||
const [messages, setMessages] = useState<GeoAssistantMessage[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loadingModels, setLoadingModels] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const selectedModel = useMemo(() => {
|
||||
const available = new Set(models.map((model) => model.name))
|
||||
if (selectedModelChoice !== 'automatic' && available.has(selectedModelChoice)) return selectedModelChoice
|
||||
if (defaultModel && available.has(defaultModel)) return defaultModel
|
||||
return models[0]?.name ?? ''
|
||||
}, [defaultModel, models, selectedModelChoice])
|
||||
|
||||
const setSelectedModel = (value: string) => {
|
||||
const valid = value === 'automatic' || models.some((model) => model.name === value)
|
||||
const next = valid ? value : 'automatic'
|
||||
setSelectedModelChoice(next)
|
||||
try { window.localStorage.setItem(ASSISTANT_MODEL_PREFERENCE_KEY, next) } catch { /* voorkeur blijft sessielokaal */ }
|
||||
}
|
||||
|
||||
const loadModels = async () => {
|
||||
setLoadingModels(true)
|
||||
setError(null)
|
||||
@@ -44,33 +68,28 @@ export function useGeoAssistant({ selectedProjectId, selectedAreaId, selectionBb
|
||||
setStatus(currentStatus)
|
||||
if (!currentStatus.enabled || !currentStatus.reachable) {
|
||||
setModels([])
|
||||
setSelectedModel('')
|
||||
setDefaultModel('')
|
||||
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 ?? ''
|
||||
})
|
||||
const fallback = result.default_model && result.items.some((model) => model.name === result.default_model)
|
||||
? result.default_model
|
||||
: result.items[0]?.name ?? ''
|
||||
setDefaultModel(fallback)
|
||||
setSelectedModelChoice((current) => current === 'automatic' || result.items.some((model) => model.name === current) ? current : 'automatic')
|
||||
} catch (requestError) {
|
||||
setStatus(null)
|
||||
setModels([])
|
||||
setDefaultModel('')
|
||||
setError(formatError(requestError, 'De lokale AI-assistent kon niet worden bereikt.'))
|
||||
} finally {
|
||||
setLoadingModels(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadModels()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setMessages([])
|
||||
setError(null)
|
||||
}, [selectedProjectId])
|
||||
useEffect(() => { void loadModels() }, [])
|
||||
useEffect(() => { setMessages([]); setError(null) }, [selectedProjectId])
|
||||
|
||||
const ask = async (question: string): Promise<boolean> => {
|
||||
const trimmed = question.trim()
|
||||
@@ -88,10 +107,7 @@ export function useGeoAssistant({ selectedProjectId, selectedAreaId, selectionBb
|
||||
area_id: selectedAreaId,
|
||||
history,
|
||||
})
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
{ id: nextAssistantMessageId('assistant'), role: 'assistant', content: result.answer, response: result },
|
||||
])
|
||||
setMessages((current) => [...current, { id: nextAssistantMessageId('assistant'), role: 'assistant', content: result.answer, response: result }])
|
||||
return true
|
||||
} catch (requestError) {
|
||||
setError(formatError(requestError, 'GeoIntel kon de vraag niet beantwoorden.'))
|
||||
@@ -101,22 +117,7 @@ export function useGeoAssistant({ selectedProjectId, selectedAreaId, selectionBb
|
||||
}
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
setMessages([])
|
||||
setError(null)
|
||||
}
|
||||
const clear = () => { setMessages([]); setError(null) }
|
||||
|
||||
return {
|
||||
status,
|
||||
models,
|
||||
selectedModel,
|
||||
messages,
|
||||
loading,
|
||||
loadingModels,
|
||||
error,
|
||||
loadModels,
|
||||
ask,
|
||||
clear,
|
||||
setSelectedModel,
|
||||
}
|
||||
}
|
||||
return { status, models, selectedModel, selectedModelChoice, defaultModel, messages, loading, loadingModels, error, loadModels, ask, clear, setSelectedModel }
|
||||
}
|
||||
@@ -1826,3 +1826,270 @@ button.overview-command-card { cursor: pointer; }
|
||||
overflow-wrap: anywhere;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Central task-oriented model selector */
|
||||
.model-selector {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.model-selector-label {
|
||||
color: var(--gi-text-muted);
|
||||
font-size: var(--gi-text-2xs);
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.model-selector-trigger {
|
||||
display: grid;
|
||||
grid-template-columns: 2.25rem minmax(0, 1fr) auto;
|
||||
gap: 0.65rem;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 3.25rem;
|
||||
border: 1px solid var(--gi-line-strong);
|
||||
border-radius: var(--gi-radius-md);
|
||||
padding: 0.45rem 0.6rem;
|
||||
background: var(--gi-surface);
|
||||
color: var(--gi-text);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.model-selector-trigger:hover:not(:disabled) { border-color: var(--gi-brand-500); background: var(--gi-brand-50); }
|
||||
.model-selector-trigger:focus-visible { outline: 3px solid color-mix(in srgb, var(--gi-brand-500) 28%, transparent); outline-offset: 2px; }
|
||||
.model-selector-trigger > span:nth-child(2) { display: grid; gap: 0.08rem; min-width: 0; }
|
||||
.model-selector-trigger strong,
|
||||
.model-selector-trigger small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.model-selector-trigger strong { font-size: var(--gi-text-sm); }
|
||||
.model-selector-trigger small { color: var(--gi-text-muted); font-size: var(--gi-text-xs); }
|
||||
.model-selector-trigger > svg { width: 1rem; color: var(--gi-text-muted); }
|
||||
.model-selector-trigger-icon { display: grid; width: 2.25rem; height: 2.25rem; place-items: center; border-radius: var(--gi-radius-sm); background: var(--gi-brand-100); color: var(--gi-brand-700); }
|
||||
.model-selector-trigger-icon svg { width: 1.05rem; }
|
||||
|
||||
.model-selector-dialog {
|
||||
width: min(46rem, calc(100vw - 2rem));
|
||||
max-height: min(47rem, calc(100dvh - 2rem));
|
||||
border: 0;
|
||||
border-radius: var(--gi-radius-xl);
|
||||
padding: 0;
|
||||
background: var(--gi-surface);
|
||||
color: var(--gi-text);
|
||||
box-shadow: 0 1.5rem 4rem rgba(4, 45, 39, 0.24);
|
||||
}
|
||||
.model-selector-dialog::backdrop { background: rgba(3, 27, 24, 0.52); backdrop-filter: blur(3px); }
|
||||
.model-selector-dialog-header { display: flex; gap: 1rem; align-items: flex-start; justify-content: space-between; border-bottom: 1px solid var(--gi-line); padding: 1.15rem 1.25rem 1rem; }
|
||||
.model-selector-dialog-header h2 { margin: 0.15rem 0 0; font-size: clamp(1.15rem, 3vw, 1.45rem); }
|
||||
.model-selector-dialog-header p { max-width: 34rem; margin: 0.3rem 0 0; color: var(--gi-text-muted); font-size: var(--gi-text-sm); }
|
||||
.model-selector-dialog .icon-action { flex: 0 0 auto; min-width: 2.75rem; min-height: 2.75rem; }
|
||||
.model-selector-options { display: grid; gap: 0.65rem; max-height: min(31rem, calc(100dvh - 15rem)); overflow-y: auto; padding: 1rem 1.25rem; }
|
||||
.model-selector-advanced { border-top: 1px solid var(--gi-line); padding-top: 0.65rem; }
|
||||
.model-selector-advanced > summary { display: flex; justify-content: space-between; min-height: 2.75rem; align-items: center; color: var(--gi-brand-800); font-weight: 750; cursor: pointer; }
|
||||
.model-selector-advanced > summary small { color: var(--gi-text-muted); font-weight: 600; }
|
||||
.model-selector-advanced-grid { display: grid; gap: 0.55rem; padding-top: 0.45rem; }
|
||||
|
||||
.model-option-card {
|
||||
display: grid;
|
||||
grid-template-columns: 2.5rem minmax(0, 1fr) 1.5rem;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
min-height: 5.25rem;
|
||||
border: 1px solid var(--gi-line);
|
||||
border-radius: var(--gi-radius-lg);
|
||||
padding: 0.8rem;
|
||||
background: var(--gi-surface);
|
||||
color: var(--gi-text);
|
||||
text-align: left;
|
||||
}
|
||||
.model-option-card:hover:not(:disabled) { border-color: var(--gi-brand-400); background: var(--gi-brand-50); }
|
||||
.model-option-card:focus-visible { outline: 3px solid color-mix(in srgb, var(--gi-brand-500) 30%, transparent); outline-offset: 2px; }
|
||||
.model-option-card:disabled { cursor: not-allowed; opacity: 0.62; }
|
||||
.model-option-selected { border-color: var(--gi-brand-600); box-shadow: inset 3px 0 0 var(--gi-brand-600); }
|
||||
.model-option-icon { display: grid; width: 2.5rem; height: 2.5rem; place-items: center; border-radius: var(--gi-radius-md); background: var(--gi-brand-100); color: var(--gi-brand-700); }
|
||||
.model-option-icon svg { width: 1.15rem; }
|
||||
.model-option-copy { display: grid; gap: 0.25rem; min-width: 0; }
|
||||
.model-option-copy > span:not(.model-option-heading):not(.model-option-facts) { color: var(--gi-text-muted); font-size: var(--gi-text-sm); }
|
||||
.model-option-copy > small { color: var(--gi-text); font-size: var(--gi-text-xs); }
|
||||
.model-option-heading { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; justify-content: space-between; }
|
||||
.model-option-heading strong { font-size: var(--gi-text-md); }
|
||||
.model-option-heading em { border-radius: 999px; padding: 0.15rem 0.42rem; background: var(--gi-brand-100); color: var(--gi-brand-800); font-size: 0.62rem; font-style: normal; font-weight: 750; }
|
||||
.model-option-facts { display: flex; flex-wrap: wrap; gap: 0.3rem 0.7rem; margin-top: 0.15rem; color: var(--gi-text-muted); }
|
||||
.model-option-facts small { font-size: 0.68rem; }
|
||||
.model-option-technical { margin-top: 0.2rem; }
|
||||
.model-option-technical summary { width: fit-content; color: var(--gi-brand-700); font-size: 0.68rem; font-weight: 700; cursor: pointer; }
|
||||
.model-option-technical small { display: block; margin-top: 0.2rem; color: var(--gi-text-muted); font-size: 0.65rem; }
|
||||
.model-option-check { display: grid; width: 1.5rem; height: 1.5rem; place-items: center; align-self: center; border: 1px solid var(--gi-line-strong); border-radius: 50%; color: var(--gi-brand-700); }
|
||||
.model-option-selected .model-option-check { border-color: var(--gi-brand-600); background: var(--gi-brand-100); }
|
||||
.model-option-check svg { width: 0.9rem; }
|
||||
.model-selector-dialog-footer { display: flex; gap: 0.75rem; align-items: center; justify-content: space-between; border-top: 1px solid var(--gi-line); padding: 0.75rem 1.25rem; background: var(--gi-surface-muted); }
|
||||
.model-selector-dialog-footer > span { display: flex; gap: 0.4rem; align-items: center; color: var(--gi-text-muted); font-size: var(--gi-text-xs); }
|
||||
.model-selector-dialog-footer svg { width: 0.95rem; }
|
||||
.model-selector-loading { display: flex; gap: 0.5rem; align-items: center; padding: 1rem 1.25rem; color: var(--gi-text-muted); }
|
||||
.model-selector-loading svg { width: 1rem; animation: gi-spin 1s linear infinite; }
|
||||
|
||||
.assistant-context-strip { grid-template-columns: minmax(11rem, 0.8fr) minmax(18rem, 1.3fr) !important; }
|
||||
.assistant-context-strip .model-selector { min-width: 0; }
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.model-selector-dialog { width: 100%; max-width: none; max-height: 88dvh; margin: auto 0 0; border-radius: var(--gi-radius-xl) var(--gi-radius-xl) 0 0; }
|
||||
.model-selector-options { max-height: calc(88dvh - 13rem); padding-inline: 0.8rem; }
|
||||
.model-selector-dialog-header, .model-selector-dialog-footer { padding-inline: 0.9rem; }
|
||||
.model-selector-dialog-footer { align-items: stretch; flex-direction: column; }
|
||||
.model-selector-dialog-footer .secondary-action { width: 100%; min-height: 2.75rem; }
|
||||
.model-option-card { grid-template-columns: 2.25rem minmax(0, 1fr) 1.35rem; gap: 0.55rem; padding: 0.7rem; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.model-selector-loading svg { animation: none; }
|
||||
}
|
||||
/* Calm analysis context and readable theme navigation */
|
||||
.workbench-topbar {
|
||||
gap: var(--gi-space-2);
|
||||
padding-block: 0.35rem;
|
||||
}
|
||||
|
||||
.context-bar {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
align-items: center;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.context-bar > div,
|
||||
.context-bar > div:first-child {
|
||||
position: relative;
|
||||
display: grid;
|
||||
flex: 0 1 auto;
|
||||
grid-template-columns: auto minmax(0, auto);
|
||||
gap: 0.25rem;
|
||||
align-items: baseline;
|
||||
max-width: min(22rem, 28vw);
|
||||
min-height: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.context-bar > div + div::before {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
width: 1px;
|
||||
height: 1.4rem;
|
||||
background: var(--gi-line);
|
||||
content: '';
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.context-bar span { font-size: 0.58rem !important; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; }
|
||||
.context-bar strong { max-width: 18rem; overflow: hidden; margin: 0; font-size: var(--gi-text-xs); text-overflow: ellipsis; white-space: nowrap; }
|
||||
.context-health { min-width: auto; border: 0; padding-inline: 0.45rem; background: transparent; box-shadow: none; }
|
||||
.context-account { border-color: var(--gi-line); box-shadow: none; }
|
||||
|
||||
.geo-explorer-layout,
|
||||
.workbench-shell-guest .geo-explorer-layout {
|
||||
grid-template-columns: clamp(18rem, 18vw, 22rem) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.geo-theme-search {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 0.45rem;
|
||||
align-items: center;
|
||||
margin: 0 var(--gi-space-3) var(--gi-space-2);
|
||||
border: 1px solid var(--gi-line);
|
||||
border-radius: var(--gi-radius-md);
|
||||
padding: 0.45rem 0.6rem;
|
||||
background: var(--gi-surface);
|
||||
}
|
||||
.geo-theme-search:focus-within { border-color: var(--gi-brand-500); box-shadow: var(--gi-focus-ring); }
|
||||
.geo-theme-search svg { flex: 0 0 auto; width: 0.95rem; color: var(--gi-text-muted); }
|
||||
.geo-theme-search input { width: 100%; min-width: 0; border: 0; padding: 0; background: transparent; font: inherit; font-size: var(--gi-text-xs); outline: 0; }
|
||||
|
||||
.geo-theme-option > span:nth-child(2) > :where(strong, small) {
|
||||
overflow: visible;
|
||||
overflow-wrap: anywhere;
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
}
|
||||
.geo-theme-option > span:nth-child(2) { display: grid; gap: 0.12rem; }
|
||||
.geo-theme-option > span:nth-child(2) strong { line-height: 1.25; }
|
||||
.geo-theme-option > span:nth-child(2) small { line-height: 1.3; }
|
||||
.geo-theme-list { scrollbar-gutter: stable; }
|
||||
.geo-loaded-scope small { line-height: 1.45; }
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.context-bar > div:nth-child(n+3) { display: none; }
|
||||
.geo-explorer-layout,
|
||||
.workbench-shell-guest .geo-explorer-layout { grid-template-columns: clamp(17rem, 29vw, 20rem) minmax(0, 1fr); }
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.workbench-topbar { grid-template-columns: minmax(0, 1fr) auto; }
|
||||
.context-health { display: none; }
|
||||
.context-bar > div { max-width: 46vw; padding-inline: 0.45rem; }
|
||||
.context-bar > div:nth-child(n+2) { display: none; }
|
||||
.geo-explorer-layout,
|
||||
.workbench-shell-guest .geo-explorer-layout { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(28rem, 68vh); }
|
||||
.geo-theme-panel { max-height: min(22rem, 42dvh); }
|
||||
.geo-theme-list { max-height: 12rem; }
|
||||
}
|
||||
/* Semantic model-card split: selection button and optional disclosure are siblings. */
|
||||
.model-option-card { display: block; min-height: 0; padding: 0; overflow: hidden; }
|
||||
.model-option-select { display: grid; grid-template-columns: 2.5rem minmax(0, 1fr) 1.5rem; gap: 0.75rem; width: 100%; min-height: 5.25rem; border: 0; padding: 0.8rem; background: transparent; color: inherit; text-align: left; }
|
||||
.model-option-card:hover:has(.model-option-select:not(:disabled)) { border-color: var(--gi-brand-400); background: var(--gi-brand-50); }
|
||||
.model-option-select:focus-visible { outline: 3px solid color-mix(in srgb, var(--gi-brand-500) 30%, transparent); outline-offset: -3px; }
|
||||
.model-option-select:disabled { cursor: not-allowed; opacity: 0.62; }
|
||||
.model-option-card > .model-option-technical { border-top: 1px solid var(--gi-line); margin: 0; padding: 0.45rem 0.8rem 0.55rem 4.05rem; background: color-mix(in srgb, var(--gi-surface-muted) 64%, transparent); }
|
||||
@media (max-width: 700px) { .model-option-select { grid-template-columns: 2.25rem minmax(0, 1fr) 1.35rem; gap: 0.55rem; padding: 0.7rem; } .model-option-card > .model-option-technical { padding-left: 3.5rem; } }
|
||||
/* Smartphone: preserve a usable map viewport above the fold. */
|
||||
@media (max-width: 480px) {
|
||||
.workbench-layout { grid-template-rows: auto minmax(0, 1fr); }
|
||||
.workbench-sidebar { min-height: 4.25rem; max-height: 4.25rem; padding: 0.3rem 0.55rem; }
|
||||
.workbench-sidebar .brand-block, .workbench-sidebar .itworx-signature { display: none; }
|
||||
.workbench-sidebar nav { flex: 1 1 auto; gap: 0.25rem; align-items: center; overflow: hidden; }
|
||||
.workbench-sidebar .nav-item { min-width: 4.35rem; min-height: 3.45rem; padding: 0.35rem 0.5rem; }
|
||||
.workbench-stage { min-height: calc(100dvh - 4.25rem); }
|
||||
.workbench-topbar { min-height: 3rem; padding: 0.3rem 0.65rem; }
|
||||
.workbench-topbar .context-bar, .workbench-topbar .context-health { display: none; }
|
||||
.mobile-brand { display: flex; }
|
||||
.context-account { justify-self: end; min-height: 2.4rem; border: 0; padding: 0; background: transparent; }
|
||||
.context-account > span { display: none; }
|
||||
.context-account button { min-width: 2.75rem; min-height: 2.75rem; padding: 0.45rem; }
|
||||
.context-account button span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
|
||||
.guest-mode-banner { min-height: 3.25rem; padding: 0.45rem 0.7rem; }
|
||||
.guest-mode-banner span:not(.guest-mode-badge) { display: none; }
|
||||
.guest-mode-badge { font-size: 0.58rem; }
|
||||
.workbench-content, .workbench-content > * { padding: 0 !important; }
|
||||
.geo-explorer { gap: 0; }
|
||||
.geo-explorer-header { padding: 0.55rem 0.7rem; }
|
||||
.geo-explorer-header p { max-width: 100%; line-height: 1.35; }
|
||||
.geo-readonly-notice { padding: 0.45rem 0.7rem; }
|
||||
.geo-readonly-notice span { display: none; }
|
||||
.geo-explorer-layout, .workbench-shell-guest .geo-explorer-layout { grid-template-rows: 12rem minmax(25rem, 66vh); }
|
||||
.geo-theme-panel { max-height: 12rem; }
|
||||
.geo-panel-heading { padding: 0.55rem 0.7rem; }
|
||||
.geo-panel-heading p { display: none; }
|
||||
.geo-loaded-scope { display: none; }
|
||||
.geo-theme-search { margin: 0 0.7rem 0.45rem; min-height: 2.6rem; }
|
||||
.geo-theme-list { max-height: 4.6rem; padding-inline: 0.7rem !important; }
|
||||
.geo-theme-option { min-height: 3.75rem; }
|
||||
.geo-theme-actions { grid-template-columns: minmax(0, 1fr) auto; gap: 0.45rem; align-items: center; padding: 0.45rem 0.7rem; }
|
||||
.geo-theme-actions > span { display: none; }
|
||||
.geo-theme-actions button { min-height: 2.75rem; }
|
||||
.geo-theme-actions small { grid-column: 1 / -1; }
|
||||
}@media (max-width: 480px) {
|
||||
.geo-theme-panel .geo-panel-heading { min-height: 2.4rem; padding-block: 0.35rem; }
|
||||
.geo-theme-panel .geo-panel-heading h3 { font-size: var(--gi-text-sm); }
|
||||
.geo-theme-panel .geo-source-summary { display: none; }
|
||||
.geo-theme-actions small { display: none; }
|
||||
}@media (max-width: 480px) {
|
||||
.geo-explorer-layout, .workbench-shell-guest .geo-explorer-layout { grid-template-rows: 15rem minmax(25rem, 62vh); }
|
||||
.geo-theme-panel { max-height: 15rem; }
|
||||
.geo-theme-list { max-height: 7.2rem; min-height: 4.75rem; }
|
||||
}
|
||||
Reference in New Issue
Block a user