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

|

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

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