diff --git a/README.md b/README.md index 786b3b3d..4776f4ab 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/backend/tests/frontend_contract.py b/backend/tests/frontend_contract.py index 70b9dfe2..b7d5abca 100644 --- a/backend/tests/frontend_contract.py +++ b/backend/tests/frontend_contract.py @@ -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", diff --git a/backend/tests/test_sprint193_end_user_workbench.py b/backend/tests/test_sprint193_end_user_workbench.py index af9824f3..17e63a01 100644 --- a/backend/tests/test_sprint193_end_user_workbench.py +++ b/backend/tests/test_sprint193_end_user_workbench.py @@ -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 diff --git a/backend/tests/test_sprint233_operational_completion.py b/backend/tests/test_sprint233_operational_completion.py index 8e575f2a..0c0a4121 100644 --- a/backend/tests/test_sprint233_operational_completion.py +++ b/backend/tests/test_sprint233_operational_completion.py @@ -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(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 diff --git a/backend/tests/test_sprint96_useful_default_context.py b/backend/tests/test_sprint96_useful_default_context.py index b912aac8..4a9a1dd5 100644 --- a/backend/tests/test_sprint96_useful_default_context.py +++ b/backend/tests/test_sprint96_useful_default_context.py @@ -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 diff --git a/docs/assets/portfolio/geointel-interactive-story.png b/docs/assets/portfolio/geointel-interactive-story.png index 60355dba..4d1035e8 100644 Binary files a/docs/assets/portfolio/geointel-interactive-story.png and b/docs/assets/portfolio/geointel-interactive-story.png differ diff --git a/docs/assets/portfolio/geointel-landing-hero.png b/docs/assets/portfolio/geointel-landing-hero.png index 606f0f3f..11099acb 100644 Binary files a/docs/assets/portfolio/geointel-landing-hero.png and b/docs/assets/portfolio/geointel-landing-hero.png differ diff --git a/docs/assets/portfolio/geointel-workbench-map.png b/docs/assets/portfolio/geointel-workbench-map.png index 64c7e46c..4f0d00d8 100644 Binary files a/docs/assets/portfolio/geointel-workbench-map.png and b/docs/assets/portfolio/geointel-workbench-map.png differ diff --git a/docs/assets/portfolio/geointel-workbench-mobile.png b/docs/assets/portfolio/geointel-workbench-mobile.png index beceea97..2f5f741b 100644 Binary files a/docs/assets/portfolio/geointel-workbench-mobile.png and b/docs/assets/portfolio/geointel-workbench-mobile.png differ diff --git a/docs/assets/portfolio/geointel-workbench-quality.png b/docs/assets/portfolio/geointel-workbench-quality.png index 67193524..0b93d340 100644 Binary files a/docs/assets/portfolio/geointel-workbench-quality.png and b/docs/assets/portfolio/geointel-workbench-quality.png differ diff --git a/docs/assets/portfolio/geointel-workbench-wide.png b/docs/assets/portfolio/geointel-workbench-wide.png new file mode 100644 index 00000000..b3ef4213 Binary files /dev/null and b/docs/assets/portfolio/geointel-workbench-wide.png differ diff --git a/frontend/e2e/uxAudit.mjs b/frontend/e2e/uxAudit.mjs index 02a5145d..028fe57c 100644 --- a/frontend/e2e/uxAudit.mjs +++ b/frontend/e2e/uxAudit.mjs @@ -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, diff --git a/frontend/src/WorkbenchApp.tsx b/frontend/src/WorkbenchApp.tsx index 438ae313..3b941e02 100644 --- a/frontend/src/WorkbenchApp.tsx +++ b/frontend/src/WorkbenchApp.tsx @@ -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('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' ?
-

{selectedProject?.region ?? 'Belgie en Belgische Noordzee'}

+

{selectedProject?.region ?? 'België en Belgische Noordzee'}

{activeWorkspaceItem.label}

