feat(ui): refine governed workbench and dual-screen flows
@@ -48,8 +48,10 @@ België plus de juridisch benoemde Belgische maritieme zones.
|
||||
## Product in beeld
|
||||
|
||||
De onderstaande screenshots zijn rechtstreeks vastgelegd op de actuele
|
||||
Unraid-productieomgeving. De gastmodus toont een beperkte, alleen-lezen
|
||||
demowerkruimte; operatorfuncties en nieuwe analyses blijven afgeschermd.
|
||||
Unraid-productieomgeving. De gastmodus toont een projectgebonden demowerkruimte
|
||||
met dezelfde kaart-, bron-, model-, analyse-, QA- en exportflow als een
|
||||
operator. Alleen beheer, instellingen, uploads, bronconfiguratie,
|
||||
projectbeheer en evidence-review blijven afgeschermd.
|
||||
|
||||
### Interactieve projectketen
|
||||
|
||||
@@ -68,6 +70,11 @@ resultaten zijn vanuit dezelfde ruimtelijke context bereikbaar.
|
||||
|
||||

|
||||
|
||||
Op een breed scherm krijgt de kaart extra ruimte terwijl de themakolom en de
|
||||
controleerbare analysestappen zichtbaar blijven.
|
||||
|
||||

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