feat(ui): refine governed workbench and dual-screen flows

This commit is contained in:
Jens
2026-08-30 06:00:28 +02:00
parent 80a2d1654d
commit a0884d64c9
48 changed files with 1360 additions and 184 deletions
+51 -1
View File
@@ -94,6 +94,9 @@ async function layoutEvidence(page) {
const main = document.querySelector('.workbench-main')?.getBoundingClientRect()
const map = document.querySelector('.geo-map-stage')?.getBoundingClientRect()
const theme = document.querySelector('.geo-theme-panel')?.getBoundingClientRect()
const themeList = document.querySelector('.geo-theme-list')?.getBoundingClientRect()
const firstTheme = document.querySelector('.geo-theme-option')?.getBoundingClientRect()
const sourceSummary = document.querySelector('.geo-source-summary')?.getBoundingClientRect()
return {
viewport_width: window.innerWidth,
viewport_height: window.innerHeight,
@@ -108,6 +111,9 @@ async function layoutEvidence(page) {
main: main ? { left: main.left, right: main.right, width: main.width } : null,
map: map ? { left: map.left, right: map.right, width: map.width, height: map.height } : null,
theme: theme ? { left: theme.left, right: theme.right, width: theme.width } : null,
theme_list: themeList ? { top: themeList.top, bottom: themeList.bottom, height: themeList.height } : null,
first_theme: firstTheme ? { top: firstTheme.top, bottom: firstTheme.bottom, height: firstTheme.height } : null,
source_summary: sourceSummary ? { top: sourceSummary.top, bottom: sourceSummary.bottom, height: sourceSummary.height } : null,
}
})
}
@@ -177,12 +183,21 @@ async function runViewport(browser, baseUrl, outputDir, viewport) {
}
})
try {
await prepareAuditSession(page, baseUrl)
const auditSession = await prepareAuditSession(page, baseUrl)
const startedAt = Date.now()
await page.goto(baseUrl, { waitUntil: 'networkidle', timeout: 60_000 })
await page.getByTestId('map-workspace').waitFor({ state: 'visible', timeout: 30_000 })
const readyMs = Date.now() - startedAt
await auditInteractiveNames(page, `${viewport.width}px map explorer`)
if (auditSession?.role === 'guest') {
const exposedAcquisitionActions = await page.locator('.geo-theme-option').evaluateAll((buttons) => buttons
.filter((button) => button.textContent?.includes('Op aanvraag') && !button.disabled)
.map((button) => button.textContent?.trim() || ''))
assert(
exposedAcquisitionActions.length > 0,
'Guest UI must expose bounded official source acquisition inside the signed demo project',
)
}
const layout = await layoutEvidence(page)
const clippedNavigationLabels = await page.locator('.nav-item span').evaluateAll((labels) => labels
.filter((label) => label.getClientRects().length > 0 && label.scrollWidth > label.clientWidth + 1)
@@ -190,6 +205,16 @@ async function runViewport(browser, baseUrl, outputDir, viewport) {
assert.equal(layout.horizontal_overflow_px, 0, `${viewport.width}px layout overflows horizontally`)
assert.deepEqual(clippedNavigationLabels, [], `${viewport.width}px navigation clips visible labels`)
assert(layout.map && layout.map.width >= Math.min(320, viewport.width - 32), `${viewport.width}px map is too narrow`)
if (viewport.width === 1366 && layout.theme && layout.theme_list && layout.first_theme) {
assert(
layout.theme_list.height >= layout.first_theme.height,
'1366px theme list is shorter than one selectable theme row',
)
assert(
!layout.source_summary || layout.source_summary.bottom <= layout.theme.bottom + 1,
'1366px source summary falls outside the theme column',
)
}
if (layout.topbar && layout.guest_banner) {
assert(
layout.topbar.bottom <= layout.guest_banner.top + 1,
@@ -222,6 +247,31 @@ async function runViewport(browser, baseUrl, outputDir, viewport) {
await evolutionTab.press('ArrowLeft')
assert.equal(await currentTab.getAttribute('aria-selected'), 'true')
const coordinateSelection = page.locator('.geo-coordinate-selection')
await coordinateSelection.locator('summary').focus()
await coordinateSelection.locator('summary').press('Enter')
assert.equal(await coordinateSelection.getAttribute('open'), '')
await coordinateSelection.locator('summary').press('Enter')
const insightsToggle = page.getByRole('button', { name: 'Open inzichten' })
await insightsToggle.click()
const openDrawer = await page.locator('#geo-explorer-results').evaluate((element) => {
const bounds = element.getBoundingClientRect()
return { left: bounds.left, right: bounds.right, width: bounds.width, expanded: element.getAttribute('aria-hidden') }
})
assert(openDrawer.left < viewport.width - 100, 'Explicitly opened insights drawer remains off-screen')
assert(openDrawer.right <= viewport.width + 1, 'Explicitly opened insights drawer exceeds the viewport')
assert.equal(openDrawer.expanded, 'false')
await page.getByRole('button', { name: 'Sluit inzichten' }).click()
const themeToggle = page.locator('.workbench-theme-toggle')
await themeToggle.click()
const mapControlIcon = page.locator('.maplibregl-ctrl-icon').first()
if (await mapControlIcon.count()) {
assert.equal(await mapControlIcon.evaluate((element) => getComputedStyle(element).filter), 'none')
}
await themeToggle.click()
await page.screenshot({
path: path.join(outputDir, `viewport-${viewport.width}x${viewport.height}.png`),
fullPage: viewport.width <= 480,
+23 -8
View File
@@ -55,7 +55,8 @@ import { useMapWorkspaceState } from './hooks/useMapWorkspaceState'
import { useOperatorSession } from './hooks/useOperatorSession'
import { useMapSelectionExtract } from './hooks/useMapSelectionExtract'
import { useMapOrthophotoAnalysis } from './hooks/useMapOrthophotoAnalysis'
import { isDetectionImageryDataset, isMapRasterDataset } from './lib/datasetCapabilities'
import { isDetectionImageryDataset, isMapRasterDataset, isProductionInferenceRasterDataset } from './lib/datasetCapabilities'
import { getWorkbenchAccessCapabilities, type WorkbenchAccessMode } from './lib/accessCapabilities'
import { useProviderCapabilities } from './hooks/useProviderCapabilities'
import { useProjectWorkspace } from './hooks/useProjectWorkspace'
import { useQualityWorkflow } from './hooks/useQualityWorkflow'
@@ -116,13 +117,14 @@ const guestWorkspaceGroups: WorkspaceNavigationGroup[] = [
interface WorkbenchAppProps {
username: string | null
accessMode: 'open' | 'operator' | 'guest'
accessMode: WorkbenchAccessMode
loggingOut: boolean
onLogout: () => void
}
function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchAppProps): JSX.Element {
const isGuest = accessMode === 'guest'
const accessCapabilities = getWorkbenchAccessCapabilities(accessMode)
const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceKey>('map')
const [inspectorOpen, setInspectorOpen] = useState(false)
const [guestDemoReady, setGuestDemoReady] = useState(!isGuest)
@@ -297,6 +299,11 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA
[availableVectorDatasets],
)
const rasterDatasets = useMemo(() => datasets.filter((item) => item.dataset_type === 'raster'), [datasets])
const inferenceRasterDatasets = useMemo(
() => rasterDatasets.filter(isProductionInferenceRasterDataset),
[rasterDatasets],
)
const blockedInferenceDatasetCount = rasterDatasets.length - inferenceRasterDatasets.length
const detectionRasterDatasets = useMemo(
() => rasterDatasets.filter(isDetectionImageryDataset),
[rasterDatasets],
@@ -471,8 +478,9 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA
setSegmentationReferenceDatasetId,
} = useSegmentationWorkflow({
selectedProjectId,
rasterDatasets,
rasterDatasets: inferenceRasterDatasets,
qaIouThreshold,
maxInferenceTiles: yoloPreflight?.max_tiles ?? null,
loadProjectData,
loadQualityChecks,
})
@@ -1016,7 +1024,7 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA
>
{activeWorkspace !== 'map' ? <div className="workspace-heading">
<div className="workspace-heading-copy">
<p className="eyebrow">{selectedProject?.region ?? 'Belgie en Belgische Noordzee'}</p>
<p className="eyebrow">{selectedProject?.region ?? 'België en Belgische Noordzee'}</p>
<h2>{activeWorkspaceItem.label}</h2>
</div>
<WorkspaceSignal workspace={activeWorkspace} />
@@ -1116,6 +1124,9 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA
<div className="workspace-persistent-map" hidden={activeWorkspace !== 'map'}>
<MapWorkspace
readOnly={false}
managementLocked={!accessCapabilities.manageWorkspace}
sourceAcquisitionEnabled={accessCapabilities.acquireSources}
derivedDatasetWritesEnabled={accessCapabilities.writeDerivedDatasets}
secondaryResultsContainer={secondaryDisplay.container}
selectedProjectId={selectedProjectId}
projects={projects}
@@ -1220,6 +1231,7 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA
{activeWorkspace === 'analysis' ? (
<div className="workspace-grid workspace-grid-analysis">
<QualityResultsPanel
reviewLocked={!accessCapabilities.reviewEvidence}
selectedProjectId={selectedProjectId}
qualityChecks={qualityChecks}
qualityChecksError={qualityChecksError}
@@ -1232,7 +1244,7 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA
onOpenMapWorkspace={() => setActiveWorkspace('map')}
onOpenAnalysisWorkspace={() => setActiveWorkspace('ai')}
/>
<details className="secondary-analysis-disclosure">
{accessCapabilities.runChangeDetection ? <details className="secondary-analysis-disclosure">
<summary>
<span>Historische vectorlagen vergelijken</span>
<strong>Geavanceerd</strong>
@@ -1252,7 +1264,7 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA
onIncludeUnchangedChange={setChangeIncludeUnchanged}
onRun={runChangeDetection}
/>
</details>
</details> : null}
</div>
) : null}
@@ -1270,7 +1282,8 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA
{activeWorkspace === 'ai' ? (
<div className="workspace-grid workspace-grid-ai">
<DetectionLab
managementLocked={isGuest}
managementLocked={!accessCapabilities.manageModels}
blockedInferenceDatasetCount={blockedInferenceDatasetCount}
detectionModels={detectionModels}
modelAssets={modelAssets}
loadingDetectionModels={loadingDetectionModels}
@@ -1347,6 +1360,8 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA
</strong>
</summary>
<SegmentationLab
managementLocked={!accessCapabilities.manageModels}
blockedInferenceDatasetCount={blockedInferenceDatasetCount}
segmentationModels={segmentationModels}
loadingSegmentationModels={loadingSegmentationModels}
segmentationModelError={segmentationModelError}
@@ -1371,7 +1386,7 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA
segmentationQaError={segmentationQaError}
runningSegmentationQa={runningSegmentationQa}
selectedProjectId={selectedProjectId}
rasterDatasets={rasterDatasets}
rasterDatasets={inferenceRasterDatasets}
referenceDatasets={referenceDatasets}
selectedSegmentationModelConfigured={Boolean(selectedSegmentationModel?.configured)}
selectedSegmentationModelLimitation={selectedSegmentationModel?.limitation_message ?? null}
@@ -17,6 +17,7 @@ import { DetectionModelManagement, detectionModelLabel } from './DetectionModelM
import { AiPipelineIllustration } from './AiPipelineIllustration'
import { ModelSelector } from '../models/ModelSelector'
import { analysisModelAvailabilityMessage, toAnalysisModelOption } from '../models/modelOptions'
import { getDatasetSelectionLabel } from '../../lib/datasetDisplay'
const DETECTION_PAGE_SIZE_OPTIONS = [25, 50, 100] as const
const DEFAULT_DETECTION_PAGE_SIZE = 50
@@ -77,6 +78,7 @@ interface CalibrationRow {
interface DetectionLabProps {
managementLocked?: boolean
blockedInferenceDatasetCount?: number
detectionModels: DetectionModelCapability[]
modelAssets: ModelAssetRead[]
loadingDetectionModels: boolean
@@ -142,6 +144,7 @@ interface DetectionLabProps {
export function DetectionLab({
managementLocked = false,
blockedInferenceDatasetCount = 0,
detectionModels,
modelAssets,
loadingDetectionModels,
@@ -339,7 +342,7 @@ export function DetectionLab({
<strong>Nog niet nationaal gevalideerd</strong>
<p>
Dit model is operationeel voor gecontroleerde beeldanalyse, maar de gemeten kwaliteit geldt alleen voor {selectedDetectionModel?.validation_scope ?? selectedOperatorProfile.validationScope}.
Resultaten elders in Belgie of op zee vereisen lokale referentiedata en QA voordat ze als betrouwbaar kunnen worden vrijgegeven.
Resultaten elders in België of op zee vereisen lokale referentiedata en QA voordat ze als betrouwbaar kunnen worden vrijgegeven.
</p>
</div>
) : null}
@@ -423,7 +426,12 @@ export function DetectionLab({
{rasterDatasets.length === 0 ? (
<div className="result-state result-state-empty">
<strong>Geen luchtbeeld beschikbaar in deze werkruimte.</strong>
<p>Voeg hieronder een gegeorefereerde GeoTIFF toe. GeoIntel controleert de projectie en bewaart het bronbestand als dataset.</p>
{blockedInferenceDatasetCount > 0 ? (
<p>{blockedInferenceDatasetCount} rasterbestand(en) zijn vooraf uitgesloten door fixture-, quarantaine-, validatie- of provenancegegevens. De backend voert bij de start altijd de volledige consumptiecontrole uit.</p>
) : null}
<p>{managementLocked
? 'Ga naar Kaart, teken een begrensde rechthoek en kies Gebouwen. GeoIntel haalt pas dan een officieel orthofotobeeld op; de synthetische demo-context wordt nooit aan een productiemodel aangeboden.'
: 'Voeg hieronder een gegeorefereerde GeoTIFF toe of haal via Kaart een begrensd officieel orthofotobeeld op. GeoIntel controleert projectie en provenance vóór modelinference.'}</p>
</div>
) : null}
{!managementLocked ? <div className="guided-raster-input" aria-label="Luchtbeeld toevoegen">
@@ -470,7 +478,7 @@ export function DetectionLab({
<option value="">Kies een luchtbeeld</option>
{rasterDatasets.map((dataset) => (
<option key={dataset.id} value={dataset.id}>
{dataset.name}
{getDatasetSelectionLabel(dataset)}
</option>
))}
</select>
@@ -611,7 +619,7 @@ export function DetectionLab({
<option value="">Kies een referentielaag</option>
{referenceDatasets.map((dataset) => (
<option key={dataset.id} value={dataset.id}>
{dataset.name}
{getDatasetSelectionLabel(dataset)}
</option>
))}
</select>
@@ -930,7 +938,7 @@ export function DetectionLab({
<option value="">Kies een referentielaag</option>
{referenceDatasets.map((dataset) => (
<option key={dataset.id} value={dataset.id}>
{dataset.name}
{getDatasetSelectionLabel(dataset)}
</option>
))}
</select>
@@ -1032,7 +1040,7 @@ function CalibrationSummaryCard({
) : (
<>
<strong>n.v.t.</strong>
<p>Persisted QA metrics are required.</p>
<p>Bewaarde QA-metrieken zijn vereist.</p>
</>
)}
</div>
@@ -45,6 +45,11 @@ function statusLabel(value: string): string {
return value.replace(/_/g, ' ')
}
function governanceStatusLabel(value: string): string {
if (value === 'not_verified_by_catalog') return 'niet geverifieerd door deze catalogus'
return statusLabel(value)
}
export function DetectionModelManagement({
detectionModels,
modelAssets,
@@ -196,7 +201,7 @@ export function DetectionModelManagement({
<option value="">Kies een lokaal modelbestand</option>
{modelAssets.map((asset) => (
<option key={asset.model_asset_id} value={asset.model_asset_id}>
{asset.display_name} {asset.active ? '(actief)' : ''}
{asset.display_name} {asset.active ? '(actief)' : ''} · {governanceStatusLabel(asset.governed_validation_status)}
</option>
))}
</select>
@@ -204,22 +209,33 @@ export function DetectionModelManagement({
{modelAssets.length === 0 && !loadingDetectionModels ? (
<div className="result-state result-state-empty">
<strong>Geen lokaal modelbestand gevonden.</strong>
<p>Plaats een gecontroleerd model in de modelmap of configureer het bestaande YOLO-modelpad.</p>
<p>Plaats een lokaal model in de modelmap of configureer het bestaande YOLO-modelpad. Beschikbaarheid alleen is geen kwaliteits- of releasebewijs.</p>
</div>
) : null}
{selectedModelAsset ? (
<details className="technical-inline-details">
<>
{selectedModelAsset.governed_validation_status === 'not_verified_by_catalog' ? (
<div className="result-state result-state-warning" role="status">
<strong>Modelkwaliteit niet geverifieerd</strong>
<p>Dit bestand is technisch beschikbaar, maar heeft in deze catalogus geen gevalideerde kwaliteits- of promotiestatus.</p>
</div>
) : null}
<details className="technical-inline-details">
<summary>Bestands- en integriteitsgegevens</summary>
<div className="result-summary-card">
<p>Bestand: {selectedModelAsset.filename}</p>
<p>Status: {statusLabel(selectedModelAsset.status)}</p>
<p>Runtimestatus: {statusLabel(selectedModelAsset.runtime_status)}</p>
<p>Actief servermodel: {selectedModelAsset.active ? 'ja' : 'nee'}</p>
<p>Gevalideerde kwaliteit: {governanceStatusLabel(selectedModelAsset.governed_validation_status)}</p>
<p>Promotiestatus: {governanceStatusLabel(selectedModelAsset.promotion_status)}</p>
<p>Automatisch downloaden: {selectedModelAsset.will_download_models ? 'ja' : 'nee'}</p>
<p>Grootte: {formatModelAssetSize(selectedModelAsset.size_bytes)}</p>
<p>SHA-256: {selectedModelAsset.sha256.slice(0, 12)}</p>
<p>Pad: {selectedModelAsset.model_path}</p>
<p>{selectedModelAsset.limitation_message}</p>
</div>
</details>
</details>
</>
) : null}
</div>
</details>
@@ -4,6 +4,7 @@
import GeoMap from '../GeoMap'
import { formatPerformanceDuration } from '../../lib/performanceBudget'
import { getDatasetSelectionLabel } from '../../lib/datasetDisplay'
import { formatBboxLabel } from './mapWorkspaceUtils'
import { coverageStatusLabel, coverageZoneLabel } from './mapWorkspaceThemes'
import type { MapWorkspaceProps } from './mapWorkspaceProps'
@@ -18,6 +19,7 @@ interface MapAdvancedWorkbenchProps {
export function MapAdvancedWorkbench({ props, view }: MapAdvancedWorkbenchProps): JSX.Element {
const {
readOnly = false,
derivedDatasetWritesEnabled = true,
secondaryResultsContainer = null,
selectedProjectId,
projects,
@@ -302,7 +304,7 @@ export function MapAdvancedWorkbench({ props, view }: MapAdvancedWorkbenchProps)
<option value="">Kies een bewaarde vectorlaag</option>
{availableMapDatasets.map((dataset) => (
<option key={dataset.id} value={dataset.id}>
{dataset.name}
{getDatasetSelectionLabel(dataset, areas.find((area) => area.id === dataset.area_id)?.name)}
</option>
))}
</select>
@@ -627,7 +629,7 @@ export function MapAdvancedWorkbench({ props, view }: MapAdvancedWorkbenchProps)
</button>
</div>
{mapSelectionError ? <p className="error">{mapSelectionError}</p> : null}
<div className="guided-gis-flow" aria-label="Begeleide operationele GIS-werkstroom">
{derivedDatasetWritesEnabled ? <div className="guided-gis-flow" aria-label="Begeleide operationele GIS-werkstroom">
<div className="guided-gis-steps">
<div className={selectedMapDataset ? 'complete' : ''}>
<span>1</span>
@@ -716,7 +718,7 @@ export function MapAdvancedWorkbench({ props, view }: MapAdvancedWorkbenchProps)
<option value="">Kies een referentielaag</option>
{mapQaReferenceDatasets.map((dataset) => (
<option key={dataset.id} value={dataset.id}>
{dataset.name}
{getDatasetSelectionLabel(dataset, areas.find((area) => area.id === dataset.area_id)?.name)}
</option>
))}
</select>
@@ -742,7 +744,11 @@ export function MapAdvancedWorkbench({ props, view }: MapAdvancedWorkbenchProps)
{selectionExportError ? <p className="error">{selectionExportError}</p> : null}
{mapSelectionQaError ? <p className="error">{mapSelectionQaError}</p> : null}
{fullWorkflowError ? <p className="error">{fullWorkflowError}</p> : null}
</div>
</div> : (
<p className="geo-data-notice" role="note">
U kunt selecties analyseren, controleren en downloaden. Nieuwe databronnen en afgeleide lagen worden alleen door een operator bewaard.
</p>
)}
</div>
<details className="map-advanced-tools">
<summary>
@@ -869,14 +875,14 @@ export function MapAdvancedWorkbench({ props, view }: MapAdvancedWorkbenchProps)
>
{selectionExporting ? 'Download bewaren...' : 'Gebiedsdownload bewaren'}
</button>
<button
{derivedDatasetWritesEnabled ? <button
className="secondary-action"
disabled={!currentSelectionBbox || selectionDatasetSaving}
type="button"
onClick={saveAreaSelectionDataset}
>
{selectionDatasetSaving ? 'Resultaatlaag bewaren...' : 'Als resultaatlaag bewaren'}
</button>
</button> : null}
</div>
{selectionExportError ? <p className="error">{selectionExportError}</p> : null}
{latestSelectionExportPath ? (
@@ -898,7 +904,7 @@ export function MapAdvancedWorkbench({ props, view }: MapAdvancedWorkbenchProps)
<option value="">Kies een referentielaag</option>
{mapQaReferenceDatasets.map((dataset) => (
<option key={dataset.id} value={dataset.id}>
{dataset.name}
{getDatasetSelectionLabel(dataset, areas.find((area) => area.id === dataset.area_id)?.name)}
</option>
))}
</select>
@@ -0,0 +1,18 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ResultsPanelDismiss } from './MapExplorerView'
vi.mock('../GeoMap', () => ({ default: () => null }))
afterEach(() => cleanup())
describe('ResultsPanelDismiss', () => {
it('offers an explicit mobile-safe way to close the results drawer', () => {
const onClose = vi.fn()
render(<ResultsPanelDismiss onClose={onClose} />)
fireEvent.click(screen.getByRole('button', { name: 'Resultatenpaneel sluiten' }))
expect(onClose).toHaveBeenCalledOnce()
})
})
+95 -11
View File
@@ -23,9 +23,28 @@ interface MapExplorerViewProps {
view: MapWorkspaceViewModel
}
interface ResultsPanelDismissProps {
onClose: () => void
}
export function ResultsPanelDismiss({ onClose }: ResultsPanelDismissProps): JSX.Element {
return (
<button
className="geo-panel-close"
type="button"
aria-label="Resultatenpaneel sluiten"
onClick={onClose}
>
<ChevronRight aria-hidden="true" />
</button>
)
}
export function MapExplorerView({ props, view }: MapExplorerViewProps): JSX.Element {
const {
readOnly = false,
managementLocked = false,
sourceAcquisitionEnabled = true,
secondaryResultsContainer = null,
selectedProjectId,
projects,
@@ -275,7 +294,7 @@ export function MapExplorerView({ props, view }: MapExplorerViewProps): JSX.Elem
<div>
<p className="eyebrow">{activeScopeLabel} · geografische verkenner</p>
<h2>Gebied analyseren</h2>
<p>Verken vrij op de kaart, gebruik optioneel een officiële grens en vraag daarna inzichten op voor uw selectie.</p>
<p>Kies een gebied en verwerk alleen de themas die u nodig hebt.</p>
</div>
<div className="geo-explorer-header-tools">
<div className="geo-analysis-mode" role="tablist" aria-label="Analyseperiode">
@@ -331,14 +350,14 @@ export function MapExplorerView({ props, view }: MapExplorerViewProps): JSX.Elem
<span>U kunt kaartlagen verkennen, een selectie meten en kwaliteitsbewijs bekijken. Nieuwe gebieden, bronimports en bewaarde analyses zijn uitgeschakeld.</span>
</div>
</div>
) : (
) : !managementLocked ? (
<MunicipalitySearch
projectId={selectedProjectId}
activeArea={selectedMapArea ?? null}
disabled={workspaceLoading}
onActivate={onActivateMunicipality}
/>
)}
) : null}
{workspaceLoading ? (
<div className="geo-bootstrap-status" role="status" aria-live="polite">
@@ -362,7 +381,7 @@ export function MapExplorerView({ props, view }: MapExplorerViewProps): JSX.Elem
<div className="geo-panel-heading">
<div>
<h3>Kies themas</h3>
<p>Alleen uw gekozen bronnen en modellen worden geanalyseerd.</p>
<p>Alleen uw keuze wordt verwerkt.</p>
</div>
</div>
<div className="geo-loaded-scope" aria-label="Ingeladen regiobereik">
@@ -371,11 +390,11 @@ export function MapExplorerView({ props, view }: MapExplorerViewProps): JSX.Elem
<small>
{workspaceLoading
? 'Gebieden en bronnen worden geladen'
: readOnly
? `${municipalityAreaCount || 1} vooraf ingestelde demogrens; vrije kaartselectie blijft beschikbaar`
: managementLocked
? 'Vooraf ingeladen demo; vrij tekenen blijft beschikbaar'
: municipalityAreaCount > 0
? `${municipalityAreaCount} geactiveerde gemeentegrenzen; vrij tekenen blijft mogelijk`
: 'Zoek optioneel een gemeente of teken vrij op de kaart'}
? `${municipalityAreaCount} gemeentegrenzen · vrij tekenen mogelijk`
: 'Zoek een gemeente of teken vrij'}
</small>
</div>
<ThemeSearchField waarde={themeFilter} onChange={setThemeFilter} />
@@ -387,7 +406,7 @@ export function MapExplorerView({ props, view }: MapExplorerViewProps): JSX.Elem
const temporalGroup = temporalGroups[0]
const evolutionAvailable = temporalGroups.some((group) => group.items.length >= 2)
const available = !workspaceLoading && (analysisMode === 'current'
? Boolean(dataset || (!readOnly && onDemandProduct))
? Boolean(dataset || (sourceAcquisitionEnabled && onDemandProduct))
: Boolean(dataset) && evolutionAvailable)
const active = selectedThemeIds.includes(theme.id)
return (
@@ -410,7 +429,7 @@ export function MapExplorerView({ props, view }: MapExplorerViewProps): JSX.Elem
: dataset
? 'Lokaal beschikbaar'
: onDemandProduct
? readOnly ? 'Niet in demo' : 'Op aanvraag'
? sourceAcquisitionEnabled ? 'Op aanvraag' : 'Operatorbron vereist'
: 'Niet beschikbaar'}
</small>
</span>
@@ -678,6 +697,68 @@ export function MapExplorerView({ props, view }: MapExplorerViewProps): JSX.Elem
<span>Wis selectie</span>
</button>
</div>
<details className="geo-coordinate-selection">
<summary>Coördinaten invoeren</summary>
<div className="geo-coordinate-selection-body" aria-label="Rechthoek invoeren in EPSG:4326">
<div className="geo-coordinate-selection-grid">
<label>
West
<input
type="number"
inputMode="decimal"
step="any"
value={bboxInput.min_x}
onChange={(event) => setBboxInput((previous) => ({ ...previous, min_x: event.target.value }))}
/>
</label>
<label>
Zuid
<input
type="number"
inputMode="decimal"
step="any"
value={bboxInput.min_y}
onChange={(event) => setBboxInput((previous) => ({ ...previous, min_y: event.target.value }))}
/>
</label>
<label>
Oost
<input
type="number"
inputMode="decimal"
step="any"
value={bboxInput.max_x}
onChange={(event) => setBboxInput((previous) => ({ ...previous, max_x: event.target.value }))}
/>
</label>
<label>
Noord
<input
type="number"
inputMode="decimal"
step="any"
value={bboxInput.max_y}
onChange={(event) => setBboxInput((previous) => ({ ...previous, max_y: event.target.value }))}
/>
</label>
</div>
<button
className="secondary-action"
type="button"
disabled={!currentSelectionBbox}
onClick={() => {
if (!currentSelectionBbox) return
clearThemeInsights()
clearTemporalComparison()
setResultsPanelOpen(false)
setSelectionBbox(currentSelectionBbox)
}}
>
Gebruik coördinaten
</button>
<small>Lengte- en breedtegraden in EPSG:4326. Er start pas een analyse nadat u themas kiest.</small>
</div>
</details>
</div>
<div className={bboxSelectionMode ? 'geo-map-canvas geo-map-canvas-drawing' : 'geo-map-canvas'}>
@@ -789,9 +870,12 @@ export function MapExplorerView({ props, view }: MapExplorerViewProps): JSX.Elem
<h3>Inzichten</h3>
<p>Resultaten van de gekozen analyses.</p>
</div>
{secondaryResultsContainer ? null : (
<ResultsPanelDismiss onClose={() => setResultsPanelOpen(false)} />
)}
</div>
{!readOnly && analysisMode === 'current' && activeTheme.id === 'buildings' && mapSelectionBbox ? (
{!readOnly && sourceAcquisitionEnabled && analysisMode === 'current' && activeTheme.id === 'buildings' && mapSelectionBbox ? (
<div className={`geo-image-analysis geo-image-analysis-${orthophotoAnalysisStage}`}>
<div>
<span>Beeldanalyse</span>
@@ -9,6 +9,9 @@ import type { AreaRead, CoverageResolveResponse, DatasetCreateResponse, Detectio
export interface MapWorkspaceProps {
readOnly?: boolean
managementLocked?: boolean
sourceAcquisitionEnabled?: boolean
derivedDatasetWritesEnabled?: boolean
secondaryResultsContainer?: HTMLElement | null
selectedProjectId: string | null
projects: ProjectRead[]
@@ -268,7 +268,7 @@ export function coverageStatusLabel(status: CoverageStatus): string {
export function coverageZoneLabel(zone: string): string {
const labels: Record<string, string> = {
belgium: 'Belgie',
belgium: 'België',
flanders: 'Vlaanderen',
wallonia: 'Wallonie',
brussels: 'Brussel',
@@ -24,6 +24,7 @@ import type { MapWorkspaceProps } from './mapWorkspaceProps'
export function useMapWorkspaceViewModel({
readOnly = false,
sourceAcquisitionEnabled = true,
secondaryResultsContainer = null,
selectedProjectId,
projects,
@@ -1140,7 +1141,7 @@ export function useMapWorkspaceViewModel({
const loadSelectedThemeResult = async (bbox: VectorSelectionBBox, areaId?: string) => {
let resolvedZones = selectedCoverageZones
const scale = selectionAnalysisScale(bbox)
if (analysisMode === 'current' && selectedProjectId) {
if (analysisMode === 'current' && selectedProjectId && sourceAcquisitionEnabled) {
const resolvedCoverage = await resolveCoverage({
minx: bbox.min_x,
miny: bbox.min_y,
@@ -1154,7 +1155,7 @@ export function useMapWorkspaceViewModel({
resolvedZones = resolvedCoverage.intersected_zones
}
let resolvedProducts: PlannedOnDemandMapProduct[] = []
if (analysisMode === 'current') {
if (analysisMode === 'current' && sourceAcquisitionEnabled) {
const zoneProducts = resolvedZones
? onDemandProductsForZones(resolvedZones)
: []
@@ -5,7 +5,7 @@ const AREA_CATALOG_PAGE_SIZE = 12
function projectDisplayName(project: ProjectRead | null): string {
if (project?.name === 'Belgium and North Sea Workbench') {
return 'Belgie en Belgische Noordzee'
return 'België en Belgische Noordzee'
}
if (project?.name === 'Kempen Regional Workbench') {
return 'Kempen · volledige regio'
@@ -17,7 +17,7 @@ function isAdvancedProject(project: ProjectRead): boolean {
function projectDisplayName(project: ProjectRead): string {
if (project.name === NATIONAL_PROJECT_NAME) {
return 'Belgie en Belgische Noordzee'
return 'België en Belgische Noordzee'
}
if (project.name === REGIONAL_PROJECT_NAME) {
return 'Kempen · volledige regionale werkruimte'
@@ -130,7 +130,7 @@ export function ProjectPanel({
<label>
Regio
<input
value={projectForm.region ?? 'Belgie en Belgische Noordzee'}
value={projectForm.region ?? 'België en Belgische Noordzee'}
onChange={(event) => onUpdateProjectForm({ ...projectForm, region: event.target.value })}
placeholder="Regio"
/>
@@ -11,6 +11,7 @@ import { formatError } from '../../lib/formatError'
interface DetectionReviewPanelProps {
projectId: string
qualityCheckId: string
reviewLocked?: boolean
onOpenEvidenceMap?: (qualityCheckId: string) => void
}
@@ -58,6 +59,7 @@ function formatScore(value: number | null | undefined): string {
export function DetectionReviewPanel({
projectId,
qualityCheckId,
reviewLocked = false,
onOpenEvidenceMap,
}: DetectionReviewPanelProps): JSX.Element {
const [queue, setQueue] = useState<DetectionReviewList | null>(null)
@@ -118,8 +120,12 @@ export function DetectionReviewPanel({
<section className="detection-review-panel" aria-label="Handmatige controle van beeldanalyse">
<div className="panel-title-row">
<div>
<h3>Fouten controleren</h3>
<p className="muted">Beoordeel alleen twijfelgevallen. Bevestigde fouten kunnen later veilig als trainingsfeedback worden gebruikt.</p>
<h3>{reviewLocked ? 'Beoordelingen bekijken' : 'Fouten controleren'}</h3>
<p className="muted">
{reviewLocked
? 'De demo toont het bestaande kwaliteitsbewijs zonder beoordelingen te wijzigen.'
: 'Beoordeel alleen twijfelgevallen. Bevestigde fouten kunnen later veilig als trainingsfeedback worden gebruikt.'}
</p>
</div>
<button type="button" className="secondary-action" onClick={() => onOpenEvidenceMap?.(qualityCheckId)}>
Op kaart bekijken
@@ -214,6 +220,7 @@ export function DetectionReviewPanel({
Beoordeling
<select
value={decision}
disabled={reviewLocked}
onChange={(event) => setDraftDecisions((current) => ({
...current,
[key]: event.target.value as DetectionReviewDecision,
@@ -231,10 +238,11 @@ export function DetectionReviewPanel({
maxLength={2000}
placeholder="Waarom is dit correct, fout of onzeker?"
value={draftNotes[key] ?? ''}
readOnly={reviewLocked}
onChange={(event) => setDraftNotes((current) => ({ ...current, [key]: event.target.value }))}
/>
</label>
<button type="button" className="primary-action" disabled={savingKey === key} onClick={() => void save(item)}>
<button type="button" className="primary-action" disabled={reviewLocked || savingKey === key} onClick={() => void save(item)}>
{savingKey === key ? 'Bewaren...' : 'Beoordeling bewaren'}
</button>
</div>
@@ -12,6 +12,7 @@ const CORE_METRIC_ORDER = [
]
interface QualityResultsPanelProps {
reviewLocked?: boolean
selectedProjectId: string | null
qualityChecks: QualityCheckRead[]
qualityChecksError: string | null
@@ -126,6 +127,7 @@ function qualityMatchesSearch(check: QualityCheckRead, query: string, datasetNam
}
export function QualityResultsPanel({
reviewLocked = false,
selectedProjectId,
qualityChecks,
qualityChecksError,
@@ -387,6 +389,7 @@ export function QualityResultsPanel({
<DetectionReviewPanel
projectId={selectedProjectId}
qualityCheckId={selectedQualityCheck.id}
reviewLocked={reviewLocked}
onOpenEvidenceMap={onOpenEvidenceMap}
/>
) : null}
@@ -13,8 +13,11 @@ import {
analysisModelDisplayName,
toAnalysisModelOption,
} from '../models/modelOptions'
import { getDatasetSelectionLabel } from '../../lib/datasetDisplay'
interface SegmentationLabProps {
managementLocked?: boolean
blockedInferenceDatasetCount?: number
segmentationModels: SegmentationModelCapability[]
loadingSegmentationModels: boolean
segmentationModelError: string | null
@@ -92,6 +95,8 @@ function segmentationClassLabel(className: string): string {
}
export function SegmentationLab({
managementLocked = false,
blockedInferenceDatasetCount = 0,
segmentationModels,
loadingSegmentationModels,
segmentationModelError,
@@ -145,7 +150,7 @@ export function SegmentationLab({
? analysisModelAvailabilityMessage(selectedSegmentationModel)
: selectedSegmentationModelLimitation ?? 'Het gekozen segmentatiemodel is niet geconfigureerd'
const segmentationRunReady =
Boolean(selectedProjectId) && segmentationHasDataset && segmentationModelUiRunnable && segmentationHasTileManifest
Boolean(selectedProjectId) && segmentationHasDataset && segmentationModelUiRunnable
const segmentationJobActive = segmentationJob?.status === 'queued' || segmentationJob?.status === 'running'
const segmentationRunBlockedReason = !selectedProjectId
? 'Kies eerst een werkruimte'
@@ -155,9 +160,7 @@ export function SegmentationLab({
? 'Het fixturemodel is alleen bedoeld voor expliciete tests en demos'
: !selectedSegmentationModelConfigured
? selectedSegmentationModelAvailability
: !segmentationHasTileManifest
? 'Koppel eerst het beeldtegelmanifest van het gekozen rasterbestand'
: null
: null
return (
<section className="workspace-panel ai-lab-shell segmentation-lab-shell">
@@ -249,9 +252,9 @@ export function SegmentationLab({
: selectedSegmentationModelAvailability}
</strong>
</div>
<div className={segmentationHasTileManifest ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<div className={segmentationHasDataset ? 'lab-readiness-item lab-readiness-item-ready' : 'lab-readiness-item'}>
<span>Beeldtegels</span>
<strong>{segmentationHasTileManifest ? 'Technisch manifest gekoppeld' : 'Koppel het tegelmanifest van het rasterbestand'}</strong>
<strong>{segmentationHasTileManifest ? 'Technisch manifest gekoppeld' : segmentationHasDataset ? 'Worden automatisch voorbereid' : 'Wachten op een rasterbestand'}</strong>
</div>
</div>
</div>
@@ -262,17 +265,28 @@ export function SegmentationLab({
{rasterDatasets.length === 0 ? (
<div className="result-state result-state-empty">
<strong>Geen rasterbestand beschikbaar voor segmentatie.</strong>
<p>Voeg eerst een rasterbestand toe onder Bronnen.</p>
{blockedInferenceDatasetCount > 0 ? (
<p>{blockedInferenceDatasetCount} rasterbestand(en) zijn vooraf uitgesloten door fixture-, quarantaine-, validatie- of provenancegegevens. De volledige servergate blijft beslissend.</p>
) : null}
<p>{managementLocked
? 'Ga naar Kaart en maak een begrensde officiële bronselectie. De synthetische demo-context blijft bewust uitgesloten van geconfigureerde modelinference.'
: 'Voeg eerst een gevalideerd rasterbestand toe onder Bronnen of haal via Kaart een begrensde officiële bron op.'}</p>
</div>
) : null}
<div className="lab-form-grid">
<label>
Rasterbestand
<select value={selectedSegmentationDatasetId} onChange={(event) => onSelectDataset(event.target.value)}>
<select
value={selectedSegmentationDatasetId}
onChange={(event) => {
onSelectDataset(event.target.value)
onSetTileManifestPath('')
}}
>
<option value="">Kies een rasterbestand</option>
{rasterDatasets.map((dataset) => (
<option key={dataset.id} value={dataset.id}>
{dataset.name}
{getDatasetSelectionLabel(dataset)}
</option>
))}
</select>
@@ -298,7 +312,7 @@ export function SegmentationLab({
/>
</label>
</div>
<details className="technical-inline-details">
{!managementLocked ? <details className="technical-inline-details">
<summary>Technische beeldtegelinstelling</summary>
<label>
Beeldtegelmanifest
@@ -309,7 +323,7 @@ export function SegmentationLab({
onChange={(event) => onSetTileManifestPath(event.target.value)}
/>
</label>
</details>
</details> : null}
<button
className="primary-action"
type="button"
@@ -319,7 +333,7 @@ export function SegmentationLab({
{segmentationJob?.status === 'queued'
? 'Wachten op NVIDIA GPU…'
: runningSegmentation
? 'GPU-segmentatie wordt verwerkt…'
? segmentationHasTileManifest ? 'GPU-segmentatie wordt verwerkt…' : 'Beeldtegels voorbereiden…'
: 'Segmentatie starten'}
</button>
</div>
@@ -480,7 +494,7 @@ export function SegmentationLab({
<option value="">Kies een referentielaag</option>
{referenceDatasets.map((dataset) => (
<option key={dataset.id} value={dataset.id}>
{dataset.name}
{getDatasetSelectionLabel(dataset)}
</option>
))}
</select>
@@ -1,6 +1,14 @@
import { describe, expect, it } from 'vitest'
import { act, renderHook, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useSecondaryDisplay } from './SecondaryDisplay'
import { parseSecondaryDisplayGeometry, secondaryDisplayFeatures } from './secondaryDisplayGeometry'
afterEach(() => {
vi.restoreAllMocks()
delete document.body.dataset.theme
document.body.replaceChildren()
})
describe('secondary display geometry', () => {
it('restores a valid remembered window position', () => {
expect(parseSecondaryDisplayGeometry('{"left":1920,"top":0,"width":720,"height":1040}')).toEqual({
@@ -27,3 +35,43 @@ describe('secondary display geometry', () => {
)
})
})
describe('secondary display document', () => {
it('preserves metadata, semantics, focus and the active theme', async () => {
const iframe = document.createElement('iframe')
document.body.append(iframe)
const popup = iframe.contentWindow!
let closed = false
Object.defineProperty(popup, 'closed', { configurable: true, get: () => closed })
Object.defineProperty(popup, 'close', { configurable: true, value: vi.fn(() => { closed = true }) })
Object.defineProperty(popup, 'focus', { configurable: true, value: vi.fn() })
vi.spyOn(window, 'open').mockReturnValue(popup)
document.body.dataset.theme = 'dark'
const launcher = document.createElement('button')
launcher.textContent = 'Open tweede scherm'
document.body.append(launcher)
launcher.focus()
const view = renderHook(() => useSecondaryDisplay())
act(() => view.result.current.open())
expect(popup.document.title).toBe('GeoIntel · Analyseconsole')
expect(popup.document.documentElement.lang).toBe(document.documentElement.lang || 'nl')
expect(popup.document.querySelector('meta[charset="utf-8"]')).not.toBeNull()
expect(popup.document.querySelector('base')?.href).toBe(document.baseURI)
expect(popup.document.querySelector('meta[name="viewport"]')).not.toBeNull()
expect(popup.document.querySelector('h1')?.textContent).toBe('Analyseconsole')
expect(popup.document.querySelector('main > h2')?.textContent).toBe('Live analyse en resultaten')
expect(popup.document.activeElement).toBe(popup.document.querySelector('h1'))
expect(popup.document.body.dataset.theme).toBe('dark')
act(() => {
document.body.dataset.theme = 'light'
})
await waitFor(() => expect(popup.document.body.dataset.theme).toBe('light'))
act(() => view.result.current.close())
expect(document.activeElement).toBe(launcher)
})
})
@@ -112,16 +112,27 @@ function copyLoadedFonts(target: Window): void {
})
}
function initialiseSecondaryDocument(target: Window, onClose: () => void): HTMLElement {
function syncSecondaryTheme(target: Window): void {
const theme = document.body.dataset.theme
if (theme === 'light' || theme === 'dark') target.document.body.dataset.theme = theme
else delete target.document.body.dataset.theme
}
export function initialiseSecondaryDocument(target: Window, onClose: () => void): HTMLElement {
const targetDocument = target.document
targetDocument.title = 'GeoIntel · Analyseconsole'
targetDocument.documentElement.lang = document.documentElement.lang || 'nl'
targetDocument.head.replaceChildren()
const charset = targetDocument.createElement('meta')
charset.setAttribute('charset', 'utf-8')
const title = targetDocument.createElement('title')
title.textContent = 'GeoIntel · Analyseconsole'
const base = targetDocument.createElement('base')
base.href = document.baseURI
const viewport = targetDocument.createElement('meta')
viewport.name = 'viewport'
viewport.content = 'width=device-width, initial-scale=1'
targetDocument.head.append(viewport)
targetDocument.head.append(charset, title, base, viewport)
copyDocumentStyles(targetDocument)
copyLoadedFonts(target)
@@ -133,7 +144,7 @@ function initialiseSecondaryDocument(target: Window, onClose: () => void): HTMLE
header.innerHTML = `
<div class="secondary-display-brand">
<img src="/geointel-icon.svg" alt="" aria-hidden="true" />
<span><small>LIVE GEKOPPELD</small><strong>Analyseconsole</strong></span>
<span><small>LIVE GEKOPPELD</small><h1 tabindex="-1">Analyseconsole</h1></span>
</div>
<div class="secondary-display-status"><i aria-hidden="true"></i><span>Gesynchroniseerd met de kaart</span></div>
`
@@ -148,16 +159,23 @@ function initialiseSecondaryDocument(target: Window, onClose: () => void): HTMLE
content.className = 'secondary-display-content'
content.id = 'secondary-display-content'
content.setAttribute('aria-label', 'Live analyse en resultaten')
const contentHeading = targetDocument.createElement('h2')
contentHeading.className = 'sr-only'
contentHeading.textContent = 'Live analyse en resultaten'
content.append(contentHeading)
shell.append(header, content)
targetDocument.body.replaceChildren(shell)
targetDocument.body.className = 'secondary-display-body'
syncSecondaryTheme(target)
header.querySelector<HTMLElement>('h1')?.focus({ preventScroll: true })
return content
}
export function useSecondaryDisplay() {
const popupRef = useRef<Window | null>(null)
const monitorTimerRef = useRef<number | null>(null)
const returnFocusRef = useRef<HTMLElement | null>(null)
const [container, setContainer] = useState<HTMLElement | null>(null)
const [error, setError] = useState<string | null>(null)
@@ -183,21 +201,54 @@ export function useSecondaryDisplay() {
}
}, [])
const restoreFocus = useCallback(() => {
const target = returnFocusRef.current
returnFocusRef.current = null
if (target?.isConnected) target.focus({ preventScroll: true })
}, [])
const close = useCallback(() => {
const popup = popupRef.current
if (popup && !popup.closed) {
rememberGeometry(popup)
popup.close()
try {
rememberGeometry(popup)
} catch {
// A navigated popup can refuse geometry access; closing still wins.
}
try {
popup.close()
} catch {
// State is cleared below even when the browser has already detached it.
}
}
popupRef.current = null
setContainer(null)
stopMonitoring()
}, [rememberGeometry, stopMonitoring])
restoreFocus()
}, [rememberGeometry, restoreFocus, stopMonitoring])
const open = useCallback(() => {
const existing = popupRef.current
if (existing && !existing.closed) {
existing.focus()
try {
const existingContent = existing.document.getElementById('secondary-display-content')
const content = existingContent?.nodeType === Node.ELEMENT_NODE
? existingContent as HTMLElement
: initialiseSecondaryDocument(existing, close)
syncSecondaryTheme(existing)
setContainer(content)
setError(null)
existing.focus()
} catch {
try {
existing.close()
} finally {
popupRef.current = null
setContainer(null)
stopMonitoring()
}
setError('Het tweede venster kon niet opnieuw worden gekoppeld. Sluit het venster en probeer opnieuw.')
}
return
}
@@ -207,6 +258,7 @@ export function useSecondaryDisplay() {
} catch {
saved = null
}
returnFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
const popup = window.open('', SECONDARY_DISPLAY_NAME, secondaryDisplayFeatures(saved ?? defaultGeometry()))
if (!popup) {
setError('De browser blokkeerde het tweede venster. Sta pop-ups voor GeoIntel toe en probeer opnieuw.')
@@ -226,11 +278,24 @@ export function useSecondaryDisplay() {
popupRef.current = null
setContainer(null)
stopMonitoring()
restoreFocus()
return
}
rememberGeometry(current)
}, 1_000)
}, [close, rememberGeometry, stopMonitoring])
}, [close, rememberGeometry, restoreFocus, stopMonitoring])
useEffect(() => {
if (!container) return
const sync = () => {
const popup = popupRef.current
if (popup && !popup.closed) syncSecondaryTheme(popup)
}
sync()
const observer = new MutationObserver(sync)
observer.observe(document.body, { attributes: true, attributeFilter: ['data-theme'] })
return () => observer.disconnect()
}, [container])
useEffect(() => close, [close])
+2 -2
View File
@@ -3,8 +3,8 @@ import type { DatasetCreateResponse, ProjectRead } from '../types'
export const PRIMARY_FOCUS_LABEL = 'Mol'
export const PRIMARY_FOCUS_REGION = 'Mol, Kempen'
export const NATIONAL_WORKSPACE_PROJECT_NAME = 'Belgium and North Sea Workbench'
export const NATIONAL_WORKSPACE_LABEL = 'Belgie en Belgische Noordzee'
export const NATIONAL_WORKSPACE_REGION = 'Belgie en Belgische Noordzee'
export const NATIONAL_WORKSPACE_LABEL = 'België en Belgische Noordzee'
export const NATIONAL_WORKSPACE_REGION = 'België en Belgische Noordzee'
export const NATIONAL_MAP_CENTER: [number, number] = [4.62, 50.72]
export const NATIONAL_MAP_ZOOM = 7.25
export const REGIONAL_WORKSPACE_PROJECT_NAME = 'Kempen Regional Workbench'
@@ -46,7 +46,7 @@ export function useChangeDetectionWorkflow({
return
}
if (changeIouThreshold < 0 || changeIouThreshold > 1) {
setChangeDetectionError('IoU threshold must be between 0 and 1')
setChangeDetectionError('De IoU-drempel moet tussen 0 en 1 liggen.')
return
}
setChangeDetectionError(null)
@@ -63,10 +63,10 @@ export function useChangeDetectionWorkflow({
...(selection.areaId ? { area_id: selection.areaId } : {}),
})
if (job.status !== 'success') {
throw new Error(job.error_message || 'Change detection job failed')
throw new Error(job.error_message || 'De wijzigingsanalyse is mislukt.')
}
if (!job.result_json) {
throw new Error('Change detection completed without result payload')
throw new Error('De wijzigingsanalyse is afgerond zonder resultaat.')
}
setChangeSourceDatasetId(sourceDatasetId)
setChangeTargetDatasetId(targetDatasetId)
+6 -6
View File
@@ -218,7 +218,7 @@ export function useDatasetWorkflow({
await loadDatasetJobs(projectId, dataset.id, detailRequestId)
} catch (error) {
if (detailRequestId === datasetDetailRequestSequence.current) {
setDatasetDetailError(formatError(error, 'Unable to load dataset detail'))
setDatasetDetailError(formatError(error, 'De datasetdetails konden niet worden geladen.'))
}
} finally {
if (detailRequestId === datasetDetailRequestSequence.current) {
@@ -245,11 +245,11 @@ export function useDatasetWorkflow({
try {
const parsedSourceMetadata = JSON.parse(datasetForm.sourceMetadataJson)
if (parsedSourceMetadata === null || typeof parsedSourceMetadata !== 'object') {
setErrorMessage('Source metadata must be a JSON object')
setErrorMessage('Bronmetadata moet een JSON-object zijn.')
return
}
} catch {
setErrorMessage('Source metadata must be valid JSON')
setErrorMessage('Bronmetadata moet geldige JSON zijn.')
return
}
}
@@ -257,11 +257,11 @@ export function useDatasetWorkflow({
try {
const parsedProvenanceMetadata = JSON.parse(datasetForm.provenanceMetadataJson)
if (parsedProvenanceMetadata === null || typeof parsedProvenanceMetadata !== 'object') {
setErrorMessage('Provenance metadata must be a JSON object')
setErrorMessage('Provenancemetadata moet een JSON-object zijn.')
return
}
} catch {
setErrorMessage('Provenance metadata must be valid JSON')
setErrorMessage('Provenancemetadata moet geldige JSON zijn.')
return
}
}
@@ -401,7 +401,7 @@ export function useDatasetWorkflow({
}
const targetCrs = rasterReprojectCrs.trim()
if (!targetCrs) {
setDatasetDetailError('Target CRS is required for raster reproject')
setDatasetDetailError('Voor rasterherprojectie is een doel-CRS vereist.')
return
}
try {
+6 -12
View File
@@ -13,6 +13,7 @@ import type {
YoloPreflightResponse,
} from '../types'
import { formatError } from '../lib/formatError'
import { rasterTileCount } from '../lib/rasterTiling'
import {
analysisRunIdFromJob,
completedDetectionResponse,
@@ -84,16 +85,6 @@ function tileManifestPathFromJob(job: JobRead): string | null {
return typeof manifestPath === 'string' && manifestPath.trim().length > 0 ? manifestPath.trim() : null
}
function rasterTileCount(metadata: Record<string, unknown>, tileSize: number, overlap: number): number | null {
const width = metadata.width
const height = metadata.height
if (typeof width !== 'number' || typeof height !== 'number' || width <= 0 || height <= 0) {
return null
}
const step = tileSize - overlap
return Math.ceil(width / step) * Math.ceil(height / step)
}
function isAbortError(error: unknown): boolean {
return error instanceof Error && error.name === 'AbortError'
}
@@ -507,11 +498,14 @@ export function useDetectionWorkflow({
const inspection = await datasetsApi.rasterInspect(projectId, datasetId)
assertProjectCurrent()
const expectedTileCount = rasterTileCount(inspection.metadata, 512, 64)
const maxTiles = yoloPreflight?.max_tiles ?? 256
const maxTiles = yoloPreflight?.max_tiles
if (expectedTileCount === null) {
throw new Error('De afmetingen van het luchtbeeld konden niet veilig worden bepaald')
}
if (expectedTileCount > maxTiles) {
if (!Number.isInteger(maxTiles) || (maxTiles ?? 0) <= 0) {
throw new Error('De serverlimiet voor beeldtegels kon niet betrouwbaar worden opgehaald; vernieuw eerst de modelstatus')
}
if (expectedTileCount > maxTiles!) {
throw new Error(
`Dit luchtbeeld zou ${expectedTileCount} beeldtegels maken; het veilige maximum is ${maxTiles}. Knip het beeld eerst tot het gewenste werkgebied.`,
)
@@ -107,7 +107,7 @@ describe('a thrown error', () => {
await act(async () => void (await view.result.current.run()))
expect(view.result.current.error).toBe('Full GIS workflow failed.')
expect(view.result.current.error).toBe('De volledige GIS-werkstroom is mislukt.')
})
})
+1 -1
View File
@@ -16,7 +16,7 @@ export type FullWorkflowMode = 'new' | 'reuse'
export const IDLE_STATUS = 'Klaar om de volledige GIS-werkstroom uit te voeren.'
const STOPPED_STATUS = 'De werkstroom is gestopt.'
const GENERIC_FAILURE = 'Full GIS workflow failed.'
const GENERIC_FAILURE = 'De volledige GIS-werkstroom is mislukt.'
interface FullGisWorkflowOptions {
selectedDataset: DatasetCreateResponse | null | undefined
+1 -1
View File
@@ -36,7 +36,7 @@ export function useMapSelectionQa({
return null
}
if (candidateDataset.id === selectedMapQaReferenceDatasetId) {
setMapSelectionQaError('Candidate and reference datasets must be different.')
setMapSelectionQaError('De kandidaat- en referentiedataset moeten verschillend zijn.')
return null
}
+4 -4
View File
@@ -75,11 +75,11 @@ export function useQualityWorkflow({ selectedProjectId, loadProjectData }: Quali
return
}
if (qaCandidateDatasetId === qaReferenceDatasetId) {
setQaError('Candidate and reference datasets must be different')
setQaError('De kandidaat- en referentiedataset moeten verschillend zijn.')
return
}
if (!Number.isFinite(qaIouThreshold) || qaIouThreshold < 0 || qaIouThreshold > 1) {
setQaError('IoU threshold must be between 0 and 1')
setQaError('De IoU-drempel moet tussen 0 en 1 liggen.')
return
}
setQaError(null)
@@ -94,7 +94,7 @@ export function useQualityWorkflow({ selectedProjectId, loadProjectData }: Quali
}
const job: JobRead = await qaApi.runQa(selectedProjectId, request)
if (job.status === 'failed') {
setQaError(job.error_message || 'QA comparison failed')
setQaError(job.error_message || 'De kwaliteitsvergelijking is mislukt.')
return
}
const payload = job.result_json
@@ -113,7 +113,7 @@ export function useQualityWorkflow({ selectedProjectId, loadProjectData }: Quali
await loadProjectData(selectedProjectId)
}
} catch (error) {
setQaError(error instanceof Error ? error.message : 'QA comparison failed')
setQaError(error instanceof Error ? error.message : 'De kwaliteitsvergelijking is mislukt.')
} finally {
setQaRunning(false)
}
@@ -3,6 +3,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { JobRead, SegmentationRead, SegmentationRunRead } from '../types'
const mocks = vi.hoisted(() => ({
rasterInspect: vi.fn(),
rasterTile: vi.fn(),
listModels: vi.fn(),
runAsync: vi.fn(),
listRuns: vi.fn(),
@@ -13,6 +15,10 @@ const mocks = vi.hoisted(() => ({
}))
vi.mock('../services/api', () => ({
datasetsApi: {
rasterInspect: mocks.rasterInspect,
rasterTile: mocks.rasterTile,
},
segmentationApi: {
listModels: mocks.listModels,
runAsync: mocks.runAsync,
@@ -60,6 +66,7 @@ function renderWorkflow(selectedProjectId = projectId) {
selectedProjectId,
rasterDatasets: [],
qaIouThreshold: 0.5,
maxInferenceTiles: 100,
loadProjectData,
loadQualityChecks,
}))
@@ -87,6 +94,20 @@ describe('useSegmentationWorkflow GPU execution', () => {
mocks.getRun.mockResolvedValue(persistedRun)
mocks.listSegmentations.mockResolvedValue({ items: [], total: 0, truncated: false })
mocks.getRunGeoJson.mockResolvedValue({ type: 'FeatureCollection', features: [] })
mocks.rasterInspect.mockResolvedValue({
dataset_id: datasetId,
ready: true,
metadata: { width: 512, height: 512 },
})
mocks.rasterTile.mockResolvedValue({
id: 'tile-job-1',
job_type: 'raster.tile',
status: 'success',
project_id: projectId,
dataset_id: datasetId,
parameters_json: {},
result_json: { manifest_path: '/tiles/generated-manifest.json' },
})
})
it('queues, follows and reconciles a persisted segmentation result', async () => {
@@ -118,15 +139,41 @@ describe('useSegmentationWorkflow GPU execution', () => {
expect(loadProjectData).toHaveBeenCalledWith(projectId)
})
it('does not queue a configured model without a tile manifest', async () => {
it('prepares a server manifest before queueing when no manifest is supplied', async () => {
const { result } = renderWorkflow()
await act(async () => { await result.current.loadSegmentationModels() })
act(() => { result.current.setSelectedSegmentationDatasetId(datasetId) })
await act(async () => { await result.current.runSegmentation() })
expect(mocks.rasterInspect).toHaveBeenCalledWith(projectId, datasetId)
expect(mocks.rasterTile).toHaveBeenCalledWith(projectId, datasetId, {
tile_size: 512,
overlap: 64,
})
expect(mocks.runAsync).toHaveBeenCalledWith(expect.objectContaining({
tile_manifest_path: '/tiles/generated-manifest.json',
}))
expect(result.current.segmentationTileManifestPath).toBe('/tiles/generated-manifest.json')
expect(result.current.segmentationRunError).toBeNull()
})
it('uses the backend-reported inference limit and refuses tiling before writes', async () => {
mocks.rasterInspect.mockResolvedValueOnce({
dataset_id: datasetId,
ready: true,
metadata: { width: 5376, height: 4480 },
})
const { result } = renderWorkflow()
await act(async () => { await result.current.loadSegmentationModels() })
act(() => { result.current.setSelectedSegmentationDatasetId(datasetId) })
await act(async () => { await result.current.runSegmentation() })
expect(mocks.rasterTile).not.toHaveBeenCalled()
expect(mocks.runAsync).not.toHaveBeenCalled()
expect(result.current.segmentationRunError).toContain('beeldtegelmanifest')
expect(result.current.segmentationRunError).toContain('120 beeldtegels')
expect(result.current.segmentationRunError).toContain('maximum is 100')
})
it('ignores a late run list after the active project changes', async () => {
@@ -142,6 +189,7 @@ describe('useSegmentationWorkflow GPU execution', () => {
selectedProjectId,
rasterDatasets: [],
qaIouThreshold: 0.5,
maxInferenceTiles: 100,
loadProjectData,
loadQualityChecks,
}),
@@ -185,6 +233,7 @@ describe('useSegmentationWorkflow GPU execution', () => {
selectedProjectId,
rasterDatasets: [],
qaIouThreshold: 0.5,
maxInferenceTiles: 100,
loadProjectData,
loadQualityChecks,
}),
+52 -14
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { segmentationApi } from '../services/api'
import { datasetsApi, segmentationApi } from '../services/api'
import type {
DatasetCreateResponse,
JobRead,
@@ -11,6 +11,7 @@ import type {
SegmentationRunResponse,
} from '../types'
import { formatError } from '../lib/formatError'
import { rasterTileCount } from '../lib/rasterTiling'
import {
analysisRunIdFromSegmentationJob,
completedSegmentationResponse,
@@ -18,10 +19,19 @@ import {
waitForSegmentationJob,
} from '../services/segmentationJob'
const SEGMENTATION_TILE_SIZE = 512
const SEGMENTATION_TILE_OVERLAP = 64
function tileManifestPathFromJob(job: JobRead): string | null {
const manifestPath = job.result_json?.manifest_path
return typeof manifestPath === 'string' && manifestPath.trim() ? manifestPath.trim() : null
}
interface SegmentationWorkflowOptions {
selectedProjectId: string | null
rasterDatasets: DatasetCreateResponse[]
qaIouThreshold: number
maxInferenceTiles: number | null
loadProjectData: (projectId: string) => Promise<unknown>
loadQualityChecks: (projectId?: string | null) => Promise<QualityCheckRead[] | void>
}
@@ -40,6 +50,7 @@ export function useSegmentationWorkflow({
selectedProjectId,
rasterDatasets,
qaIouThreshold,
maxInferenceTiles,
loadProjectData,
loadQualityChecks,
}: SegmentationWorkflowOptions) {
@@ -237,10 +248,6 @@ export function useSegmentationWorkflow({
setSegmentationRunError('Het fixturemodel is uitsluitend beschikbaar voor expliciete geautomatiseerde tests')
return
}
if (!segmentationTileManifestPath.trim()) {
setSegmentationRunError('Koppel eerst het beeldtegelmanifest van het gekozen rasterbestand')
return
}
if (
(activeSegmentationControllerRef.current && !activeSegmentationControllerRef.current.signal.aborted)
|| segmentationJob?.status === 'queued'
@@ -251,15 +258,6 @@ export function useSegmentationWorkflow({
}
const projectId = selectedProjectId
const parameters: Record<string, unknown> = {}
const request = {
project_id: projectId,
dataset_id: datasetId,
model_id: selectedSegmentationModelId,
confidence_threshold: segmentationConfidenceThreshold,
tile_manifest_path: segmentationTileManifestPath.trim() || null,
parameters_json: parameters,
}
const controller = new AbortController()
const executionSequence = segmentationExecutionSequence.current + 1
segmentationExecutionSequence.current = executionSequence
@@ -279,6 +277,46 @@ export function useSegmentationWorkflow({
setRunningSegmentation(true)
setSegmentationJob(null)
try {
let manifestPath = segmentationTileManifestPath.trim()
if (!manifestPath) {
if (!Number.isInteger(maxInferenceTiles) || (maxInferenceTiles ?? 0) <= 0) {
throw new Error('De serverlimiet voor beeldtegels kon niet betrouwbaar worden opgehaald; vernieuw eerst de modelstatus')
}
const inspection = await datasetsApi.rasterInspect(projectId, datasetId)
assertExecutionCurrent()
const expectedTileCount = rasterTileCount(
inspection.metadata,
SEGMENTATION_TILE_SIZE,
SEGMENTATION_TILE_OVERLAP,
)
if (expectedTileCount === null) {
throw new Error('De afmetingen van het rasterbestand konden niet veilig worden bepaald')
}
if (expectedTileCount > maxInferenceTiles!) {
throw new Error(
`Dit rasterbestand zou ${expectedTileCount} beeldtegels maken; het door de server gemelde maximum is ${maxInferenceTiles}. Knip het raster eerst tot het gewenste werkgebied.`,
)
}
const tileJob = await datasetsApi.rasterTile(projectId, datasetId, {
tile_size: SEGMENTATION_TILE_SIZE,
overlap: SEGMENTATION_TILE_OVERLAP,
})
assertExecutionCurrent()
manifestPath = tileManifestPathFromJob(tileJob) ?? ''
if (!manifestPath) {
throw new Error(tileJob.error_message || 'De tegelvoorbereiding leverde geen geldig manifest op')
}
setSegmentationTileManifestPath(manifestPath)
}
const parameters: Record<string, unknown> = {}
const request = {
project_id: projectId,
dataset_id: datasetId,
model_id: selectedSegmentationModelId,
confidence_threshold: segmentationConfidenceThreshold,
tile_manifest_path: manifestPath,
parameters_json: parameters,
}
const queuedJob = await segmentationApi.runAsync(request)
assertExecutionCurrent()
setSegmentationJob(queuedJob)
+28 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { DatasetCreateResponse } from '../types'
import { isDetectionImageryDataset, isMapRasterDataset } from './datasetCapabilities'
import { datasetInferenceBlockReason, isDetectionImageryDataset, isMapRasterDataset, isProductionInferenceRasterDataset } from './datasetCapabilities'
function raster(sourceName: string, status = 'ready'): DatasetCreateResponse {
return {
@@ -26,4 +26,31 @@ describe('dataset capabilities', () => {
expect(isDetectionImageryDataset(raster('spw_walous_land_cover'))).toBe(false)
expect(isDetectionImageryDataset(raster('unclassified_uploaded_imagery'))).toBe(true)
})
it('never offers the synthetic demo fixture to configured inference', () => {
const fixture = raster('fixture')
fixture.source_metadata = { fixture: true }
expect(isProductionInferenceRasterDataset(fixture)).toBe(false)
expect(isDetectionImageryDataset(fixture)).toBe(false)
})
it.each([
[{ quarantine_status: 'quarantined' }, 'dataset staat in quarantaine'],
[{ validation_status: 'failed' }, 'datavalidatie is niet geslaagd'],
[{ provenance_status: 'incomplete' }, 'bronherkomst is onvolledig'],
[{ lineage_status: 'incomplete' }, 'afleidingslijn is onvolledig'],
])('blocks explicit backend governance failures before inference', (fields, reason) => {
const dataset = { ...raster('digitaal_vlaanderen_orthophoto'), ...fields }
expect(datasetInferenceBlockReason(dataset)).toBe(reason)
expect(isProductionInferenceRasterDataset(dataset)).toBe(false)
})
it('does not claim full eligibility when classification and freshness are absent from the list response', () => {
const dataset = raster('digitaal_vlaanderen_orthophoto')
expect(datasetInferenceBlockReason(dataset)).toBeNull()
expect(isProductionInferenceRasterDataset(dataset)).toBe(true)
})
})
+35 -1
View File
@@ -18,6 +18,40 @@ const MAP_RASTER_SOURCES = new Set([
'spw_walous_land_cover',
])
export function isProductionInferenceRasterDataset(dataset: DatasetCreateResponse): boolean {
// This is only a UI shortlist. Source classification and snapshot freshness
// are not part of DatasetCreateResponse, so the backend consumption gate
// remains the sole authority for positive eligibility.
return datasetInferenceBlockReason(dataset) === null
}
export function datasetInferenceBlockReason(dataset: DatasetCreateResponse): string | null {
if (dataset.dataset_type !== 'raster') return 'geen rasterdataset'
if (dataset.status !== 'ready') return `datasetstatus ${dataset.status}`
const source = dataset.source.trim().toLowerCase()
const sourceName = dataset.source_name?.trim().toLowerCase() ?? ''
if (
source === 'fixture'
|| sourceName === 'fixture'
|| dataset.source_metadata?.['fixture'] === true
|| dataset.metadata_json?.['fixture'] === true
) return 'test- of demofixture'
if (dataset.quarantine_status && dataset.quarantine_status !== 'not_quarantined') return 'dataset staat in quarantaine'
if (dataset.validation_status && dataset.validation_status !== 'passed') return 'datavalidatie is niet geslaagd'
if (dataset.provenance_status && dataset.provenance_status !== 'complete') return 'bronherkomst is onvolledig'
if (dataset.lineage_status && !['complete', 'not_applicable'].includes(dataset.lineage_status)) {
return 'afleidingslijn is onvolledig'
}
if (dataset.data_contract_key || dataset.data_contract_version) {
if (!dataset.data_contract_key || !dataset.data_contract_version) return 'datacontract is onvolledig'
}
if (dataset.checksum_sha256 && !/^[a-f0-9]{64}$/i.test(dataset.checksum_sha256)) return 'datasetchecksum is ongeldig'
if (dataset.source_registry_id || dataset.source_snapshot_id) {
if (!dataset.source_registry_id || !dataset.source_snapshot_id) return 'bronregistratie is onvolledig'
}
return null
}
export function isMapRasterDataset(dataset: DatasetCreateResponse): boolean {
return dataset.dataset_type === 'raster'
&& dataset.status === 'ready'
@@ -25,7 +59,7 @@ export function isMapRasterDataset(dataset: DatasetCreateResponse): boolean {
}
export function isDetectionImageryDataset(dataset: DatasetCreateResponse): boolean {
if (dataset.dataset_type !== 'raster' || dataset.status !== 'ready') {
if (!isProductionInferenceRasterDataset(dataset)) {
return false
}
if (NON_IMAGERY_RASTER_SOURCES.has(dataset.source_name ?? '')) {
+55
View File
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest'
import type { DatasetCreateResponse } from '../types'
import { getDatasetSelectionLabel } from './datasetDisplay'
function dataset(overrides: Partial<DatasetCreateResponse> = {}): DatasetCreateResponse {
return {
id: '7bc07783-82b5-4f32-b280-a035614c0ab1',
project_id: 'project-1',
name: 'demo_context_raster.tif',
dataset_type: 'raster',
source: 'orthophoto',
source_name: 'digitaal_vlaanderen_orthophoto',
status: 'ready',
...overrides,
}
}
describe('dataset selection labels', () => {
it('disambiguates identical filenames with governed operational context', () => {
const label = getDatasetSelectionLabel(dataset({
observed_at: '2025-07-14T00:00:00Z',
area_id: '92bfe017-6432-4e91-87d2-c94bf44b02c1',
source_metadata: { analysis_resolution_m: 0.25 },
}), 'Gemeente Mol')
expect(label).toContain('demo_context_raster.tif')
expect(label).toContain('Digitaal Vlaanderen')
expect(label).toContain('waarneming 14 jul 2025')
expect(label).toContain('Gemeente Mol')
expect(label).toContain('resolutie 0,25 m')
})
it('uses explicit coverage metadata without inventing missing evidence', () => {
const label = getDatasetSelectionLabel(dataset({
source: 'manual',
source_name: null,
source_metadata: { coverage_scope: 'bounded_selection', observation_year: 2023 },
}))
expect(label).toBe('demo_context_raster.tif · manual · waarneming 2023 · bounded selection')
})
it('labels import time separately and never reports geographic resolution as metres', () => {
const label = getDatasetSelectionLabel(dataset({
imported_at: '2026-08-30T00:00:00Z',
crs: 'EPSG:4326',
metadata_json: { resolution: [0.00001, 0.00001] },
}))
expect(label).toContain('ingeladen 30 aug 2026')
expect(label).toContain('resolutie 0,00001 °')
expect(label).not.toContain('0,00001 m')
})
})
+110
View File
@@ -94,3 +94,113 @@ export function getDatasetDisplayName(dataset: DatasetCreateResponse): string {
const source = getDatasetSourceDisplayName(dataset)
return `${label} · ${source}`
}
function firstNonEmptyString(...values: unknown[]): string | null {
for (const value of values) {
if (typeof value === 'string' && value.trim()) return value.trim()
}
return null
}
function formatDatasetDate(dataset: DatasetCreateResponse): string | null {
const observationYear = Number(dataset.source_metadata?.['observation_year'])
if (Number.isInteger(observationYear) && observationYear >= 1800 && observationYear <= 2200) {
return `waarneming ${observationYear}`
}
const period = firstNonEmptyString(
dataset.source_metadata?.['acquisition_period'],
dataset.source_metadata?.['survey_period'],
)
if (period) return `opname ${period}`
const timestamp = dataset.observed_at ?? dataset.valid_from ?? dataset.imported_at ?? dataset.created_at
if (!timestamp) return null
const date = new Date(timestamp)
if (Number.isNaN(date.getTime())) return null
const formatted = new Intl.DateTimeFormat('nl-BE', {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(date)
if (dataset.observed_at) return `waarneming ${formatted}`
if (dataset.valid_from) return `geldig vanaf ${formatted}`
return `ingeladen ${formatted}`
}
function formatResolutionValue(value: unknown, unit: string): string | null {
const numeric = typeof value === 'number' ? value : Number(value)
if (!Number.isFinite(numeric) || numeric <= 0) return null
return `resolutie ${numeric.toLocaleString('nl-BE', { maximumSignificantDigits: 4 })} ${unit}`
}
function resolutionUnit(dataset: DatasetCreateResponse, explicitUnit: unknown): string {
if (typeof explicitUnit === 'string' && explicitUnit.trim()) {
const normalized = explicitUnit.trim().toLowerCase()
if (normalized === 'degree' || normalized === 'degrees' || normalized === 'deg') return '°'
if (normalized === 'meter' || normalized === 'metre' || normalized === 'meters' || normalized === 'metres') return 'm'
return explicitUnit.trim()
}
const crs = dataset.crs?.trim().toUpperCase() ?? ''
if (/EPSG:(4326|4258|4313)$/.test(crs) || crs.includes('CRS84')) return '°'
if (/EPSG:(31370|3812|25831|32631)$/.test(crs)) return 'm'
return crs ? `CRS-eenheden (${crs})` : 'eenheden'
}
function formatDatasetResolution(dataset: DatasetCreateResponse): string | null {
const direct = formatResolutionValue(
dataset.source_metadata?.['analysis_resolution_m']
?? dataset.source_metadata?.['resolution_m']
?? dataset.source_metadata?.['native_resolution_m'],
'm',
)
if (direct) return direct
const metadataResolution = dataset.metadata_json?.['resolution']
const explicitUnit = dataset.source_metadata?.['resolution_unit']
?? dataset.metadata_json?.['resolution_unit']
if (Array.isArray(metadataResolution)) {
return formatResolutionValue(metadataResolution[0], resolutionUnit(dataset, explicitUnit))
}
if (metadataResolution && typeof metadataResolution === 'object') {
const resolution = metadataResolution as Record<string, unknown>
return formatResolutionValue(
resolution['x'],
resolutionUnit(dataset, resolution['unit'] ?? explicitUnit),
)
}
return null
}
function formatDatasetArea(dataset: DatasetCreateResponse, areaName?: string | null): string | null {
const configured = firstNonEmptyString(
areaName,
dataset.source_metadata?.['area_name'],
dataset.source_metadata?.['selection_area_name'],
dataset.source_metadata?.['municipality'],
dataset.source_metadata?.['coverage_scope'],
)
if (configured) return configured.replaceAll('_', ' ')
return dataset.area_id ? `gebied ${dataset.area_id.slice(0, 8)}` : null
}
/**
* A compact, disambiguating label for native select controls.
*
* Operational rasters often share the same generated filename. Source, time,
* area and resolution are therefore part of the option label whenever that
* evidence is present; no missing metadata is invented.
*/
export function getDatasetSelectionLabel(
dataset: DatasetCreateResponse,
areaName?: string | null,
): string {
const details = [
getDatasetSourceDisplayName(dataset),
formatDatasetDate(dataset),
formatDatasetArea(dataset, areaName),
formatDatasetResolution(dataset),
].filter((value, index, values): value is string => Boolean(value) && values.indexOf(value) === index)
return [dataset.name, ...details].join(' · ')
}
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { rasterTileCount, rasterTileOffsets } from './rasterTiling'
describe('raster tiling geometry', () => {
it('does not create overlap-only edge slivers for an exact tile', () => {
expect(rasterTileOffsets(512, 512, 64)).toEqual([0])
expect(rasterTileCount({ width: 512, height: 512 }, 512, 64)).toBe(1)
})
it('uses two full tiles for a 960 pixel axis', () => {
expect(rasterTileOffsets(960, 512, 64)).toEqual([0, 448])
expect(rasterTileCount({ width: 960, height: 512 }, 512, 64)).toBe(2)
})
it('adds one unique edge-aligned tile when the step does not reach the edge', () => {
expect(rasterTileOffsets(513, 512, 64)).toEqual([0, 1])
expect(rasterTileOffsets(961, 512, 64)).toEqual([0, 448, 449])
})
})
+36
View File
@@ -0,0 +1,36 @@
export function rasterTileOffsets(
dimension: number,
tileSize: number,
overlap: number,
): number[] | null {
if (
!Number.isInteger(dimension)
|| !Number.isInteger(tileSize)
|| !Number.isInteger(overlap)
|| dimension <= 0
|| tileSize <= 0
|| overlap < 0
|| overlap >= tileSize
) return null
if (dimension <= tileSize) return [0]
const step = tileSize - overlap
const finalStart = dimension - tileSize
const offsets: number[] = []
for (let start = 0; start <= finalStart; start += step) offsets.push(start)
if (offsets[offsets.length - 1] !== finalStart) offsets.push(finalStart)
return offsets
}
export function rasterTileCount(
metadata: Record<string, unknown>,
tileSize: number,
overlap: number,
): number | null {
const width = metadata.width
const height = metadata.height
if (typeof width !== 'number' || typeof height !== 'number') return null
const xOffsets = rasterTileOffsets(width, tileSize, overlap)
const yOffsets = rasterTileOffsets(height, tileSize, overlap)
return xOffsets && yOffsets ? xOffsets.length * yOffsets.length : null
}
+254 -2
View File
@@ -851,7 +851,7 @@
.geo-results-panel {
position: absolute;
z-index: 5;
z-index: 20;
inset-block: 0;
right: 0;
width: clamp(20rem, 21vw, 26rem);
@@ -1043,6 +1043,7 @@
.geo-results-panel {
overflow-y: auto;
overscroll-behavior: contain;
align-items: stretch;
}
.geo-results-panel > .geo-panel-heading {
@@ -1052,6 +1053,8 @@
}
.geo-results-panel > * {
box-sizing: border-box;
width: 100%;
flex: 0 0 auto;
margin: 0;
padding-inline: var(--gi-space-4);
@@ -1790,9 +1793,35 @@ button.overview-command-card { cursor: pointer; }
display: none;
}
.geo-panel-close {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 2.35rem;
min-width: 2.35rem;
height: 2.35rem;
border: 1px solid var(--gi-line-strong);
border-radius: var(--gi-radius-md);
background: var(--gi-surface-raised);
color: var(--gi-ink-700);
}
.geo-panel-close:hover,
.geo-panel-close:focus-visible {
border-color: var(--gi-brand-500);
background: var(--gi-brand-50);
color: var(--gi-brand-800);
}
.geo-panel-close svg {
width: 1rem;
height: 1rem;
}
.workbench-shell .geo-results-toggle {
position: absolute;
z-index: 7;
z-index: 21;
top: 50%;
right: 0;
display: inline-flex;
@@ -3342,3 +3371,226 @@ body:not([data-theme='light']) .workbench-shell :where(input, select, textarea)
margin-top: var(--gi-space-2);
white-space: nowrap;
}
/* ============================================================================
Frontend review repairs: compact explorer, drawer and keyboard selection
========================================================================== */
/* Keep the source summary in its column and give the theme list a real scroll
viewport. Flex shrinking previously reduced this list to less than one row
at 1366 × 768 while the panel's other blocks consumed the full height. */
.workbench-shell .geo-theme-panel {
overflow: hidden;
}
.workbench-shell .geo-theme-list {
flex: 1 1 10rem;
min-height: 8rem;
overflow-y: auto;
}
.workbench-shell .geo-theme-panel > :where(
.geo-panel-heading,
.geo-loaded-scope,
.geo-theme-search,
.geo-theme-actions,
.geo-source-summary,
.geo-data-notice,
.error
) {
flex: 0 0 auto;
}
.workbench-shell .geo-source-summary :where(strong, small) {
max-width: 100%;
overflow-wrap: anywhere;
word-break: normal;
}
/* On laptop-height workspaces these cards repeat the active work area and
source already present in the context bar and footer. Reclaim the space for
the actual theme choices, while keeping the full provenance cards on large
displays. */
@media (max-height: 940px) and (min-width: 761px) {
.workbench-shell .geo-loaded-scope,
.workbench-shell .geo-source-summary {
display: none;
}
}
/* Explicit open state outranks the older empty-state :has() rule. */
.workbench-shell .geo-explorer-layout:has(.geo-results-empty) .geo-results-panel.geo-results-panel-open {
visibility: visible;
pointer-events: auto;
transform: translateX(0);
transition-delay: 0s;
}
/* MapLibre's sprite is dark in the light theme. The global dark-mode filter
must not invert it on white control buttons. */
body[data-theme='light'] .workbench-shell .maplibregl-ctrl-group {
border: 1px solid var(--gi-line-strong);
background: var(--gi-surface);
box-shadow: var(--gi-shadow-sm);
}
body[data-theme='light'] .workbench-shell .maplibregl-ctrl-group button {
background: transparent;
}
body[data-theme='light'] .workbench-shell .maplibregl-ctrl-group button:hover {
background: var(--gi-surface-soft);
}
body[data-theme='light'] .workbench-shell .maplibregl-ctrl-group button .maplibregl-ctrl-icon {
filter: none;
}
body[data-theme='light'] .workbench-shell .maplibregl-ctrl-attrib {
background: rgba(255, 255, 255, 0.88);
color: var(--gi-ink-600);
}
.secondary-display-brand h1 {
margin: 0;
overflow: hidden;
font-family: var(--gi-font-display);
font-size: 1rem;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.secondary-display-brand h1:focus {
outline: 0;
}
/* Pointer drawing remains the fastest route; this disclosure provides an
equivalent keyboard path without permanently crowding the map toolbar. */
.geo-coordinate-selection {
position: relative;
flex: 0 0 auto;
}
.geo-coordinate-selection > summary {
min-height: 1.9rem;
border: 1px solid var(--gi-line);
border-radius: var(--gi-radius-sm);
padding: 0.42rem 0.66rem;
color: var(--gi-ink-700);
font-size: 0.66rem;
font-weight: 600;
cursor: pointer;
list-style-position: inside;
white-space: nowrap;
}
.geo-coordinate-selection[open] > summary {
border-color: var(--gi-brand-500);
color: var(--gi-brand-700);
}
.geo-coordinate-selection-body {
position: absolute;
z-index: 30;
top: calc(100% + 0.45rem);
right: 0;
display: grid;
width: min(23rem, calc(100vw - 2rem));
gap: var(--gi-space-3);
border: 1px solid var(--gi-line-strong);
border-radius: var(--gi-radius-md);
padding: var(--gi-space-3);
background: var(--gi-surface);
box-shadow: var(--gi-shadow-lg);
}
.geo-coordinate-selection-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--gi-space-2);
}
.geo-coordinate-selection-grid label {
display: grid;
gap: 0.2rem;
color: var(--gi-ink-600);
font-size: var(--gi-text-3xs);
font-weight: 600;
}
.geo-coordinate-selection-grid input {
width: 100%;
min-width: 0;
}
.geo-coordinate-selection-body small {
color: var(--gi-ink-500);
font-size: var(--gi-text-3xs);
line-height: var(--gi-leading-snug);
}
@media (max-height: 800px) and (min-width: 761px) {
.workbench-shell .geo-theme-panel > .geo-panel-heading p,
.workbench-shell .geo-loaded-scope small,
.workbench-shell .geo-theme-actions small {
display: none;
}
.workbench-shell .geo-theme-panel > .geo-panel-heading {
min-height: 3.25rem;
padding: var(--gi-space-2) var(--gi-space-3);
}
.workbench-shell .geo-loaded-scope {
margin-top: var(--gi-space-2);
padding-block: var(--gi-space-2);
}
.workbench-shell .geo-theme-search {
min-height: 2.25rem;
height: 2.25rem;
margin-block: var(--gi-space-2);
padding-block: var(--gi-space-1);
}
.workbench-shell .geo-theme-search input {
min-height: 0;
height: 1.25rem;
}
.workbench-shell .geo-theme-list {
min-height: 8rem;
margin-top: 0;
padding-block: var(--gi-space-2);
}
.workbench-shell .geo-theme-option {
min-height: 2.8rem;
padding-block: 0.4rem;
}
.workbench-shell .geo-source-summary small {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.workbench-shell .geo-theme-actions,
.workbench-shell .geo-source-summary {
padding-block: var(--gi-space-2);
}
}
@media (max-width: 760px) {
.workbench-shell .geo-theme-panel {
overflow: auto;
}
.workbench-shell .geo-theme-list {
flex: 0 0 auto;
min-height: 8rem;
max-height: 12rem;
}
}
+33
View File
@@ -794,6 +794,39 @@ body.landing-body {
transition: background 160ms ease, transform 160ms ease, box-shadow 160ms ease;
}
.landing-authentik-submit {
display: inline-flex;
min-height: 3rem;
gap: 0.55rem;
align-items: center;
justify-content: center;
border: 1px solid var(--landing-primary);
border-radius: var(--gi-radius-md);
padding: 0.72rem 1rem;
background: var(--landing-primary);
color: #fff;
font-size: 0.77rem;
font-weight: 600;
text-decoration: none;
box-shadow: var(--gi-shadow-md);
transition: background 160ms ease, transform 160ms ease, box-shadow 160ms ease;
}
.landing-authentik-submit:hover {
background: var(--landing-primary-strong);
color: #fff;
}
.landing-authentik-submit:focus-visible {
outline: 3px solid color-mix(in srgb, var(--landing-primary) 38%, transparent);
outline-offset: 2px;
}
.landing-authentik-submit svg {
width: 1rem;
height: 1rem;
}
.landing-operator-submit:hover:not(:disabled) {
background: var(--landing-primary-strong);
box-shadow: var(--gi-shadow-md);
+13
View File
@@ -99,6 +99,15 @@ export interface DatasetCreateResponse {
reference_layer_name?: string | null
source_metadata?: Record<string, unknown> | null
provenance_metadata?: Record<string, unknown> | null
source_registry_id?: string | null
source_snapshot_id?: string | null
data_contract_key?: string | null
data_contract_version?: string | null
validation_status?: string | null
validation_report_json?: Record<string, unknown> | null
provenance_status?: string | null
lineage_status?: string | null
quarantine_status?: string | null
imported_at?: string | null
temporal_series_key?: string | null
observed_at?: string | null
@@ -1307,6 +1316,10 @@ export interface ModelAssetRead {
size_bytes: number
sha256: string
active: boolean
runtime_available: boolean
runtime_status: string
governed_validation_status: string
promotion_status: string
status: string
limitation_message: string
will_download_models: boolean