feat(ui): refine governed workbench and dual-screen flows
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 thema’s 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 thema’s</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 thema’s 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 demo’s'
|
||||
: !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])
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 ?? '')) {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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(' · ')
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user