Publish curated GeoIntel source snapshot

This commit is contained in:
Jens committed 2026-09-29 22:16:56 +02:00
commit 0aae654d6e
764 files changed
+169154

No files matched your search

@@ -0,0 +1,57 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { LiveAnalysisJourney, resolveLiveAnalysisJourney } from './LiveAnalysisJourney'
const baseState = {
selectionMode: false,
hasSelection: false,
sourceLoading: false,
sourceReady: false,
processing: false,
validating: false,
hasResult: false,
verified: false,
error: null,
}
describe('resolveLiveAnalysisJourney', () => {
it('follows real workflow state without assuming completion', () => {
expect(resolveLiveAnalysisJourney(baseState).currentLabel).toBe('Selecteer')
expect(resolveLiveAnalysisJourney({ ...baseState, hasSelection: true, sourceLoading: true }).currentLabel).toBe('Bronnen')
expect(resolveLiveAnalysisJourney({ ...baseState, hasSelection: true, sourceReady: true, processing: true }).currentLabel).toBe('Verwerk')
expect(resolveLiveAnalysisJourney({ ...baseState, hasSelection: true, sourceReady: true, hasResult: true }).currentLabel).toBe('Controleer')
expect(resolveLiveAnalysisJourney({ ...baseState, hasSelection: true, sourceReady: true, hasResult: true, verified: true }).status).toBe('complete')
})
it('marks the reached stage as failed', () => {
const view = resolveLiveAnalysisJourney({
...baseState,
hasSelection: true,
sourceReady: true,
processing: true,
error: 'Bron tijdelijk niet beschikbaar.',
})
expect(view.status).toBe('error')
expect(view.steps[2]).toBe('error')
})
})
describe('LiveAnalysisJourney', () => {
it('announces verified evidence only when verification exists', () => {
render(
<LiveAnalysisJourney
{...baseState}
hasSelection
sourceReady
hasResult
verified
selectionLabel="Vrije kaartselectie"
sourceLabel="Gebouwen"
statusMessage="Analyse voltooid"
resultLabel="Kwaliteitsbewijs beschikbaar"
/>,
)
expect(screen.getAllByText('Kwaliteitsbewijs beschikbaar')).toHaveLength(2)
expect(screen.getByTestId('live-analysis-journey').getAttribute('data-stage')).toBe('controleer')
})
})
@@ -0,0 +1,97 @@
import { BadgeCheck, Database, MapPinned, ScanSearch, ShieldAlert } from 'lucide-react'
export type LiveAnalysisStepStatus = 'waiting' | 'active' | 'complete' | 'error'
export interface LiveAnalysisJourneyState {
selectionMode: boolean
hasSelection: boolean
sourceLoading: boolean
sourceReady: boolean
processing: boolean
validating: boolean
hasResult: boolean
verified: boolean
error: string | null
}
export interface LiveAnalysisJourneyView {
currentIndex: number
currentLabel: string
status: 'idle' | 'running' | 'complete' | 'error'
steps: LiveAnalysisStepStatus[]
}
const STEP_LABELS = ['Selecteer', 'Bronnen', 'Verwerk', 'Controleer'] as const
export function resolveLiveAnalysisJourney(state: LiveAnalysisJourneyState): LiveAnalysisJourneyView {
let currentIndex = 0
if (state.validating || state.verified || state.hasResult) currentIndex = 3
else if (state.processing) currentIndex = 2
else if (state.hasSelection) currentIndex = state.sourceLoading || !state.sourceReady ? 1 : 2
const status: LiveAnalysisJourneyView['status'] = state.error
? 'error'
: state.verified
? 'complete'
: state.sourceLoading || state.processing || state.validating
? 'running'
: 'idle'
const steps: LiveAnalysisStepStatus[] = [
state.hasSelection ? 'complete' : 'active',
state.hasSelection && state.sourceReady ? 'complete' : state.hasSelection ? 'active' : 'waiting',
state.hasResult ? 'complete' : state.processing || (state.hasSelection && state.sourceReady) ? 'active' : 'waiting',
state.verified ? 'complete' : state.validating || state.hasResult ? 'active' : 'waiting',
]
if (state.error) steps[currentIndex] = 'error'
return { currentIndex, currentLabel: STEP_LABELS[currentIndex], status, steps }
}
interface LiveAnalysisJourneyProps extends LiveAnalysisJourneyState {
selectionLabel: string
sourceLabel: string
statusMessage: string
resultLabel: string
}
const steps = [
{ label: 'Selecteer', icon: MapPinned },
{ label: 'Bronnen', icon: Database },
{ label: 'Verwerk', icon: ScanSearch },
{ label: 'Controleer', icon: BadgeCheck },
] as const
export function LiveAnalysisJourney({ selectionLabel, sourceLabel, statusMessage, resultLabel, ...state }: LiveAnalysisJourneyProps): JSX.Element {
const view = resolveLiveAnalysisJourney(state)
const detail = view.currentIndex === 0
? state.selectionMode ? 'Teken nu op de kaart' : selectionLabel
: view.currentIndex === 1
? sourceLabel
: view.currentIndex === 3 && state.verified
? resultLabel
: statusMessage
return (
<div className={`live-analysis-journey live-analysis-journey-${view.status}`} data-testid="live-analysis-journey" data-stage={view.currentLabel.toLocaleLowerCase('nl-BE')} aria-live="polite">
<div className="live-analysis-status-card">
<span className="live-analysis-kicker"><i aria-hidden="true" /> Live analyse</span>
<strong>{state.error ? 'Analyse onderbroken' : view.currentLabel}</strong>
<small>{state.error ?? detail}</small>
</div>
<ol className="live-analysis-steps" aria-label="Voortgang van de gebiedsanalyse">
{steps.map(({ label, icon: Icon }, index) => (
<li className={`live-analysis-step live-analysis-step-${view.steps[index]}`} key={label}>
<span><Icon aria-hidden="true" /></span>
<small>{label}</small>
</li>
))}
</ol>
<div className={`live-analysis-proof live-analysis-proof-${state.verified ? 'ready' : 'pending'}`}>
{state.error ? <ShieldAlert aria-hidden="true" /> : <BadgeCheck aria-hidden="true" />}
<span>{state.verified ? resultLabel : state.hasResult ? 'Resultaat wacht op controle' : 'Controleerbaar resultaat'}</span>
</div>
</div>
)
}
File diff suppressed because it is too large. Load diff
@@ -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()
})
})
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,17 @@
import { MapAdvancedWorkbench } from './MapAdvancedWorkbench'
import { MapExplorerView } from './MapExplorerView'
import type { MapWorkspaceProps } from './mapWorkspaceProps'
import { useMapWorkspaceViewModel } from './useMapWorkspaceViewModel'
/**
* The map workspace has two render paths over one body of derived state: the
* map-first explorer, and the advanced workbench behind it. Both read the same
* view model, so the choice between them is all that is left here.
*/
export function MapWorkspace(props: MapWorkspaceProps): JSX.Element {
const view = useMapWorkspaceViewModel(props)
return view.advancedMode
? <MapAdvancedWorkbench props={props} view={view} />
: <MapExplorerView props={props} view={view} />
}
@@ -0,0 +1,37 @@
import { cleanup, render } from '@testing-library/react'
import { fireEvent, screen, waitFor } from '@testing-library/dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { areasApi } from '../../services/api/areas'
import { MunicipalitySearch } from './MunicipalitySearch'
import type { AreaRead } from '../../types'
vi.mock('../../services/api/areas', () => ({
areasApi: { searchMunicipalities: vi.fn() },
}))
describe('MunicipalitySearch', () => {
beforeEach(() => {
vi.mocked(areasApi.searchMunicipalities).mockResolvedValue({
items: [{ niscode: '13025', name: 'Mol', name_nl: 'Mol', name_fr: 'Mol', name_de: 'Mol' }],
total: 1,
})
})
afterEach(() => cleanup())
it('searches the official municipality catalog and activates a result', async () => {
const activated = { id: 'mol', project_id: 'project', name: 'Gemeente Mol - NIS 13025' } as AreaRead
const onActivate = vi.fn().mockResolvedValue(activated)
render(<MunicipalitySearch projectId="project" activeArea={null} onActivate={onActivate} />)
fireEvent.change(screen.getByTestId('municipality-search-input'), { target: { value: 'Mol' } })
await waitFor(() => expect(areasApi.searchMunicipalities).toHaveBeenCalledWith('project', 'Mol'))
fireEvent.click(await screen.findByRole('button', { name: /Mol/ }))
await waitFor(() => expect(onActivate).toHaveBeenCalledWith('13025'))
})
it('presents municipality search as optional and keeps free selection visible', () => {
render(<MunicipalitySearch projectId="project" activeArea={null} onActivate={vi.fn()} />)
expect(screen.getByText('optioneel')).toBeTruthy()
expect(screen.getByText('Vrije kaartselectie')).toBeTruthy()
})
})
@@ -0,0 +1,109 @@
import { useEffect, useRef, useState } from 'react'
import { MapPin, Search, X } from 'lucide-react'
import { areasApi } from '../../services/api/areas'
import type { AreaRead, MunicipalitySearchItem } from '../../types'
interface MunicipalitySearchProps {
projectId: string | null
activeArea: AreaRead | null
disabled?: boolean
onActivate: (niscode: string) => Promise<AreaRead | null>
}
function areaDisplayName(area: AreaRead | null): string | null {
return area?.name.replace(/^Gemeente\s+/i, '').replace(/\s+-\s+NIS\s+\d+$/i, '') ?? null
}
export function MunicipalitySearch({ projectId, activeArea, disabled = false, onActivate }: MunicipalitySearchProps): JSX.Element {
const [query, setQuery] = useState('')
const [results, setResults] = useState<MunicipalitySearchItem[]>([])
const [searching, setSearching] = useState(false)
const [activatingCode, setActivatingCode] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const searchSequence = useRef(0)
const activeMunicipality = areaDisplayName(activeArea)
useEffect(() => {
const normalized = query.trim()
const requestId = ++searchSequence.current
if (!projectId || normalized.length < 2) {
setResults([])
setSearching(false)
setError(null)
return
}
setSearching(true)
setError(null)
const timeout = window.setTimeout(() => {
void areasApi.searchMunicipalities(projectId, normalized)
.then((response) => {
if (requestId === searchSequence.current) setResults(response.items)
})
.catch((caught: unknown) => {
if (requestId === searchSequence.current) setError(caught instanceof Error ? caught.message : 'Gemeenten zoeken is niet gelukt')
})
.finally(() => {
if (requestId === searchSequence.current) setSearching(false)
})
}, 220)
return () => window.clearTimeout(timeout)
}, [projectId, query])
const activate = async (municipality: MunicipalitySearchItem) => {
setActivatingCode(municipality.niscode)
setError(null)
const area = await onActivate(municipality.niscode)
if (area) {
setQuery('')
setResults([])
} else {
setError('De officiële gemeentegrens kon niet worden geactiveerd.')
}
setActivatingCode(null)
}
return (
<section className="municipality-search" aria-label="Optioneel een gemeente zoeken">
<div className="municipality-search-copy">
<span className="municipality-shortcut"><MapPin aria-hidden="true" /> Snelkeuze</span>
<div>
<strong>Ga naar een gemeente <small>optioneel</small></strong>
<p>Zoek een officiële grens, of teken straks vrij op de kaart.</p>
</div>
</div>
<div className="municipality-search-control">
<label>
<span className="sr-only">Zoek op gemeentenaam of NIS-code</span>
<Search aria-hidden="true" />
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Gemeentenaam of NIS-code"
disabled={disabled || !projectId}
autoComplete="off"
data-testid="municipality-search-input"
/>
{query ? <button type="button" className="municipality-search-clear" onClick={() => setQuery('')} aria-label="Zoekterm wissen"><X aria-hidden="true" /></button> : null}
</label>
{query.trim().length >= 2 ? (
<div className="municipality-search-results" aria-label="Gevonden gemeenten">
{searching ? <p role="status">Gemeenten zoeken…</p> : null}
{!searching && results.length === 0 && !error ? <p>Geen officiële gemeente gevonden.</p> : null}
{results.map((municipality) => (
<button key={municipality.niscode} type="button" onClick={() => void activate(municipality)} disabled={activatingCode !== null}>
<span><strong>{municipality.name}</strong><small>NIS {municipality.niscode}</small></span>
<em>{activatingCode === municipality.niscode ? 'Laden…' : 'Gebruik grens'}</em>
</button>
))}
{error ? <p className="error" role="alert">{error}</p> : null}
</div>
) : null}
</div>
<div className="municipality-search-status">
<span>Actief werkgebied</span>
<strong>{activeMunicipality ?? (activeArea?.name || 'Vrije kaartselectie')}</strong>
</div>
</section>
)
}
@@ -0,0 +1,76 @@
import type { TemporalObservation } from '../../types'
interface TemporalTrendChartProps {
timeline: TemporalObservation[]
metricKey: string
}
const WIDTH = 560
const HEIGHT = 150
const PADDING_X = 34
const PADDING_Y = 24
function formatValue(value: number, unit: string): string {
const maximumFractionDigits = unit === 'inwoners' || unit === 'objecten' ? 0 : 2
return `${value.toLocaleString('nl-BE', { maximumFractionDigits })} ${unit}`
}
export function TemporalTrendChart({ timeline, metricKey }: TemporalTrendChartProps): JSX.Element | null {
const observations = timeline.flatMap((observation) => {
const metric = observation.metrics.find((item) => item.metric_key === metricKey)
return metric ? [{ observation, metric }] : []
})
if (observations.length < 2) {
return null
}
const values = observations.map((item) => item.metric.value)
const minimum = Math.min(...values)
const maximum = Math.max(...values)
const range = maximum - minimum
const innerWidth = WIDTH - PADDING_X * 2
const innerHeight = HEIGHT - PADDING_Y * 2
const points = observations.map((item, index) => {
const x = PADDING_X + (index / (observations.length - 1)) * innerWidth
const normalized = range === 0 ? 0.5 : (item.metric.value - minimum) / range
const y = HEIGHT - PADDING_Y - normalized * innerHeight
return { ...item, x, y }
})
const pointString = points.map((point) => `${point.x},${point.y}`).join(' ')
const label = `${points[0].metric.label}: ${formatValue(points[0].metric.value, points[0].metric.unit)} tot ${formatValue(
points[points.length - 1].metric.value,
points[points.length - 1].metric.unit,
)}`
return (
<div className="geo-temporal-chart" aria-label={label}>
<div className="geo-temporal-chart-heading">
<div>
<span>Volledige tijdreeks</span>
<strong>{points[0].metric.label}</strong>
</div>
<span>{points.length} meetmomenten</span>
</div>
<svg viewBox={`0 0 ${WIDTH} ${HEIGHT}`} role="img" aria-label={label} preserveAspectRatio="none">
<line x1={PADDING_X} x2={WIDTH - PADDING_X} y1={HEIGHT - PADDING_Y} y2={HEIGHT - PADDING_Y} />
<polyline points={pointString} />
{points.map((point) => (
<g key={point.observation.dataset.id}>
<circle cx={point.x} cy={point.y} r="4" />
<text x={point.x} y={HEIGHT - 6} textAnchor="middle">
{new Date(point.observation.dataset.observed_at).getUTCFullYear()}
</text>
</g>
))}
</svg>
<div className="geo-temporal-chart-values">
{points.map((point) => (
<div key={point.observation.dataset.id}>
<span>{new Date(point.observation.dataset.observed_at).getUTCFullYear()}</span>
<strong>{formatValue(point.metric.value, point.metric.unit)}</strong>
</div>
))}
</div>
</div>
)
}
@@ -0,0 +1,54 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ThemeSearchField } from './ThemeSearchField'
describe('ThemeSearchField', () => {
afterEach(() => {
cleanup()
vi.useRealTimers()
})
it('toont wat de gebruiker typt zonder daarop te wachten', () => {
vi.useFakeTimers()
const onChange = vi.fn()
render(<ThemeSearchField waarde="" onChange={onChange} />)
const veld = screen.getByRole('searchbox')
fireEvent.change(veld, { target: { value: 'beb' } })
expect((veld as HTMLInputElement).value).toBe('beb')
// De boven­liggende werkruimte weet er nog niets van; die hertekende
// voorheen bij elke letter.
expect(onChange).not.toHaveBeenCalled()
})
it('geeft de waarde één keer door wanneer het typen stopt', () => {
vi.useFakeTimers()
const onChange = vi.fn()
render(<ThemeSearchField waarde="" onChange={onChange} />)
const veld = screen.getByRole('searchbox')
for (const tekst of ['b', 'be', 'beb', 'bebo', 'bebou', 'bebouw']) {
fireEvent.change(veld, { target: { value: tekst } })
vi.advanceTimersByTime(40)
}
expect(onChange).not.toHaveBeenCalled()
vi.advanceTimersByTime(200)
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange).toHaveBeenCalledWith('bebouw')
})
it('volgt een waarde die van buitenaf gewist wordt', () => {
const onChange = vi.fn()
const { rerender } = render(<ThemeSearchField waarde="bebouw" onChange={onChange} />)
expect((screen.getByRole('searchbox') as HTMLInputElement).value).toBe('bebouw')
rerender(<ThemeSearchField waarde="" onChange={onChange} />)
expect((screen.getByRole('searchbox') as HTMLInputElement).value).toBe('')
})
it('houdt een toegankelijke naam', () => {
render(<ThemeSearchField waarde="" onChange={vi.fn()} />)
expect(screen.getByRole('searchbox', { name: /zoek een thema/i })).toBeTruthy()
})
})
@@ -0,0 +1,55 @@
import { useEffect, useRef, useState } from 'react'
import { Search } from 'lucide-react'
interface ThemeSearchFieldProps {
waarde: string
onChange: (waarde: string) => void
}
/**
* Het zoekveld boven de themalijst, met zijn eigen invoertoestand.
*
* De filterwaarde zelf leeft in het viewmodel van de kaartwerkruimte, dat de
* hele werkruimte voedt. Rechtstreeks doorgeven betekende dat elke toetsaanslag
* de volledige boom hertekende, inclusief de kaart: gemeten 33 tot 58 ms per
* letter met lege data, en dat loopt op zodra er echt bronnen in staan.
*
* Wat de gebruiker typt blijft nu hier. Pas als het even stil is gaat de waarde
* naar boven. Typen voelt daardoor direct, terwijl de lijst een fractie later
* bijtrekt — wat bij zoeken ook het gewenste gedrag is, want filteren op elke
* losse letter levert toch geen bruikbaar tussenresultaat.
*/
const STILTE_MS = 160
export function ThemeSearchField({ waarde, onChange }: ThemeSearchFieldProps): JSX.Element {
const [invoer, setInvoer] = useState(waarde)
const onChangeRef = useRef(onChange)
onChangeRef.current = onChange
// Wordt de waarde van buitenaf gewist (bijvoorbeeld bij een andere
// werkruimte), dan volgt het veld.
useEffect(() => {
setInvoer((huidig) => (huidig === waarde ? huidig : waarde))
}, [waarde])
useEffect(() => {
if (invoer === waarde) return
const teller = window.setTimeout(() => onChangeRef.current(invoer), STILTE_MS)
return () => window.clearTimeout(teller)
// waarde bewust niet in de lijst: die verandert door onze eigen melding.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [invoer])
return (
<label className="geo-theme-search">
<span className="sr-only">Zoek een thema of gegevensbron</span>
<Search aria-hidden="true" />
<input
type="search"
value={invoer}
onChange={(event) => setInvoer(event.target.value)}
placeholder="Zoek thema’s"
/>
</label>
)
}
@@ -0,0 +1,91 @@
/**
* Kaartsymbologie.
*
* De kleuren van de kaartlagen stonden als losse hexwaarden door GeoMap.tsx
* heen. Het waren framework-standaardkleuren die bij geen enkel token uit het
* designsysteem hoorden, en op de donkere operationele ondergrond vielen de
* donkere varianten volledig weg.
*
* Deze waarden horen bij de donkere werkstand en zijn afgestemd op de tokens
* in geointel-system.css, sectie "Operationeel donker". Ze staan hier als
* letterlijke waarden omdat MapLibre paint-eigenschappen geen CSS-variabelen
* kunnen lezen; wijzigt een token, dan wijzigt zijn tegenhanger hier mee.
*/
export const mapSymbology = {
/** Officiële grenzen. Komt overeen met --gi-brand-500. */
boundary: '#45cfb4',
boundaryStrong: '#38c0a5',
/** Verandering tussen twee meetmomenten. */
added: '#4cc48d',
removed: '#ec7d76',
modified: '#dda45e',
unchanged: '#6fb0dd',
/** Eigen ingeladen data. Oranje blijft de tegenkleur van het teal-merk. */
dataFill: '#fb923c',
dataLine: '#f97316',
/** Actieve selectie. Geel leest op elke ondergrond. */
selectionFill: '#fde047',
selectionLine: '#fbbf24',
/** Water en bathymetrie. */
waterFill: '#38bdf8',
waterLine: '#7dd3fc',
/** Modelresultaten uit beeldanalyse. */
detectionFill: '#a78bfa',
detectionLine: '#c4b5fd',
/** Randen van puntsymbolen, tegen elke vulkleur. */
pointStroke: '#ffffff',
/** Terugvalkleur wanneer een feature geen bekende klasse heeft. */
fallback: '#94a3b8',
} as const
export type KaartWerkstand = 'dark' | 'light'
/**
* De verf van de ondergrond.
*
* De tegels zijn consumentenkaarten: rode snelwegen, groene bossen,
* POI-drukte. In beide werkstanden worden ze ontkleurd zodat alleen de eigen
* data nog kleur draagt; het verschil zit in hoe ver ze gedempt worden.
*
* De featurekleuren hierboven blijven in beide werkstanden gelijk. Het zijn
* middentonen die op een lichte én een donkere ondergrond leesbaar zijn, en
* één set houdt de betekenis van een kleur constant wanneer een gebruiker
* tussen de twee wisselt.
*/
export function basemapPaint(werkstand: KaartWerkstand): Record<string, number> {
if (werkstand === 'light') {
return {
'raster-saturation': -0.62,
'raster-brightness-min': 0.32,
'raster-brightness-max': 1,
'raster-contrast': -0.14,
'raster-hue-rotate': 140,
'raster-opacity': 0.92,
}
}
return {
'raster-saturation': -0.78,
'raster-brightness-min': 0.03,
'raster-brightness-max': 0.44,
'raster-contrast': -0.08,
'raster-hue-rotate': 140,
'raster-opacity': 0.9,
}
}
export function basemapGround(werkstand: KaartWerkstand): string {
return werkstand === 'light' ? '#eef2f0' : '#0a100e'
}
export function huidigeWerkstand(): KaartWerkstand {
if (typeof document === 'undefined') return 'dark'
return document.body.dataset.theme === 'light' ? 'light' : 'dark'
}
@@ -0,0 +1,107 @@
/**
* The map workspace's props.
*
* Its own module so the view-model hook and both render paths can name the
* same shape without importing the component that renders them.
*/
import type { AreaRead, CoverageResolveResponse, DatasetCreateResponse, DetectionQaResult, MapResultExportRequest, MapViewportState, OrthophotoAcquisitionResult, OrthophotoProductRead, ProjectRead, QaComparisonResult, VectorSelectionBBox, VectorSelectionResponse } from '../../types'
export interface MapWorkspaceProps {
readOnly?: boolean
managementLocked?: boolean
sourceAcquisitionEnabled?: boolean
derivedDatasetWritesEnabled?: boolean
secondaryResultsContainer?: HTMLElement | null
selectedProjectId: string | null
projects: ProjectRead[]
areas: AreaRead[]
selectedMapAreaId: string
areaFeatureCollection: GeoJSON.FeatureCollection | null
mapFeatureCollection: GeoJSON.FeatureCollection | null
qualityEvidenceGeoJson?: GeoJSON.FeatureCollection | null
qualityEvidenceFeatureCount?: number
qualityEvidenceLoading?: boolean
qualityEvidenceError?: string | null
qualityEvidenceWarnings?: string[]
mapLayerLabel: string
mapLayerSourceLabel: string
mapLayerProvenance: string
mapLayerVisible: boolean
mapLayerOpacity: number
areaLayerVisible: boolean
areaLayerOpacity: number
mapFeatureCount: number
areaFeatureCount: number
viewportVectorEnabled: boolean
viewportVectorStatus: string | null
viewportVectorTone: 'ready' | 'pending' | 'warning' | 'error'
fitMapDataOnChange: boolean
mapContentMode: 'dataset' | 'analysis'
analysisLayerAvailable: boolean
selectedMapFeature: GeoJSON.Feature | null
selectedFeature?: GeoJSON.Feature | null
mapSelectionBbox: VectorSelectionBBox | null
mapSelectionResult: VectorSelectionResponse | null
mapSelectionLoading: boolean
mapSelectionError: string | null
coverage: CoverageResolveResponse | null
coverageLoading: boolean
coverageError: string | null
coverageDurationMs: number | null
coverageBudgetExceeded: boolean
workspaceLoading: boolean
workspaceError: string | null
selectionExporting: boolean
selectionExportError: string | null
latestSelectionExportPath: string | null
selectionDatasetSaving: boolean
selectionDatasetError: string | null
latestSelectionDataset: DatasetCreateResponse | null
latestSelectionDatasetName: string | null
mapQaReferenceDatasets: DatasetCreateResponse[]
selectedMapQaReferenceDatasetId: string
mapSelectionQaRunning: boolean
mapSelectionQaError: string | null
mapSelectionQaResult: QaComparisonResult | null
latestMapSelectionQualityCheckId: string | null
orthophotoAnalysisStage: 'idle' | 'acquiring' | 'detecting' | 'validating' | 'complete' | 'failed'
orthophotoAnalysisStatus: string
orthophotoAnalysisError: string | null
orthophotoAnalysisRunning: boolean
orthophotoAnalysisQuality: DetectionQaResult | null
orthophotoAnalysisDetectionCount: number | null
orthophotoProducts: OrthophotoProductRead[]
selectedOrthophotoProductKey: string
orthophotoResult: OrthophotoAcquisitionResult | null
orthophotoImageUrl: string | null
availableMapDatasets: DatasetCreateResponse[]
selectedMapDatasetId: string
onSelectMapArea: (areaId: string) => void
onActivateMunicipality: (niscode: string) => Promise<AreaRead | null>
onSetContextSourceLabel: (label: string | null) => void
onSetContextLayerLabel: (label: string | null) => void
onOpenDatasetInMap: (dataset: DatasetCreateResponse) => void
onSetAreaLayerVisible: (visible: boolean) => void
onSetAreaLayerOpacity: (opacity: number) => void
onSetMapLayerVisible: (visible: boolean) => void
onSetMapLayerOpacity: (opacity: number) => void
onSetMapContentMode: (mode: 'dataset' | 'analysis') => void
onSelectMapFeature: (feature: GeoJSON.Feature | null) => void
onMapViewportChange: (viewport: MapViewportState) => void
onSetMapSelectionBbox: (bbox: VectorSelectionBBox | null) => void
onRunMapSelectionExtract: (bbox: VectorSelectionBBox, areaId?: string) => Promise<VectorSelectionResponse | null>
onClearMapSelectionExtract: () => void
onExportMapSelection: (bbox: VectorSelectionBBox, areaId?: string) => Promise<unknown>
onPersistMapResult: (payload: MapResultExportRequest) => Promise<unknown>
onDeriveMapSelectionDataset: (bbox: VectorSelectionBBox, areaId?: string) => Promise<DatasetCreateResponse | null>
onSelectMapQaReferenceDataset: (datasetId: string) => void
onRunMapSelectionQa: (candidateDataset?: DatasetCreateResponse | null) => Promise<QaComparisonResult | null>
onOpenMapSelectionQualityEvidence: () => void
onRunOrthophotoAnalysis: (bbox: VectorSelectionBBox) => Promise<boolean>
onSelectOrthophotoProduct: (productKey: string) => void
onClearQualityEvidence?: () => void
onRefreshProjectData: () => Promise<unknown>
onOpenAssistant: () => void
onOpenExports: () => void
}
@@ -0,0 +1,612 @@
/**
* The map workspace's domain layer: which themes exist, which persisted dataset
* answers a theme, and how a dataset describes itself to an operator.
*
* This was ~590 lines at the top of MapWorkspace.tsx, above a 3.200-line
* component. None of it is React, all of it is testable on its own, and both
* render paths in that component read from it.
*/
import type {
CoverageStatus,
DatasetCreateResponse,
VectorSelectionBBox,
} from '../../types'
import type { MapThemeAcquisition } from '../../hooks/useMapThemeSelectionInsights'
import { getDatasetDisplayName } from '../../lib/datasetDisplay'
import {
deduplicateTemporalSnapshots,
isSelectionBoundedDataset,
selectedAreaCoverageZones,
selectionAnalysisScale,
selectionDimensions,
temporalDatasetAreaMatch,
} from './mapWorkspaceUtils'
export const DEFAULT_SELECTED_FEATURE_FILENAME = 'selected-feature.geojson'
export const DEFAULT_AREA_SELECTION_FILENAME = 'area-selection.geojson'
export const EMPTY_TEMPORAL_SERIES: DatasetCreateResponse[] = []
export type DataThemeId =
| 'administrative'
| 'buildings'
| 'land_cover'
| 'space_occupation'
| 'open_space'
| 'population'
| 'forest'
| 'nature_value'
| 'agriculture'
| 'soil'
| 'water'
| 'bathymetry'
| 'flood_hazard'
| 'elevation'
| 'accessibility'
| 'services'
| 'roads'
| 'parcels'
| 'maritime_planning'
| 'marine_environment'
export interface DataTheme {
id: DataThemeId
label: string
shortLabel: string
description: string
tokens: string[]
}
export interface TemporalSeriesGroup {
key: string
label: string
items: DatasetCreateResponse[]
}
export interface OnDemandMapProduct extends MapThemeAcquisition {
theme: DataThemeId
availabilityLabel: string
attribution: string
limitationMessage: string
coverageZones: string[]
}
export interface PlannedOnDemandMapProduct extends OnDemandMapProduct {
acquisitionBboxes: VectorSelectionBBox[]
}
export function productSupportsSelection(product: OnDemandMapProduct, bbox: VectorSelectionBBox): boolean {
const scale = selectionAnalysisScale(bbox)
if (scale === 'overview') return true
const dimensions = selectionDimensions(bbox)
if (product.kind === 'dhmv' || product.kind === 'spw_terrain' || product.kind === 'flood_hazard') {
return dimensions.areaSquareMetres <= 280_000_000
}
if (product.kind === 'thematic_raster' || product.kind === 'walous') {
return dimensions.widthMetres <= 50_000
&& dimensions.heightMetres <= 50_000
&& dimensions.areaSquareMetres <= 2_800_000_000
}
return true
}
export const DATA_THEMES: DataTheme[] = [
{
id: 'administrative',
label: 'Bestuurlijke indeling',
shortLabel: 'Bestuursgebieden',
description: 'Officiële lands-, gewest-, provincie- en gemeentegrenzen van het NGI.',
tokens: ['administrative', 'adminvector', 'belgium_land_boundary', 'belgium_regions', 'belgium_provinces', 'belgium_municipalities'],
},
{
id: 'buildings',
label: 'Bebouwing',
shortLabel: 'Gebouwen',
description: 'Gebouwen en gebouwcontouren uit GRB of een andere persistente bron.',
tokens: ['buildings', 'building', 'gebouwen', 'gebouw', 'bebouwing', 'gbg'],
},
{
id: 'land_cover',
label: 'Landbedekking',
shortLabel: 'Landbedekking',
description: 'Fysieke en biologische bodembedekking uit een officieel regionaal classificatieraster.',
tokens: ['land_cover', 'land_cover_use', 'landbedekking', 'walous', 'occupation du sol'],
},
{
id: 'space_occupation',
label: 'Ruimtebeslag',
shortLabel: 'Ruimtebeslag',
description: 'Officiële 10 m-beleidskaart van ruimte ingenomen door wonen, economie, infrastructuur en recreatie.',
tokens: ['space_occupation', 'ruimtebeslag', 'ruibes'],
},
{
id: 'open_space',
label: 'Open ruimte',
shortLabel: 'Open ruimte',
description: 'Officiële 10 m-beleidskaart van open ruimte buiten kernen en ruimtebeslag.',
tokens: ['open_space', 'open ruimte', 'openruimte'],
},
{
id: 'population',
label: 'Bevolking',
shortLabel: 'Inwoners',
description: 'Bevolkingscijfers of statistische raster- en vectorzones.',
tokens: ['population', 'bevolking', 'inwoners', 'inhabitants', 'census'],
},
{
id: 'forest',
label: 'Bos & groen',
shortLabel: 'Bos',
description: 'Bos, natuur en groenbedekking uit een ingeladen vectorbron.',
tokens: ['forest', 'forestry', 'woodland', 'bos', 'groen', 'vegetation'],
},
{
id: 'nature_value',
label: 'Natuurwaarde',
shortLabel: 'BWK-oppervlakte',
description: 'Biologische waardering, Natura 2000-habitat en regionaal belangrijke biotopen uit de BWK.',
tokens: ['nature_value', 'nature value', 'natuurwaarde', 'bwk', 'natura2000', 'natura 2000', 'biodiversity'],
},
{
id: 'agriculture',
label: 'Landbouw',
shortLabel: 'Landbouwgebruik',
description: 'Jaarlijkse officiële landbouwgebruikspercelen en hoofdteeltgroepen.',
tokens: ['agriculture', 'agricultural', 'landbouw', 'landbouwgebruik', 'agpa'],
},
{
id: 'soil',
label: 'Bodem',
shortLabel: 'Bodemkaart',
description: 'Officiele bodemkartering met bodemtype, textuur en drainageklasse waar de geselecteerde zone door een gekoppelde bron wordt gedekt.',
tokens: ['soil', 'bodem', 'bodemkaart', 'bodemtype', 'dov_soil_map'],
},
{
id: 'water',
label: 'Water',
shortLabel: 'Water',
description: 'Waterlopen, grachten, kanalen en wateroppervlakken.',
tokens: ['waterways', 'waterway', 'water', 'hydro', 'river', 'stream', 'canal', 'waterloop'],
},
{
id: 'bathymetry',
label: 'Waterbodem',
shortLabel: 'Dwarsprofielen',
description: 'Officiële historische VHA-dwarsprofielen met meetvelden en brondocumenten.',
tokens: ['bathymetry', 'bathymetry_profiles', 'dwarsprofielen', 'waterbodem'],
},
{
id: 'flood_hazard',
label: 'Overstroming',
shortLabel: 'Overstroomd oppervlak',
description: 'Gemodelleerde maximale waterdiepte per VMM-kans- en klimaatscenario.',
tokens: ['flood_hazard', 'flood depth', 'flood_depth', 'overstroming', 'waterdiepte'],
},
{
id: 'elevation',
label: 'Hoogte & reliëf',
shortLabel: 'Hoogte',
description: 'Maaiveld- of oppervlaktehoogte, reliëf en helling uit DHMV II.',
tokens: ['dhmv', 'elevation', 'height', 'hoogte', 'terrain', 'surface', 'dtm', 'dsm', 'reliëf'],
},
{
id: 'accessibility',
label: 'Bereikbaarheid',
shortLabel: 'Knooppuntwaarde',
description: 'Knooppuntwaarde van collectief vervoer per hectare voor referentiejaar 2022.',
tokens: ['accessibility', 'bereikbaarheid', 'knooppuntwaarde', 'knptw'],
},
{
id: 'services',
label: 'Voorzieningen',
shortLabel: 'Voorzieningenniveau',
description: 'Genormaliseerde nabijheid van basis-, regionale en metropolitane voorzieningen in 2022.',
tokens: ['services', 'voorzieningen', 'voorzieningenniveau', 'totvznv'],
},
{
id: 'roads',
label: 'Wegen',
shortLabel: 'Wegen',
description: 'Wegen en wegsegmenten uit een persistente bron.',
tokens: ['roads', 'road', 'wegen', 'wegsegment', 'street'],
},
{
id: 'parcels',
label: 'Percelen',
shortLabel: 'Percelen',
description: 'Kadastrale of administratieve perceelcontouren.',
tokens: ['parcels', 'parcel', 'percelen', 'perceel', 'cadastre', 'kadaster'],
},
{
id: 'maritime_planning',
label: 'Maritieme planning',
shortLabel: 'Plan- en gebruikszones',
description: 'Officiële gebruiks- en beschermingszones uit het Belgisch Marien Ruimtelijk Plan 2026-2034.',
tokens: ['maritime_planning', 'marine_spatial_plan', 'rbins_msp', 'bmsp', 'imsp26'],
},
{
id: 'marine_environment',
label: 'Mariene rapportagezones',
shortLabel: 'Zeegebieden',
description: 'Officiële juridische en mariene rapportagegebieden voor het Belgische deel van de Noordzee.',
tokens: ['marine_environment', 'marine_legal_scopes', 'marine_reporting_units', 'rbins_marine_reporting'],
},
]
export const COVERAGE_THEME_BY_MAP_THEME: Record<DataThemeId, string> = {
administrative: 'admin',
buildings: 'buildings',
land_cover: 'land_cover_use',
space_occupation: 'land_cover_use',
open_space: 'land_cover_use',
population: 'population',
forest: 'land_cover_use',
nature_value: 'nature',
agriculture: 'land_cover_use',
soil: 'soil',
water: 'surface_water',
bathymetry: 'bathymetry',
flood_hazard: 'flood_climate',
elevation: 'elevation',
accessibility: 'roads',
services: 'population',
roads: 'roads',
parcels: 'parcels',
maritime_planning: 'maritime_planning',
marine_environment: 'marine_environment',
}
export function coverageStatusLabel(status: CoverageStatus): string {
const labels: Record<CoverageStatus, string> = {
operational: 'Beschikbaar',
partial: 'Gedeeltelijk',
not_configured: 'Niet gekoppeld',
unsupported: 'Niet ondersteund',
}
return labels[status]
}
export function coverageZoneLabel(zone: string): string {
const labels: Record<string, string> = {
belgium: 'België',
flanders: 'Vlaanderen',
wallonia: 'Wallonie',
brussels: 'Brussel',
belgian_north_sea: 'Belgische Noordzee',
territorial_sea: 'Territoriale zee',
exclusive_economic_zone: 'EEZ',
continental_shelf: 'Continentaal plat',
}
return labels[zone] ?? zone
}
export const DATA_THEME_MAP_STYLES: Record<DataThemeId, { fill: string; line: string }> = {
administrative: { fill: '#5f6f7f', line: '#344554' },
buildings: { fill: '#d45f3d', line: '#9f3e24' },
land_cover: { fill: '#4f7b4f', line: '#315c39' },
space_occupation: { fill: '#be3e33', line: '#8f2c24' },
open_space: { fill: '#267a46', line: '#175c32' },
population: { fill: '#7559a6', line: '#5b3f88' },
forest: { fill: '#347950', line: '#225f3b' },
nature_value: { fill: '#9a4f64', line: '#74364a' },
agriculture: { fill: '#7b8f32', line: '#53671d' },
soil: { fill: '#9a7040', line: '#6f4c27' },
water: { fill: '#2676a8', line: '#155b85' },
bathymetry: { fill: '#0e7490', line: '#164e63' },
flood_hazard: { fill: '#1597c2', line: '#075985' },
elevation: { fill: '#a57a4b', line: '#315f59' },
accessibility: { fill: '#0f766e', line: '#115e59' },
services: { fill: '#b66d16', line: '#854d0e' },
roads: { fill: '#6b7280', line: '#4b5563' },
parcels: { fill: '#a7792f', line: '#7d571f' },
maritime_planning: { fill: '#2f7f8f', line: '#145d6a' },
marine_environment: { fill: '#3475a3', line: '#1c557d' },
}
export function datasetSearchText(dataset: DatasetCreateResponse): string {
return [
dataset.name,
dataset.original_filename,
dataset.source,
dataset.source_name,
dataset.reference_layer_name,
dataset.metadata_json?.['layer_name'],
dataset.source_metadata?.['layer_name'],
dataset.source_metadata?.['theme'],
dataset.source_metadata?.['product_display_name'],
]
.filter(Boolean)
.join(' ')
.toLowerCase()
}
export function datasetMatchesTheme(dataset: DatasetCreateResponse, theme: DataTheme): boolean {
// Governed raster products have one unambiguous semantic theme. Matching
// them by generic substrings (for example "water" in "waterdiepte") would
// make a flood scenario replace the permanent surface-water layer.
if (dataset.source_name === 'vmm_flood_hazard') {
return theme.id === 'flood_hazard'
}
if (dataset.source_name === 'vmm_vha_bathymetry_profiles') {
return theme.id === 'bathymetry'
}
if (dataset.source_name === 'spw_bathymetry') {
return theme.id === 'bathymetry'
}
if (['digitaal_vlaanderen_dhmv', 'spw_terrain'].includes(dataset.source_name ?? '')) {
return theme.id === 'elevation'
}
if (dataset.source_name === 'department_omgeving_thematic_raster') {
return dataset.source_metadata?.['theme'] === theme.id
}
if (dataset.source_name === 'spw_walous_land_cover') {
return theme.id === 'land_cover'
}
if (dataset.source_name === 'dov_soil_map') {
return theme.id === 'soil'
}
if (dataset.source_name === 'ngi_adminvector') {
return theme.id === 'administrative'
}
if (dataset.source_name === 'rbins_msp_2026') {
return theme.id === 'maritime_planning'
}
if (dataset.source_name === 'rbins_marine_reporting_units') {
return theme.id === 'marine_environment'
}
const searchText = datasetSearchText(dataset)
return theme.tokens.some((token) => searchText.includes(token))
}
export function isPartitionedRaster(dataset: DatasetCreateResponse | null | undefined): boolean {
return Boolean(
dataset?.dataset_type === 'raster'
&& ['digitaal_vlaanderen_dhmv', 'spw_terrain', 'vmm_flood_hazard'].includes(dataset.source_name ?? ''),
)
}
export function isPartitionedBathymetry(dataset: DatasetCreateResponse | null | undefined): boolean {
return Boolean(
dataset?.source_name === 'vmm_vha_bathymetry_profiles'
&& dataset.source_metadata?.['regional_partitions_complete'] === true,
)
}
export function datasetProductKey(dataset: DatasetCreateResponse): string {
return String(dataset.source_metadata?.['product_key'] ?? '')
}
export function datasetCoversSelectedArea(
dataset: DatasetCreateResponse,
selectedAreaId: string | null,
selectedAreaName: string | null | undefined,
regionalScope = false,
): boolean {
if (isSelectionBoundedDataset(dataset.source_metadata)) {
return false
}
const selectedZones = selectedAreaCoverageZones(selectedAreaName)
const configuredZones = dataset.source_metadata?.['coverage_zones']
if (selectedZones && Array.isArray(configuredZones) && configuredZones.length > 0) {
const datasetZones = configuredZones.map((zone) => String(zone))
if (!selectedZones.some((zone) => datasetZones.includes(zone))) {
return false
}
}
const coverageScope = String(dataset.source_metadata?.['coverage_scope'] ?? '')
if (isPartitionedBathymetry(dataset)) {
return regionalScope
? true
: Boolean(selectedAreaId) && dataset.area_id === selectedAreaId
}
if (coverageScope !== 'municipality' || !dataset.area_id) {
return true
}
if (regionalScope) {
return true
}
return Boolean(selectedAreaId) && dataset.area_id === selectedAreaId
}
export function rasterPartitionsForDataset(
datasets: DatasetCreateResponse[],
representative: DatasetCreateResponse | null,
selectedAreaId: string | null,
selectedAreaName: string | null | undefined,
regionalScope: boolean,
): DatasetCreateResponse[] {
if (!representative) {
return []
}
if (!regionalScope || (!isPartitionedRaster(representative) && !isPartitionedBathymetry(representative))) {
return [representative]
}
const productKey = datasetProductKey(representative)
const manifestSha256 = String(representative.source_metadata?.['partition_manifest_sha256'] ?? '')
return datasets
.filter(
(dataset) =>
dataset.source_name === representative.source_name
&& (
isPartitionedBathymetry(representative)
? String(dataset.source_metadata?.['partition_manifest_sha256'] ?? '') === manifestSha256
: datasetProductKey(dataset) === productKey
)
&& datasetCoversSelectedArea(dataset, selectedAreaId, selectedAreaName, true),
)
.sort((left, right) => String(left.area_id ?? '').localeCompare(String(right.area_id ?? '')))
}
export function pickThemeDataset(
datasets: DatasetCreateResponse[],
theme: DataTheme,
selectedAreaId: string | null,
selectedAreaName: string | null | undefined,
regionalScope = false,
): DatasetCreateResponse | null {
const candidates = datasets.filter(
(dataset) =>
datasetMatchesTheme(dataset, theme)
&& datasetCoversSelectedArea(dataset, selectedAreaId, selectedAreaName, regionalScope),
)
candidates.sort((left, right) => {
const priorityScore = (dataset: DatasetCreateResponse) =>
(dataset.area_id && dataset.area_id === selectedAreaId ? 10_000_000 : 0) +
(dataset.reference_layer_name && theme.tokens.includes(dataset.reference_layer_name.toLowerCase()) ? 1_000_000 : 0) +
(dataset.source_name === 'grb' ? 100_000 : 0) +
(dataset.source_name === 'department_omgeving_land_use' ? 90_000 : 0) +
(dataset.source_name === 'inbo_bwk_natura2000' ? 95_000 : 0) +
(dataset.source_name === 'agentschap_landbouw_zeevisserij_agricultural_parcels' ? 98_000 : 0) +
(dataset.source_name === 'department_omgeving_thematic_raster' ? 5_000_000 : 0) +
(dataset.source_name === 'spw_walous_land_cover' ? 5_000_000 : 0) +
(dataset.source_name === 'digitaal_vlaanderen_buildings_addresses_register' ? 5_000_000 : 0) +
(dataset.source_name === 'digitaal_vlaanderen_dhmv' ? 5_000_000 : 0) +
(dataset.source_name === 'spw_terrain' ? 5_000_000 : 0) +
(dataset.source_name === 'vmm_flood_hazard' ? 5_000_000 : 0) +
(dataset.source_name === 'vmm_vha_bathymetry_profiles' ? 5_000_000 : 0) +
(dataset.source_name === 'spw_bathymetry' ? 5_100_000 : 0) +
(dataset.source_metadata?.['product_key'] === 'dtm_1m' ? 1_000_000 : 0) +
(dataset.source_metadata?.['product_key'] === 'pluviaal_current_t100' ? 1_000_000 : 0) +
(dataset.dataset_role === 'reference' ? 10_000 : 0)
const priorityDifference = priorityScore(right) - priorityScore(left)
if (priorityDifference !== 0) return priorityDifference
const observedAtDifference = new Date(right.observed_at ?? 0).getTime() - new Date(left.observed_at ?? 0).getTime()
if (observedAtDifference !== 0) return observedAtDifference
const importedAtDifference = new Date(right.imported_at ?? 0).getTime() - new Date(left.imported_at ?? 0).getTime()
if (importedAtDifference !== 0) return importedAtDifference
return (right.feature_count ?? right.vector_summary?.feature_count ?? 0)
- (left.feature_count ?? left.vector_summary?.feature_count ?? 0)
})
return candidates[0] ?? null
}
export function themeIdForDataset(dataset: DatasetCreateResponse | null): DataThemeId | null {
return dataset
? DATA_THEMES.find((theme) => datasetMatchesTheme(dataset, theme))?.id ?? null
: null
}
export function temporalSeriesLabel(items: DatasetCreateResponse[]): string {
const configuredLabel = items.find((item) => typeof item.source_metadata?.['temporal_series_label'] === 'string')
?.source_metadata?.['temporal_series_label']
if (typeof configuredLabel === 'string' && configuredLabel.trim()) {
return configuredLabel
}
const first = items[0]
const source = first ? getDatasetDisplayName(first) : 'Tijdreeks'
const range = temporalRangeLabel(items)
return range ? `${source} (${range})` : source
}
export function listThemeTemporalSeries(
datasets: DatasetCreateResponse[],
theme: DataTheme,
selectedAreaId: string | null,
): TemporalSeriesGroup[] {
const groups = new Map<string, DatasetCreateResponse[]>()
for (const dataset of datasets) {
if (!datasetMatchesTheme(dataset, theme) || !dataset.temporal_series_key || !dataset.observed_at) {
continue
}
if (temporalDatasetAreaMatch(dataset, selectedAreaId) === 'other') {
continue
}
const items = groups.get(dataset.temporal_series_key) ?? []
items.push(dataset)
groups.set(dataset.temporal_series_key, items)
}
const series = Array.from(groups.entries())
.map(([key, items]) => {
const ordered = deduplicateTemporalSnapshots(items)
return { key, label: temporalSeriesLabel(ordered), items: ordered }
})
.filter((group) => group.items.length >= 2)
const sourcesWithExactAreaSeries = new Set(
series
.filter((group) => group.items.some((item) => temporalDatasetAreaMatch(item, selectedAreaId) === 'exact'))
.map((group) => group.items[0]?.source_name)
.filter(Boolean),
)
return series
.filter((group) => {
const sourceName = group.items[0]?.source_name
if (!sourceName || !sourcesWithExactAreaSeries.has(sourceName)) return true
return group.items.some((item) => temporalDatasetAreaMatch(item, selectedAreaId) === 'exact')
})
.sort((left, right) => {
if (right.items.length !== left.items.length) {
return right.items.length - left.items.length
}
const latest = (group: TemporalSeriesGroup) => Math.max(...group.items.map((item) => new Date(item.observed_at ?? 0).getTime()))
return latest(right) - latest(left)
})
}
export function formatObservationDate(value: string | null | undefined): string {
if (!value) {
return 'Geen peildatum'
}
return new Intl.DateTimeFormat('nl-BE', { year: 'numeric', month: 'short', day: 'numeric' }).format(new Date(value))
}
export function temporalRangeLabel(items: DatasetCreateResponse[]): string | null {
const firstValue = items[0]?.observed_at
const lastValue = items[items.length - 1]?.observed_at
if (!firstValue || !lastValue) {
return null
}
const first = new Date(firstValue)
const last = new Date(lastValue)
if (first.getTime() === last.getTime()) {
return formatObservationDate(firstValue)
}
if (first.getUTCFullYear() !== last.getUTCFullYear()) {
return `${first.getUTCFullYear()}-${last.getUTCFullYear()}`
}
if (first.getUTCMonth() === last.getUTCMonth()) {
const monthAndYear = new Intl.DateTimeFormat('nl-BE', { year: 'numeric', month: 'short' }).format(last)
return `${first.getUTCDate()}-${last.getUTCDate()} ${monthAndYear}`
}
return `${formatObservationDate(firstValue)} - ${formatObservationDate(lastValue)}`
}
export function formatDatasetObservation(dataset: DatasetCreateResponse): string {
if (dataset.source_name === 'vmm_flood_hazard') {
return `scenario ${String(dataset.source_metadata?.['climate_context'] ?? '')} · ${String(dataset.source_metadata?.['probability_class'] ?? '')}`
}
if (dataset.source_name === 'vmm_vha_bathymetry_profiles') {
const firstMeasurement = String(dataset.source_metadata?.['measurement_date_min'] ?? '').slice(0, 4)
const lastMeasurement = String(dataset.source_metadata?.['measurement_date_max'] ?? '').slice(0, 4)
return firstMeasurement && lastMeasurement
? `historische profielen ${firstMeasurement}-${lastMeasurement}`
: 'historische profielmetingen'
}
if (dataset.source_name === 'spw_bathymetry') {
return `samengestelde waterbodemmeting ${String(dataset.source_metadata?.['survey_period'] ?? '2019-2022')} · mDNG`
}
if (dataset.source_name === 'department_omgeving_thematic_raster') {
const observationYear = Number(dataset.source_metadata?.['observation_year'])
if (Number.isFinite(observationYear)) {
return `referentiejaar ${observationYear}`
}
}
if (dataset.source_name === 'spw_walous_land_cover') {
const observationYear = Number(dataset.source_metadata?.['observation_year'])
return Number.isFinite(observationYear) ? `WALOUS referentiejaar ${observationYear}` : 'WALOUS landbedekking'
}
const period = dataset.source_metadata?.['acquisition_period']
if (typeof period === 'string' && period.trim()) {
return `opnameperiode ${period}`
}
return formatObservationDate(dataset.observed_at)
}
export function floodScenarioLabel(dataset: DatasetCreateResponse): string {
const configured = dataset.source_metadata?.['product_display_name']
return typeof configured === 'string' && configured.trim() ? configured : getDatasetDisplayName(dataset)
}
@@ -0,0 +1,199 @@
import { describe, expect, it } from 'vitest'
import {
bboxesEqual,
datasetIntersectsSelection,
deduplicateTemporalSnapshots,
isMunicipalityAreaName,
isSelectionBoundedDataset,
normalizeBboxFromCorners,
parseBboxInput,
persistedDatasetSupportsSelection,
productCoversZones,
resultMetricLabel,
selectedAreaCoverageZones,
selectionAnalysisScale,
selectionAreaSquareMetres,
selectionDimensions,
selectionFeatureLimit,
splitSelectionBbox,
temporalDatasetAreaMatch,
} from './mapWorkspaceUtils'
import type { VectorSelectionBBox, VectorSelectionResponse } from '../../types'
describe('map workspace selection guards', () => {
it('normalizes drag corners into an EPSG:4326 bbox', () => {
expect(normalizeBboxFromCorners([5.2, 51.3], [4.8, 50.9])).toEqual({
min_x: 4.8,
min_y: 50.9,
max_x: 5.2,
max_y: 51.3,
crs: 'EPSG:4326',
})
})
it('rejects empty, inverted and degenerate manual selections', () => {
expect(parseBboxInput({ min_x: '', min_y: '', max_x: '', max_y: '' })).toBeNull()
expect(parseBboxInput({ min_x: '5', min_y: '51', max_x: '4', max_y: '52' })).toBeNull()
expect(parseBboxInput({ min_x: '4', min_y: '51', max_x: '4', max_y: '52' })).toBeNull()
})
it('treats sub-nanodegree bbox drift as the same persisted selection', () => {
const bbox = normalizeBboxFromCorners([4.98, 51.15], [5.02, 51.19])
expect(bboxesEqual(bbox, { ...bbox, max_x: bbox.max_x + 1e-10 })).toBe(true)
expect(bboxesEqual(bbox, { ...bbox, max_x: bbox.max_x + 1e-5 })).toBe(false)
})
it('computes a positive bounded selection area and renders governed metrics', () => {
const bbox = normalizeBboxFromCorners([5.0, 51.0], [5.01, 51.01])
expect(selectionAreaSquareMetres(bbox)).toBeGreaterThan(700_000)
expect(selectionAreaSquareMetres(bbox)).toBeLessThan(900_000)
const result = {
feature_count: 12,
truncated: false,
summary: {
metric_key: 'forest_area',
metric_value: 14.236,
metric_unit: 'ha',
},
} as unknown as VectorSelectionResponse
expect(resultMetricLabel(result)).toBe('14,24 ha')
})
it('maps national work areas to regional provider zones without merging sources', () => {
expect(selectedAreaCoverageZones('Belgium land')).toEqual([
'belgium',
'flanders',
'wallonia',
'brussels',
])
expect(selectedAreaCoverageZones('RC Ardennes inland')).toEqual(['wallonia'])
expect(selectedAreaCoverageZones('Brussels-Capital Region')).toEqual(['brussels'])
expect(selectedAreaCoverageZones('Language boundary')).toEqual(['flanders', 'wallonia'])
expect(productCoversZones(['wallonia'], ['flanders', 'wallonia'])).toBe(true)
expect(productCoversZones(['brussels'], ['wallonia'])).toBe(false)
})
it('recognizes Dutch and release-golden municipality areas as local scope', () => {
expect(isMunicipalityAreaName('Gemeente Mol')).toBe(true)
expect(isMunicipalityAreaName('RC Golden - Mol municipality')).toBe(true)
expect(isMunicipalityAreaName('Flanders')).toBe(false)
expect(isMunicipalityAreaName('RC Golden - Kempen region')).toBe(false)
})
it('distinguishes selection-bounded acquisitions from reusable regional datasets', () => {
expect(isSelectionBoundedDataset({
geometry_clipped_to_selection: true,
bbox_epsg4326: [4.55, 50.58, 4.56, 50.59],
})).toBe(true)
expect(isSelectionBoundedDataset({
coverage_scope: 'bounded_selection',
bbox_epsg4326: [5.1, 51.17, 5.11, 51.18],
})).toBe(true)
expect(isSelectionBoundedDataset({
geometry_clipped_to_selection: false,
bbox_epsg4326: [2.5, 49.5, 6.4, 51.6],
})).toBe(false)
expect(isSelectionBoundedDataset({
geometry_clipped_to_selection: true,
})).toBe(false)
})
it('distinguishes exact, unscoped and foreign temporal work-area snapshots', () => {
expect(temporalDatasetAreaMatch({ area_id: 'wallonia-golden' }, 'wallonia-golden')).toBe('exact')
expect(temporalDatasetAreaMatch({ area_id: 'other-area' }, 'wallonia-golden')).toBe('other')
expect(temporalDatasetAreaMatch({ area_id: null }, 'wallonia-golden')).toBe('unscoped')
})
it('classifies local, regional and overview selections before provider calls', () => {
const bbox = (widthDegrees: number, heightDegrees: number): VectorSelectionBBox => ({
min_x: 5,
min_y: 51,
max_x: 5 + widthDegrees,
max_y: 51 + heightDegrees,
crs: 'EPSG:4326',
})
expect(selectionAnalysisScale(bbox(0.1, 0.1))).toBe('detail')
expect(selectionAnalysisScale(bbox(0.35, 0.25))).toBe('regional')
expect(selectionAnalysisScale(bbox(1, 1))).toBe('overview')
})
it('splits regional selections into provider-safe tiles without changing the outer bounds', () => {
const selection: VectorSelectionBBox = {
min_x: 5,
min_y: 51,
max_x: 5.5,
max_y: 51.35,
crs: 'EPSG:4326',
}
const tiles = splitSelectionBbox(selection)
expect(tiles.length).toBeGreaterThan(1)
expect(Math.min(...tiles.map((tile) => tile.min_x))).toBe(selection.min_x)
expect(Math.min(...tiles.map((tile) => tile.min_y))).toBe(selection.min_y)
expect(Math.max(...tiles.map((tile) => tile.max_x))).toBe(selection.max_x)
expect(Math.max(...tiles.map((tile) => tile.max_y))).toBe(selection.max_y)
for (const tile of tiles) {
const dimensions = selectionDimensions(tile)
expect(dimensions.widthMetres).toBeLessThanOrEqual(18_100)
expect(dimensions.heightMetres).toBeLessThanOrEqual(18_100)
}
})
it('refuses an unbounded detail fan-out', () => {
const selection: VectorSelectionBBox = {
min_x: 4,
min_y: 50,
max_x: 6,
max_y: 52,
crs: 'EPSG:4326',
}
expect(() => splitSelectionBbox(selection)).toThrow('detailpartities')
})
it('keeps persisted high-resolution rasters out of overview selections', () => {
const overview: VectorSelectionBBox = {
min_x: 2.5,
min_y: 49.5,
max_x: 6.4,
max_y: 51.5,
crs: 'EPSG:4326',
}
expect(persistedDatasetSupportsSelection(
{ dataset_type: 'raster', source_name: 'digitaal_vlaanderen_dhmv' },
overview,
)).toBe(false)
expect(persistedDatasetSupportsSelection(
{ dataset_type: 'vector', source_name: 'statbel' },
overview,
)).toBe(true)
expect(selectionFeatureLimit(overview)).toBe(25)
expect(selectionFeatureLimit({ ...overview, min_x: 5, max_x: 5.1, min_y: 51, max_y: 51.1 })).toBe(1000)
})
it('uses persisted raster partitions only where their recorded bounds overlap', () => {
const molPartition = { source_metadata: { bbox_epsg4326: [5.03, 51.15, 5.24, 51.32] } }
expect(datasetIntersectsSelection(molPartition, {
min_x: 5.08,
min_y: 51.17,
max_x: 5.12,
max_y: 51.2,
crs: 'EPSG:4326',
})).toBe(true)
expect(datasetIntersectsSelection(molPartition, {
min_x: 4.3,
min_y: 50.8,
max_x: 4.4,
max_y: 50.9,
crs: 'EPSG:4326',
})).toBe(false)
})
it('counts one canonical temporal snapshot per official observation date', () => {
const snapshots = deduplicateTemporalSnapshots([
{ id: 'old-2025', observed_at: '2025-12-31T23:59:59Z', imported_at: '2026-07-19T00:00:00Z' },
{ id: 'new-2025', observed_at: '2025-12-31T23:59:59Z', imported_at: '2026-07-21T00:00:00Z' },
{ id: 'year-2022', observed_at: '2022-12-31T23:59:59Z', imported_at: '2026-07-21T00:00:00Z' },
])
expect(snapshots.map((dataset) => dataset.id)).toEqual(['year-2022', 'new-2025'])
})
})
@@ -0,0 +1,438 @@
import { featureCollectionBounds } from '../../lib/geojsonBounds'
import type {
DatasetCreateResponse,
ProjectRead,
VectorSelectionBBox,
VectorSelectionMetric,
VectorSelectionResponse,
} from '../../types'
import {
FLANDERS_WORKSPACE_LABEL,
FLANDERS_WORKSPACE_PROJECT_NAME,
} from '../../config/primaryFocus'
const MOL_PROJECT_NAME = 'Mol Municipality Workbench'
const KEMPEN_PROJECT_NAME = 'Kempen Regional Workbench'
export function selectedAreaCoverageZones(areaName: string | null | undefined): string[] | null {
const normalized = String(areaName ?? '').toLowerCase()
if (!normalized) return null
if (normalized.includes('coast land-sea')) return ['flanders', 'belgian_north_sea']
if (normalized.includes('language boundary')) return ['flanders', 'wallonia']
if (normalized.includes('north sea')) {
return ['belgian_north_sea', 'territorial_sea', 'exclusive_economic_zone', 'continental_shelf']
}
if (normalized.includes('territorial sea')) return ['territorial_sea']
if (normalized.includes('exclusive economic zone')) return ['exclusive_economic_zone']
if (normalized.includes('continental shelf')) return ['continental_shelf']
if (normalized.includes('brussels')) return ['brussels']
if (normalized.includes('wallonia') || normalized.includes('ardennes')) return ['wallonia']
if (normalized.includes('flanders') || normalized.includes('mol') || normalized.includes('kempen')) return ['flanders']
if (normalized.includes('belgium land')) return ['belgium', 'flanders', 'wallonia', 'brussels']
return null
}
export function isMunicipalityAreaName(areaName: string | null | undefined): boolean {
const normalized = String(areaName ?? '').trim()
return /^Gemeente\s/i.test(normalized) || /\bmunicipality$/i.test(normalized)
}
export function productCoversZones(productZones: string[], selectedZones: string[] | null): boolean {
return selectedZones === null || selectedZones.some((zone) => productZones.includes(zone))
}
export function isSelectionBoundedDataset(
sourceMetadata: Record<string, unknown> | null | undefined,
): boolean {
const boundedScope = sourceMetadata?.['geometry_clipped_to_selection'] === true
|| sourceMetadata?.['coverage_scope'] === 'bounded_selection'
return boundedScope
&& Array.isArray(sourceMetadata?.['bbox_epsg4326'])
&& sourceMetadata['bbox_epsg4326'].length === 4
}
export type TemporalAreaMatch = 'exact' | 'unscoped' | 'other'
export function temporalDatasetAreaMatch(
dataset: Pick<DatasetCreateResponse, 'area_id'>,
selectedAreaId: string | null,
): TemporalAreaMatch {
if (!selectedAreaId || !dataset.area_id) return 'unscoped'
return dataset.area_id === selectedAreaId ? 'exact' : 'other'
}
export function operationalScopeProjectLabel(project: ProjectRead): string {
if (project.name === MOL_PROJECT_NAME) {
return 'Mol'
}
if (project.name === KEMPEN_PROJECT_NAME) {
return 'Kempen (28 gemeenten)'
}
if (project.name === FLANDERS_WORKSPACE_PROJECT_NAME) {
return FLANDERS_WORKSPACE_LABEL
}
return project.name
}
export function selectionAreaSquareMetres(bbox: VectorSelectionBBox | null): number | null {
if (!bbox) {
return null
}
const middleLatitudeRadians = ((bbox.min_y + bbox.max_y) / 2) * (Math.PI / 180)
const widthMetres = (bbox.max_x - bbox.min_x) * 111_320 * Math.cos(middleLatitudeRadians)
const heightMetres = (bbox.max_y - bbox.min_y) * 110_574
return Math.max(0, widthMetres * heightMetres)
}
export interface SelectionDimensions {
widthMetres: number
heightMetres: number
areaSquareMetres: number
}
export type SelectionAnalysisScale = 'detail' | 'regional' | 'overview'
export function selectionDimensions(bbox: VectorSelectionBBox): SelectionDimensions {
const middleLatitudeRadians = ((bbox.min_y + bbox.max_y) / 2) * (Math.PI / 180)
const widthMetres = Math.max(0, (bbox.max_x - bbox.min_x) * 111_320 * Math.cos(middleLatitudeRadians))
const heightMetres = Math.max(0, (bbox.max_y - bbox.min_y) * 110_574)
return {
widthMetres,
heightMetres,
areaSquareMetres: widthMetres * heightMetres,
}
}
export function selectionAnalysisScale(bbox: VectorSelectionBBox): SelectionAnalysisScale {
const { widthMetres, heightMetres } = selectionDimensions(bbox)
const longestSide = Math.max(widthMetres, heightMetres)
if (longestSide <= 20_000) return 'detail'
if (longestSide <= 50_000) return 'regional'
return 'overview'
}
export function persistedDatasetSupportsSelection(
dataset: { dataset_type: string; source_name?: string | null },
bbox: VectorSelectionBBox,
): boolean {
if (dataset.dataset_type !== 'raster') return true
const scale = selectionAnalysisScale(bbox)
if (scale === 'overview') return false
const dimensions = selectionDimensions(bbox)
if (dataset.source_name === 'digitaal_vlaanderen_dhmv' || dataset.source_name === 'spw_terrain' || dataset.source_name === 'vmm_flood_hazard') {
return dimensions.areaSquareMetres <= 280_000_000
}
if (dataset.source_name === 'department_omgeving_thematic_raster') {
return dimensions.areaSquareMetres <= 2_800_000_000
}
return scale === 'detail'
}
export function datasetIntersectsSelection(
dataset: { source_metadata?: Record<string, unknown> | null },
bbox: VectorSelectionBBox,
): boolean {
const bounds = dataset.source_metadata?.['bbox_epsg4326']
if (!Array.isArray(bounds) || bounds.length !== 4) {
return true
}
const [minX, minY, maxX, maxY] = bounds.map(Number)
if (![minX, minY, maxX, maxY].every(Number.isFinite)) {
return true
}
return !(
bbox.max_x < minX
|| bbox.min_x > maxX
|| bbox.max_y < minY
|| bbox.min_y > maxY
)
}
export function deduplicateTemporalSnapshots<
T extends {
id: string
observed_at?: string | null
imported_at?: string | null
created_at?: string | null
},
>(datasets: T[]): T[] {
const byObservation = new Map<string, T>()
for (const dataset of datasets) {
if (!dataset.observed_at) continue
const current = byObservation.get(dataset.observed_at)
const recency = new Date(dataset.imported_at ?? dataset.created_at ?? 0).getTime()
const currentRecency = new Date(current?.imported_at ?? current?.created_at ?? 0).getTime()
if (!current || recency > currentRecency || (recency === currentRecency && dataset.id > current.id)) {
byObservation.set(dataset.observed_at, dataset)
}
}
return Array.from(byObservation.values()).sort(
(left, right) => new Date(left.observed_at ?? 0).getTime() - new Date(right.observed_at ?? 0).getTime(),
)
}
export function selectionFeatureLimit(bbox: VectorSelectionBBox): number {
const scale = selectionAnalysisScale(bbox)
if (scale === 'overview') return 25
if (scale === 'regional') return 250
return 1000
}
export function splitSelectionBbox(
bbox: VectorSelectionBBox,
maxTileSideMetres = 18_000,
maxTiles = 16,
): VectorSelectionBBox[] {
const { widthMetres, heightMetres } = selectionDimensions(bbox)
const columns = Math.max(1, Math.ceil(widthMetres / maxTileSideMetres))
const rows = Math.max(1, Math.ceil(heightMetres / maxTileSideMetres))
if (columns * rows > maxTiles) {
throw new Error(
`De selectie vereist ${columns * rows} detailpartities; maximaal ${maxTiles} zijn toegestaan.`,
)
}
const longitudeStep = (bbox.max_x - bbox.min_x) / columns
const latitudeStep = (bbox.max_y - bbox.min_y) / rows
const tiles: VectorSelectionBBox[] = []
for (let row = 0; row < rows; row += 1) {
for (let column = 0; column < columns; column += 1) {
tiles.push({
min_x: bbox.min_x + longitudeStep * column,
min_y: bbox.min_y + latitudeStep * row,
max_x: column === columns - 1 ? bbox.max_x : bbox.min_x + longitudeStep * (column + 1),
max_y: row === rows - 1 ? bbox.max_y : bbox.min_y + latitudeStep * (row + 1),
crs: 'EPSG:4326',
})
}
}
return tiles
}
export function bboxesEqual(left: VectorSelectionBBox | null, right: VectorSelectionBBox | null): boolean {
if (!left || !right) {
return false
}
const tolerance = 1e-9
return (
Math.abs(left.min_x - right.min_x) < tolerance
&& Math.abs(left.min_y - right.min_y) < tolerance
&& Math.abs(left.max_x - right.max_x) < tolerance
&& Math.abs(left.max_y - right.max_y) < tolerance
)
}
export function formatArea(areaSquareMetres: number | null): string {
if (areaSquareMetres === null) {
return 'Nog niet geselecteerd'
}
if (areaSquareMetres >= 1_000_000) {
return `${(areaSquareMetres / 1_000_000).toLocaleString('nl-BE', { maximumFractionDigits: 2 })} km2`
}
return `${(areaSquareMetres / 10_000).toLocaleString('nl-BE', { maximumFractionDigits: 2 })} ha`
}
export function resultCountLabel(result: VectorSelectionResponse): string {
const total = result.total_feature_count ?? result.feature_count
return result.truncated && result.total_feature_count == null
? `${result.feature_count.toLocaleString('nl-BE')}+`
: total.toLocaleString('nl-BE')
}
export function resultMetricLabel(result: VectorSelectionResponse): string {
if (!result.summary) {
return resultCountLabel(result)
}
const maximumFractionDigits = result.summary.metric_unit === 'inwoners' ? 0 : 2
return `${result.summary.metric_value.toLocaleString('nl-BE', { maximumFractionDigits })} ${result.summary.metric_unit}`
}
export function selectionMetricLabel(metric: VectorSelectionMetric): string {
const maximumFractionDigits = metric.metric_unit === 'inwoners' || metric.metric_unit === 'objecten' ? 0 : 2
return `${metric.metric_value.toLocaleString('nl-BE', { maximumFractionDigits })} ${metric.metric_unit}`
}
export function formatTemporalMetric(value: number, unit: string): string {
const maximumFractionDigits = unit === 'inwoners' || unit === 'objecten' ? 0 : 2
return `${value.toLocaleString('nl-BE', { maximumFractionDigits })} ${unit}`
}
export function readablePropertyName(value: string): string {
return value.replace(/_/g, ' ').replace(/\b\w/g, (character) => character.toUpperCase())
}
function collectGeometryPoints(geometry: GeoJSON.Geometry | null | undefined): Array<[number, number]> {
const points: Array<[number, number]> = []
const walk = (coords: unknown) => {
if (!Array.isArray(coords)) {
return
}
if (coords.length >= 2 && typeof coords[0] === 'number' && typeof coords[1] === 'number') {
points.push([coords[0], coords[1]])
return
}
for (const item of coords) {
walk(item)
}
}
if ('coordinates' in (geometry ?? {})) {
walk((geometry as GeoJSON.Geometry & { coordinates: unknown }).coordinates)
}
return points
}
function formatCoordinate(value: number): string {
return Number.isFinite(value) ? value.toFixed(6) : 'n.v.t.'
}
export function getFeatureGeometrySummary(feature: GeoJSON.Feature | null) {
const points = collectGeometryPoints(feature?.geometry)
if (!feature?.geometry || points.length === 0) {
return {
bboxLabel: 'n.v.t.',
coordinateCount: 0,
geometryType: feature?.geometry?.type ?? 'geen',
}
}
const xs = points.map((point) => point[0])
const ys = points.map((point) => point[1])
const bboxLabel = `${formatCoordinate(Math.min(...xs))}, ${formatCoordinate(Math.min(...ys))} -> ${formatCoordinate(
Math.max(...xs),
)}, ${formatCoordinate(Math.max(...ys))}`
return {
bboxLabel,
coordinateCount: points.length,
geometryType: feature.geometry.type,
}
}
export function getFeatureCollectionBBox(collection: GeoJSON.FeatureCollection | null): VectorSelectionBBox | null {
const bounds = featureCollectionBounds(collection)
if (!bounds) {
return null
}
return {
min_x: bounds.minX,
min_y: bounds.minY,
max_x: bounds.maxX,
max_y: bounds.maxY,
crs: 'EPSG:4326',
}
}
export function getFeatureBBox(feature: GeoJSON.Feature | null): VectorSelectionBBox | null {
const points = collectGeometryPoints(feature?.geometry)
if (points.length === 0) {
return null
}
const xs = points.map((point) => point[0])
const ys = points.map((point) => point[1])
return {
min_x: Math.min(...xs),
min_y: Math.min(...ys),
max_x: Math.max(...xs),
max_y: Math.max(...ys),
crs: 'EPSG:4326',
}
}
export function normalizeBboxFromCorners(
first: [number, number],
second: [number, number],
): VectorSelectionBBox {
return {
min_x: Math.min(first[0], second[0]),
min_y: Math.min(first[1], second[1]),
max_x: Math.max(first[0], second[0]),
max_y: Math.max(first[1], second[1]),
crs: 'EPSG:4326',
}
}
export function formatBboxLabel(bbox: VectorSelectionBBox | null): string {
if (!bbox) {
return 'n.v.t.'
}
return `${formatCoordinate(bbox.min_x)}, ${formatCoordinate(bbox.min_y)} -> ${formatCoordinate(bbox.max_x)}, ${formatCoordinate(bbox.max_y)}`
}
export function formatPercentage(value: number | null | undefined): string {
return typeof value === 'number' && Number.isFinite(value)
? `${(value * 100).toLocaleString('nl-BE', { maximumFractionDigits: 1 })}%`
: 'n.v.t.'
}
export function bboxToInputState(bbox: VectorSelectionBBox | null) {
return {
min_x: bbox ? String(bbox.min_x) : '',
min_y: bbox ? String(bbox.min_y) : '',
max_x: bbox ? String(bbox.max_x) : '',
max_y: bbox ? String(bbox.max_y) : '',
}
}
export function parseBboxInput(input: ReturnType<typeof bboxToInputState>): VectorSelectionBBox | null {
const min_x = Number(input.min_x)
const min_y = Number(input.min_y)
const max_x = Number(input.max_x)
const max_y = Number(input.max_y)
if (![min_x, min_y, max_x, max_y].every(Number.isFinite) || min_x >= max_x || min_y >= max_y) {
return null
}
return { min_x, min_y, max_x, max_y, crs: 'EPSG:4326' }
}
export function selectedFeatureCollection(feature: GeoJSON.Feature): GeoJSON.FeatureCollection {
return {
type: 'FeatureCollection',
features: [feature],
}
}
export function safeFileStem(value: unknown): string {
const stem = String(value ?? 'selected-feature')
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/^-+|-+$/g, '')
return stem || 'selected-feature'
}
function fallbackCopyText(text: string): void {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', 'true')
textarea.style.position = 'fixed'
textarea.style.left = '-9999px'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
export function copyText(text: string): void {
if (navigator.clipboard?.writeText) {
void navigator.clipboard.writeText(text).catch(() => fallbackCopyText(text))
return
}
fallbackCopyText(text)
}
export function downloadJsonFile(
filename: string,
payload: unknown,
contentType = 'application/json',
): void {
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: contentType })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
File diff suppressed because it is too large. Load diff