@@ -1116,6 +1124,9 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA ) : null} @@ -1270,7 +1282,8 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA {activeWorkspace === 'ai' ? (
Nog niet nationaal gevalideerd

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.

) : null} @@ -423,7 +426,12 @@ export function DetectionLab({ {rasterDatasets.length === 0 ? (
Geen luchtbeeld beschikbaar in deze werkruimte. -

Voeg hieronder een gegeorefereerde GeoTIFF toe. GeoIntel controleert de projectie en bewaart het bronbestand als dataset.

+ {blockedInferenceDatasetCount > 0 ? ( +

{blockedInferenceDatasetCount} rasterbestand(en) zijn vooraf uitgesloten door fixture-, quarantaine-, validatie- of provenancegegevens. De backend voert bij de start altijd de volledige consumptiecontrole uit.

+ ) : null} +

{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.'}

) : null} {!managementLocked ?
@@ -470,7 +478,7 @@ export function DetectionLab({ {rasterDatasets.map((dataset) => ( ))} @@ -611,7 +619,7 @@ export function DetectionLab({ {referenceDatasets.map((dataset) => ( ))} @@ -930,7 +938,7 @@ export function DetectionLab({ {referenceDatasets.map((dataset) => ( ))} @@ -1032,7 +1040,7 @@ function CalibrationSummaryCard({ ) : ( <> n.v.t. -

Persisted QA metrics are required.

+

Bewaarde QA-metrieken zijn vereist.

)}
diff --git a/frontend/src/components/detection/DetectionModelManagement.tsx b/frontend/src/components/detection/DetectionModelManagement.tsx index aedc6462..d4040b1c 100644 --- a/frontend/src/components/detection/DetectionModelManagement.tsx +++ b/frontend/src/components/detection/DetectionModelManagement.tsx @@ -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({ {modelAssets.map((asset) => ( ))} @@ -204,22 +209,33 @@ export function DetectionModelManagement({ {modelAssets.length === 0 && !loadingDetectionModels ? (
Geen lokaal modelbestand gevonden. -

Plaats een gecontroleerd model in de modelmap of configureer het bestaande YOLO-modelpad.

+

Plaats een lokaal model in de modelmap of configureer het bestaande YOLO-modelpad. Beschikbaarheid alleen is geen kwaliteits- of releasebewijs.

) : null} {selectedModelAsset ? ( -
+ <> + {selectedModelAsset.governed_validation_status === 'not_verified_by_catalog' ? ( +
+ Modelkwaliteit niet geverifieerd +

Dit bestand is technisch beschikbaar, maar heeft in deze catalogus geen gevalideerde kwaliteits- of promotiestatus.

+
+ ) : null} +
Bestands- en integriteitsgegevens

Bestand: {selectedModelAsset.filename}

-

Status: {statusLabel(selectedModelAsset.status)}

+

Runtimestatus: {statusLabel(selectedModelAsset.runtime_status)}

Actief servermodel: {selectedModelAsset.active ? 'ja' : 'nee'}

+

Gevalideerde kwaliteit: {governanceStatusLabel(selectedModelAsset.governed_validation_status)}

+

Promotiestatus: {governanceStatusLabel(selectedModelAsset.promotion_status)}

Automatisch downloaden: {selectedModelAsset.will_download_models ? 'ja' : 'nee'}

Grootte: {formatModelAssetSize(selectedModelAsset.size_bytes)}

SHA-256: {selectedModelAsset.sha256.slice(0, 12)}

Pad: {selectedModelAsset.model_path}

+

{selectedModelAsset.limitation_message}

-
+
+ ) : null}
diff --git a/frontend/src/components/map/MapAdvancedWorkbench.tsx b/frontend/src/components/map/MapAdvancedWorkbench.tsx index 03b07c0f..fce59f85 100644 --- a/frontend/src/components/map/MapAdvancedWorkbench.tsx +++ b/frontend/src/components/map/MapAdvancedWorkbench.tsx @@ -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) {availableMapDatasets.map((dataset) => ( ))} @@ -627,7 +629,7 @@ export function MapAdvancedWorkbench({ props, view }: MapAdvancedWorkbenchProps) {mapSelectionError ?

{mapSelectionError}

: null} -
+ {derivedDatasetWritesEnabled ?
1 @@ -716,7 +718,7 @@ export function MapAdvancedWorkbench({ props, view }: MapAdvancedWorkbenchProps) {mapQaReferenceDatasets.map((dataset) => ( ))} @@ -742,7 +744,11 @@ export function MapAdvancedWorkbench({ props, view }: MapAdvancedWorkbenchProps) {selectionExportError ?

{selectionExportError}

: null} {mapSelectionQaError ?

{mapSelectionQaError}

: null} {fullWorkflowError ?

{fullWorkflowError}

: null} -
+
: ( +

+ U kunt selecties analyseren, controleren en downloaden. Nieuwe databronnen en afgeleide lagen worden alleen door een operator bewaard. +

+ )}
@@ -869,14 +875,14 @@ export function MapAdvancedWorkbench({ props, view }: MapAdvancedWorkbenchProps) > {selectionExporting ? 'Download bewaren...' : 'Gebiedsdownload bewaren'} - + : null}
{selectionExportError ?

{selectionExportError}

: null} {latestSelectionExportPath ? ( @@ -898,7 +904,7 @@ export function MapAdvancedWorkbench({ props, view }: MapAdvancedWorkbenchProps) {mapQaReferenceDatasets.map((dataset) => ( ))} diff --git a/frontend/src/components/map/MapExplorerView.test.tsx b/frontend/src/components/map/MapExplorerView.test.tsx new file mode 100644 index 00000000..e330924b --- /dev/null +++ b/frontend/src/components/map/MapExplorerView.test.tsx @@ -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() + fireEvent.click(screen.getByRole('button', { name: 'Resultatenpaneel sluiten' })) + + expect(onClose).toHaveBeenCalledOnce() + }) +}) diff --git a/frontend/src/components/map/MapExplorerView.tsx b/frontend/src/components/map/MapExplorerView.tsx index 63d81284..d52ff647 100644 --- a/frontend/src/components/map/MapExplorerView.tsx +++ b/frontend/src/components/map/MapExplorerView.tsx @@ -23,9 +23,28 @@ interface MapExplorerViewProps { view: MapWorkspaceViewModel } +interface ResultsPanelDismissProps { + onClose: () => void +} + +export function ResultsPanelDismiss({ onClose }: ResultsPanelDismissProps): JSX.Element { + return ( + + ) +} + 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

{activeScopeLabel} · geografische verkenner

Gebied analyseren

-

Verken vrij op de kaart, gebruik optioneel een officiële grens en vraag daarna inzichten op voor uw selectie.

+

Kies een gebied en verwerk alleen de thema’s die u nodig hebt.

@@ -331,14 +350,14 @@ export function MapExplorerView({ props, view }: MapExplorerViewProps): JSX.Elem U kunt kaartlagen verkennen, een selectie meten en kwaliteitsbewijs bekijken. Nieuwe gebieden, bronimports en bewaarde analyses zijn uitgeschakeld.
- ) : ( + ) : !managementLocked ? ( - )} + ) : null} {workspaceLoading ? (
@@ -362,7 +381,7 @@ export function MapExplorerView({ props, view }: MapExplorerViewProps): JSX.Elem

Kies thema’s

-

Alleen uw gekozen bronnen en modellen worden geanalyseerd.

+

Alleen uw keuze wordt verwerkt.

@@ -371,11 +390,11 @@ export function MapExplorerView({ props, view }: MapExplorerViewProps): JSX.Elem {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'}
@@ -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'} @@ -678,6 +697,68 @@ export function MapExplorerView({ props, view }: MapExplorerViewProps): JSX.Elem Wis selectie
+
+ Coördinaten invoeren +
+
+ + + + +
+ + Lengte- en breedtegraden in EPSG:4326. Er start pas een analyse nadat u thema’s kiest. +
+
@@ -789,9 +870,12 @@ export function MapExplorerView({ props, view }: MapExplorerViewProps): JSX.Elem

Inzichten

Resultaten van de gekozen analyses.

+ {secondaryResultsContainer ? null : ( + setResultsPanelOpen(false)} /> + )} - {!readOnly && analysisMode === 'current' && activeTheme.id === 'buildings' && mapSelectionBbox ? ( + {!readOnly && sourceAcquisitionEnabled && analysisMode === 'current' && activeTheme.id === 'buildings' && mapSelectionBbox ? (
Beeldanalyse diff --git a/frontend/src/components/map/mapWorkspaceProps.ts b/frontend/src/components/map/mapWorkspaceProps.ts index 1d74002a..ab08d389 100644 --- a/frontend/src/components/map/mapWorkspaceProps.ts +++ b/frontend/src/components/map/mapWorkspaceProps.ts @@ -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[] diff --git a/frontend/src/components/map/mapWorkspaceThemes.ts b/frontend/src/components/map/mapWorkspaceThemes.ts index 0d816a49..6c6bb858 100644 --- a/frontend/src/components/map/mapWorkspaceThemes.ts +++ b/frontend/src/components/map/mapWorkspaceThemes.ts @@ -268,7 +268,7 @@ export function coverageStatusLabel(status: CoverageStatus): string { export function coverageZoneLabel(zone: string): string { const labels: Record = { - belgium: 'Belgie', + belgium: 'België', flanders: 'Vlaanderen', wallonia: 'Wallonie', brussels: 'Brussel', diff --git a/frontend/src/components/map/useMapWorkspaceViewModel.ts b/frontend/src/components/map/useMapWorkspaceViewModel.ts index bafd66f9..3b03923f 100644 --- a/frontend/src/components/map/useMapWorkspaceViewModel.ts +++ b/frontend/src/components/map/useMapWorkspaceViewModel.ts @@ -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) : [] diff --git a/frontend/src/components/project/AreaPanel.tsx b/frontend/src/components/project/AreaPanel.tsx index 067c497b..8fa75da0 100644 --- a/frontend/src/components/project/AreaPanel.tsx +++ b/frontend/src/components/project/AreaPanel.tsx @@ -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' diff --git a/frontend/src/components/project/ProjectPanel.tsx b/frontend/src/components/project/ProjectPanel.tsx index 538d6d3c..a99bc3a0 100644 --- a/frontend/src/components/project/ProjectPanel.tsx +++ b/frontend/src/components/project/ProjectPanel.tsx @@ -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({
@@ -480,7 +494,7 @@ export function SegmentationLab({ {referenceDatasets.map((dataset) => ( ))} diff --git a/frontend/src/components/shell/SecondaryDisplay.test.ts b/frontend/src/components/shell/SecondaryDisplay.test.ts index 00345855..240e44fb 100644 --- a/frontend/src/components/shell/SecondaryDisplay.test.ts +++ b/frontend/src/components/shell/SecondaryDisplay.test.ts @@ -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) + }) +}) diff --git a/frontend/src/components/shell/SecondaryDisplay.tsx b/frontend/src/components/shell/SecondaryDisplay.tsx index 24282b5c..807feff7 100644 --- a/frontend/src/components/shell/SecondaryDisplay.tsx +++ b/frontend/src/components/shell/SecondaryDisplay.tsx @@ -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 = `
- LIVE GEKOPPELDAnalyseconsole + LIVE GEKOPPELD

Analyseconsole

Gesynchroniseerd met de kaart
` @@ -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('h1')?.focus({ preventScroll: true }) return content } export function useSecondaryDisplay() { const popupRef = useRef(null) const monitorTimerRef = useRef(null) + const returnFocusRef = useRef(null) const [container, setContainer] = useState(null) const [error, setError] = useState(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]) diff --git a/frontend/src/config/primaryFocus.ts b/frontend/src/config/primaryFocus.ts index f899ff89..4128f24e 100644 --- a/frontend/src/config/primaryFocus.ts +++ b/frontend/src/config/primaryFocus.ts @@ -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' diff --git a/frontend/src/hooks/useChangeDetectionWorkflow.ts b/frontend/src/hooks/useChangeDetectionWorkflow.ts index f2f9e456..58a2a49f 100644 --- a/frontend/src/hooks/useChangeDetectionWorkflow.ts +++ b/frontend/src/hooks/useChangeDetectionWorkflow.ts @@ -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) diff --git a/frontend/src/hooks/useDatasetWorkflow.ts b/frontend/src/hooks/useDatasetWorkflow.ts index ea9c4abc..d83c1c86 100644 --- a/frontend/src/hooks/useDatasetWorkflow.ts +++ b/frontend/src/hooks/useDatasetWorkflow.ts @@ -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 { diff --git a/frontend/src/hooks/useDetectionWorkflow.ts b/frontend/src/hooks/useDetectionWorkflow.ts index 986bbe64..5ef990b5 100644 --- a/frontend/src/hooks/useDetectionWorkflow.ts +++ b/frontend/src/hooks/useDetectionWorkflow.ts @@ -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, 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.`, ) diff --git a/frontend/src/hooks/useFullGisWorkflow.test.tsx b/frontend/src/hooks/useFullGisWorkflow.test.tsx index fe2f2641..b0838ae8 100644 --- a/frontend/src/hooks/useFullGisWorkflow.test.tsx +++ b/frontend/src/hooks/useFullGisWorkflow.test.tsx @@ -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.') }) }) diff --git a/frontend/src/hooks/useFullGisWorkflow.ts b/frontend/src/hooks/useFullGisWorkflow.ts index 53d45578..bd832a58 100644 --- a/frontend/src/hooks/useFullGisWorkflow.ts +++ b/frontend/src/hooks/useFullGisWorkflow.ts @@ -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 diff --git a/frontend/src/hooks/useMapSelectionQa.ts b/frontend/src/hooks/useMapSelectionQa.ts index cc27af19..c12e4c5e 100644 --- a/frontend/src/hooks/useMapSelectionQa.ts +++ b/frontend/src/hooks/useMapSelectionQa.ts @@ -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 } diff --git a/frontend/src/hooks/useQualityWorkflow.ts b/frontend/src/hooks/useQualityWorkflow.ts index 7f5f2407..1a5ed3f8 100644 --- a/frontend/src/hooks/useQualityWorkflow.ts +++ b/frontend/src/hooks/useQualityWorkflow.ts @@ -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) } diff --git a/frontend/src/hooks/useSegmentationWorkflow.test.tsx b/frontend/src/hooks/useSegmentationWorkflow.test.tsx index 87242f4d..9e8b0e8d 100644 --- a/frontend/src/hooks/useSegmentationWorkflow.test.tsx +++ b/frontend/src/hooks/useSegmentationWorkflow.test.tsx @@ -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, }), diff --git a/frontend/src/hooks/useSegmentationWorkflow.ts b/frontend/src/hooks/useSegmentationWorkflow.ts index caff3c28..32edb4c6 100644 --- a/frontend/src/hooks/useSegmentationWorkflow.ts +++ b/frontend/src/hooks/useSegmentationWorkflow.ts @@ -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 loadQualityChecks: (projectId?: string | null) => Promise } @@ -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 = {} - 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 = {} + 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) diff --git a/frontend/src/lib/datasetCapabilities.test.ts b/frontend/src/lib/datasetCapabilities.test.ts index 281f8742..d4c90af3 100644 --- a/frontend/src/lib/datasetCapabilities.test.ts +++ b/frontend/src/lib/datasetCapabilities.test.ts @@ -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) + }) }) diff --git a/frontend/src/lib/datasetCapabilities.ts b/frontend/src/lib/datasetCapabilities.ts index 9bd56dff..6a980703 100644 --- a/frontend/src/lib/datasetCapabilities.ts +++ b/frontend/src/lib/datasetCapabilities.ts @@ -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 ?? '')) { diff --git a/frontend/src/lib/datasetDisplay.test.ts b/frontend/src/lib/datasetDisplay.test.ts new file mode 100644 index 00000000..58dfb48f --- /dev/null +++ b/frontend/src/lib/datasetDisplay.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' + +import type { DatasetCreateResponse } from '../types' +import { getDatasetSelectionLabel } from './datasetDisplay' + +function dataset(overrides: Partial = {}): 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') + }) +}) diff --git a/frontend/src/lib/datasetDisplay.ts b/frontend/src/lib/datasetDisplay.ts index 3fca03a7..bb026462 100644 --- a/frontend/src/lib/datasetDisplay.ts +++ b/frontend/src/lib/datasetDisplay.ts @@ -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 + 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(' · ') +} diff --git a/frontend/src/lib/rasterTiling.test.ts b/frontend/src/lib/rasterTiling.test.ts new file mode 100644 index 00000000..027b5650 --- /dev/null +++ b/frontend/src/lib/rasterTiling.test.ts @@ -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]) + }) +}) diff --git a/frontend/src/lib/rasterTiling.ts b/frontend/src/lib/rasterTiling.ts new file mode 100644 index 00000000..25ea4a96 --- /dev/null +++ b/frontend/src/lib/rasterTiling.ts @@ -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, + 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 +} diff --git a/frontend/src/styles/geointel-system.css b/frontend/src/styles/geointel-system.css index 97caf7b4..9af96195 100644 --- a/frontend/src/styles/geointel-system.css +++ b/frontend/src/styles/geointel-system.css @@ -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; + } +} diff --git a/frontend/src/styles/landing.css b/frontend/src/styles/landing.css index 7872aa39..bafefed6 100644 --- a/frontend/src/styles/landing.css +++ b/frontend/src/styles/landing.css @@ -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); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index a00d1720..f005a623 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -99,6 +99,15 @@ export interface DatasetCreateResponse { reference_layer_name?: string | null source_metadata?: Record | null provenance_metadata?: Record | 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 | 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 diff --git a/scripts/create_portfolio_case_study.py b/scripts/create_portfolio_case_study.py index 0e68907d..0ea047b8 100644 --- a/scripts/create_portfolio_case_study.py +++ b/scripts/create_portfolio_case_study.py @@ -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()