Full platform remediation #7

Merged
Jens merged 11 commits from codex/full-platform-remediation into main 2026-08-30 09:30:42 +00:00
48 changed files with 1360 additions and 184 deletions
Showing only changes of commit a0884d64c9 - Show all commits
+36 -9
View File
@@ -48,8 +48,10 @@ België plus de juridisch benoemde Belgische maritieme zones.
## Product in beeld ## Product in beeld
De onderstaande screenshots zijn rechtstreeks vastgelegd op de actuele De onderstaande screenshots zijn rechtstreeks vastgelegd op de actuele
Unraid-productieomgeving. De gastmodus toont een beperkte, alleen-lezen Unraid-productieomgeving. De gastmodus toont een projectgebonden demowerkruimte
demowerkruimte; operatorfuncties en nieuwe analyses blijven afgeschermd. 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 ### Interactieve projectketen
@@ -68,6 +70,11 @@ resultaten zijn vanuit dezelfde ruimtelijke context bereikbaar.
![GeoIntel kaartwerkruimte](docs/assets/portfolio/geointel-workbench-map.png) ![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 ### Kwaliteit vóór resultaat
QA/QC is een eerste-klas workflow. Bewaarde controles koppelen scores aan QA/QC is een eerste-klas workflow. Bewaarde controles koppelen scores aan
@@ -147,9 +154,16 @@ Open daarna `http://localhost:1202`.
### Gastdemo ### Gastdemo
Met `GEOINTEL_GUEST_ACCESS_ENABLED=true` biedt de toegangspagina een Met `GEOINTEL_GUEST_ACCESS_ENABLED=true` biedt de toegangspagina een
kortlevende, alleen-lezen demosessie. Die toont uitsluitend het ingestelde kortlevende, projectgebonden demosessie. De gast heeft binnen het ingestelde
demoproject en bewaard kwaliteitsbewijs; dit is geen multi-user- of demoproject dezelfde kaart-, bronselectie-, modelselectie-, analyse-, QA- en
tenantisolatie. 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 ## NVIDIA/CUDA
@@ -176,11 +190,23 @@ GeoIntel behandelt officiële gebouwgrondvlakken niet automatisch als perfecte
daklabels. Voor trainingsdata worden temporele geldigheid, ruimtelijke leakage, daklabels. Voor trainingsdata worden temporele geldigheid, ruimtelijke leakage,
bronklasse, zichtbaarheid en pure-background-gedrag afzonderlijk gecontroleerd. bronklasse, zichtbaarheid en pure-background-gedrag afzonderlijk gecontroleerd.
Een directe polygon-overlay maakt bovendien zichtbaar of de officiële geometrie 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 De onafhankelijke AI-visuele hercontrole van 30 augustus 2026 vond nog
edge-alignmentproeven zijn afgewezen omdat ze nog vegetatie, wegen of schaduwen blokkerende referentiegeometrie, dense/nested labels, tile-edge-onduidelijkheid
als dak konden selecteren. Die resultaten zijn dus niet getraind of gepromoveerd. 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 ## Portfolio case study
@@ -258,6 +284,7 @@ Actuele rasterassets:
| `geointel-landing-hero.png` | Desktop hero en projectintroductie | | `geointel-landing-hero.png` | Desktop hero en projectintroductie |
| `geointel-interactive-story.png` | Vierstappenworkflow en bewijsvoering | | `geointel-interactive-story.png` | Vierstappenworkflow en bewijsvoering |
| `geointel-workbench-map.png` | Kaartgerichte gastwerkruimte | | `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-workbench-quality.png` | QA/QC, metrics en objectbewijs |
| `geointel-landing-mobile.png` | Mobiele landing | | `geointel-landing-mobile.png` | Mobiele landing |
| `geointel-workbench-mobile.png` | Mobiele kaartworkflow | | `geointel-workbench-mobile.png` | Mobiele kaartworkflow |
+1
View File
@@ -40,6 +40,7 @@ FEATURE_SOURCES: dict[str, tuple[str, ...]] = {
"components/map/useMapWorkspaceViewModel.ts", "components/map/useMapWorkspaceViewModel.ts",
"components/map/MapExplorerView.tsx", "components/map/MapExplorerView.tsx",
"components/map/MapAdvancedWorkbench.tsx", "components/map/MapAdvancedWorkbench.tsx",
"components/map/MunicipalitySearch.tsx",
"hooks/useMapImageOverlays.ts", "hooks/useMapImageOverlays.ts",
"hooks/useMapRectangleSelection.ts", "hooks/useMapRectangleSelection.ts",
"hooks/useFullGisWorkflow.ts", "hooks/useFullGisWorkflow.ts",
@@ -20,7 +20,8 @@ def test_national_workspace_is_automatic_and_map_has_one_scope_selector() -> Non
assert "const municipalityProject" not in project_hook assert "const municipalityProject" not in project_hook
assert 'aria-label="Regio"' not in map_workspace assert 'aria-label="Regio"' not in map_workspace
assert 'aria-label="Ingeladen regiobereik"' 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 assert 'aria-label="Werkgebied"' in map_workspace
@@ -20,7 +20,7 @@ from app.services.storage_service import StorageService
from app.services.source_registry_service import SourceRegistryService from app.services.source_registry_service import SourceRegistryService
from app.services.temporal_analysis_service import TemporalAnalysisService from app.services.temporal_analysis_service import TemporalAnalysisService
from app.services.thematic_raster_analysis_service import ThematicRasterAnalysisService 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: 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: def test_theme_failures_name_the_source_and_reason() -> None:
root = Path(__file__).resolve().parents[2]
hook = read_feature("map_workspace") hook = read_feature("map_workspace")
assert "queries[index]?.dataset?.name" in hook 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: def test_workspace_navigation_resets_the_actual_scroll_container() -> None:
root = Path(__file__).resolve().parents[2]
app = read_feature("shell") app = read_feature("shell")
assert "const workbenchMainRef = useRef<HTMLElement | null>(null)" in app assert "const workbenchMainRef = useRef<HTMLElement | null>(null)" in app
@@ -438,7 +436,6 @@ def test_workspace_navigation_resets_the_actual_scroll_container() -> None:
def test_quality_scores_have_plain_language_interpretation() -> None: def test_quality_scores_have_plain_language_interpretation() -> None:
root = Path(__file__).resolve().parents[2]
quality = read_feature("quality") quality = read_feature("quality")
detection = read_feature("detection") 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: def test_download_workspace_surfaces_map_results_in_plain_dutch() -> None:
root = Path(__file__).resolve().parents[2]
exports = read_feature("exports") exports = read_feature("exports")
assert "Gebiedsanalyse (JSON)" in exports assert "Gebiedsanalyse (JSON)" in exports
@@ -20,10 +20,12 @@ def test_ai_labs_explain_missing_raster_input_before_disabled_runs() -> None:
segmentation_lab = read_feature("segmentation") segmentation_lab = read_feature("segmentation")
assert "Geen luchtbeeld beschikbaar in deze werkruimte." in detection_lab 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 'aria-label="Luchtbeeld toevoegen"' in detection_lab
assert "onUploadRaster" in detection_lab assert "onUploadRaster" in detection_lab
assert "rasterDatasets.length === 0" in detection_lab assert "rasterDatasets.length === 0" in detection_lab
assert "Geen rasterbestand beschikbaar voor segmentatie." in segmentation_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 assert "rasterDatasets.length === 0" in segmentation_lab
Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

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