Full platform remediation #7

Merged
Jens merged 11 commits from codex/full-platform-remediation into main 2026-08-30 09:30:42 +00:00
48 changed files with 1360 additions and 184 deletions
Showing only changes of commit a0884d64c9 - Show all commits
+36 -9
View File
@@ -48,8 +48,10 @@ België plus de juridisch benoemde Belgische maritieme zones.
## Product in beeld
De onderstaande screenshots zijn rechtstreeks vastgelegd op de actuele
Unraid-productieomgeving. De gastmodus toont een beperkte, alleen-lezen
demowerkruimte; operatorfuncties en nieuwe analyses blijven afgeschermd.
Unraid-productieomgeving. De gastmodus toont een projectgebonden demowerkruimte
met dezelfde kaart-, bron-, model-, analyse-, QA- en exportflow als een
operator. Alleen beheer, instellingen, uploads, bronconfiguratie,
projectbeheer en evidence-review blijven afgeschermd.
### Interactieve projectketen
@@ -68,6 +70,11 @@ resultaten zijn vanuit dezelfde ruimtelijke context bereikbaar.
![GeoIntel kaartwerkruimte](docs/assets/portfolio/geointel-workbench-map.png)
Op een breed scherm krijgt de kaart extra ruimte terwijl de themakolom en de
controleerbare analysestappen zichtbaar blijven.
![GeoIntel brede kaartwerkruimte](docs/assets/portfolio/geointel-workbench-wide.png)
### Kwaliteit vóór resultaat
QA/QC is een eerste-klas workflow. Bewaarde controles koppelen scores aan
@@ -147,9 +154,16 @@ Open daarna `http://localhost:1202`.
### Gastdemo
Met `GEOINTEL_GUEST_ACCESS_ENABLED=true` biedt de toegangspagina een
kortlevende, alleen-lezen demosessie. Die toont uitsluitend het ingestelde
demoproject en bewaard kwaliteitsbewijs; dit is geen multi-user- of
tenantisolatie.
kortlevende, projectgebonden demosessie. De gast heeft binnen het ingestelde
demoproject dezelfde kaart-, bronselectie-, modelselectie-, analyse-, QA- en
exportmogelijkheden als een operator. Alleen beheerfuncties zoals instellingen,
uploads, bronconfiguratie, projectbeheer en evidence-review blijven geblokkeerd;
dit is geen multi-user- of tenantisolatie.
Een optionele Authentik OIDC-login kan naast de lokale operatorlogin worden
ingeschakeld. GeoIntel gebruikt daarbij PKCE, state, nonce, issuer-/audience-
controle en één expliciet toegelaten, geverifieerd e-mailadres. De lokale
operatorlogin blijft altijd het herstelpad.
## NVIDIA/CUDA
@@ -176,11 +190,23 @@ GeoIntel behandelt officiële gebouwgrondvlakken niet automatisch als perfecte
daklabels. Voor trainingsdata worden temporele geldigheid, ruimtelijke leakage,
bronklasse, zichtbaarheid en pure-background-gedrag afzonderlijk gecontroleerd.
Een directe polygon-overlay maakt bovendien zichtbaar of de officiële geometrie
op het bijbehorende luchtbeeld aansluit vóór omzetting naar YOLO-boxen.
op het bijbehorende luchtbeeld aansluit vóór omzetting naar YOLO-boxen. Voor
productresultaten blijft de taakgeschikte officiële bron doorslaggevend; een
AI-detectie is controleerbaar voorstelbewijs zolang een taakgebonden releasegate
niet aantoonbaar anders beslist.
De huidige V66 Vlaamse uitbreiding blijft kandidaatdata. Automatische SAM2- en
edge-alignmentproeven zijn afgewezen omdat ze nog vegetatie, wegen of schaduwen
als dak konden selecteren. Die resultaten zijn dus niet getraind of gepromoveerd.
De onafhankelijke AI-visuele hercontrole van 30 augustus 2026 vond nog
blokkerende referentiegeometrie, dense/nested labels, tile-edge-onduidelijkheid
en onvoldoende onafhankelijke pure-background/hard-negative dekking. Deze
controle is uitdrukkelijk geen menselijke acceptatie. Nieuwe training en
modelpromotie blijven daarom geblokkeerd; er wordt geen 100%-accuratieclaim
gemaakt.
Modelactivatie vereist naast het oudere diagnostische promotierapport ook een
geslaagd, governed Phase-4/5 release-gaterapport dat exact dezelfde candidate
key, model-SHA-256 en benchmarkmanifest-SHA-256 bindt. Zie
[de actuele accuracy-status](docs/accuracy-program/status.json) en het
[visuele reviewbewijs](artifacts/evidence/accuracy/model-training/20260830-independent-ai-visual-review.json).
## Portfolio case study
@@ -258,6 +284,7 @@ Actuele rasterassets:
| `geointel-landing-hero.png` | Desktop hero en projectintroductie |
| `geointel-interactive-story.png` | Vierstappenworkflow en bewijsvoering |
| `geointel-workbench-map.png` | Kaartgerichte gastwerkruimte |
| `geointel-workbench-wide.png` | Brede kaartwerkruimte voor desktopportfolio's |
| `geointel-workbench-quality.png` | QA/QC, metrics en objectbewijs |
| `geointel-landing-mobile.png` | Mobiele landing |
| `geointel-workbench-mobile.png` | Mobiele kaartworkflow |
+1
View File
@@ -40,6 +40,7 @@ FEATURE_SOURCES: dict[str, tuple[str, ...]] = {
"components/map/useMapWorkspaceViewModel.ts",
"components/map/MapExplorerView.tsx",
"components/map/MapAdvancedWorkbench.tsx",
"components/map/MunicipalitySearch.tsx",
"hooks/useMapImageOverlays.ts",
"hooks/useMapRectangleSelection.ts",
"hooks/useFullGisWorkflow.ts",
@@ -20,7 +20,8 @@ def test_national_workspace_is_automatic_and_map_has_one_scope_selector() -> Non
assert "const municipalityProject" not in project_hook
assert 'aria-label="Regio"' not in map_workspace
assert 'aria-label="Ingeladen regiobereik"' in map_workspace
assert "Zoek optioneel een gemeente" in map_workspace
assert 'aria-label="Optioneel een gemeente zoeken"' in map_workspace
assert 'placeholder="Gemeentenaam of NIS-code"' in map_workspace
assert 'aria-label="Werkgebied"' in map_workspace
@@ -20,7 +20,7 @@ from app.services.storage_service import StorageService
from app.services.source_registry_service import SourceRegistryService
from app.services.temporal_analysis_service import TemporalAnalysisService
from app.services.thematic_raster_analysis_service import ThematicRasterAnalysisService
from tests.frontend_contract import assert_calls, assert_mentions, assert_wired, read_map_workspace, read_feature
from tests.frontend_contract import assert_mentions, assert_wired, read_map_workspace, read_feature
class FakeSession:
@@ -418,7 +418,6 @@ def test_frontend_fetches_canonical_workspace_outside_default_project_page() ->
def test_theme_failures_name_the_source_and_reason() -> None:
root = Path(__file__).resolve().parents[2]
hook = read_feature("map_workspace")
assert "queries[index]?.dataset?.name" in hook
@@ -428,7 +427,6 @@ def test_theme_failures_name_the_source_and_reason() -> None:
def test_workspace_navigation_resets_the_actual_scroll_container() -> None:
root = Path(__file__).resolve().parents[2]
app = read_feature("shell")
assert "const workbenchMainRef = useRef<HTMLElement | null>(null)" in app
@@ -438,7 +436,6 @@ def test_workspace_navigation_resets_the_actual_scroll_container() -> None:
def test_quality_scores_have_plain_language_interpretation() -> None:
root = Path(__file__).resolve().parents[2]
quality = read_feature("quality")
detection = read_feature("detection")
@@ -479,7 +476,6 @@ def test_detection_lab_only_receives_operational_imagery_rasters() -> None:
def test_download_workspace_surfaces_map_results_in_plain_dutch() -> None:
root = Path(__file__).resolve().parents[2]
exports = read_feature("exports")
assert "Gebiedsanalyse (JSON)" in exports
@@ -20,10 +20,12 @@ def test_ai_labs_explain_missing_raster_input_before_disabled_runs() -> None:
segmentation_lab = read_feature("segmentation")
assert "Geen luchtbeeld beschikbaar in deze werkruimte." in detection_lab
assert "Voeg hieronder een gegeorefereerde GeoTIFF toe." in detection_lab
assert "Voeg hieronder een gegeorefereerde GeoTIFF toe of haal via Kaart" in detection_lab
assert "de synthetische demo-context wordt nooit aan een productiemodel aangeboden" in detection_lab
assert 'aria-label="Luchtbeeld toevoegen"' in detection_lab
assert "onUploadRaster" in detection_lab
assert "rasterDatasets.length === 0" in detection_lab
assert "Geen rasterbestand beschikbaar voor segmentatie." in segmentation_lab
assert "Voeg eerst een rasterbestand toe onder Bronnen." in segmentation_lab
assert "Voeg eerst een gevalideerd rasterbestand toe onder Bronnen" in segmentation_lab
assert "De synthetische demo-context blijft bewust uitgesloten" in segmentation_lab
assert "rasterDatasets.length === 0" in segmentation_lab
Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

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