extract the map workspace's domain layer out of the component
MapWorkspace.tsx opened with ~590 lines of theme catalogue, dataset matching and label formatting above a 3.200-line component. None of it is React, all of it is independently testable, and both render paths read from it, so it belongs beside the pure helpers that already live in mapWorkspaceUtils. The contract tests that read MapWorkspace.tsx would have gone red for a move that changes no behaviour at all — 24 of them. That is the brittleness the frontend_contract helper exists to remove, so it gains read_map_workspace(): the workspace is one feature spread over several modules, and a contract belongs to the feature rather than to whichever file currently holds it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -25,7 +25,6 @@ import {
|
||||
bboxesEqual,
|
||||
copyText,
|
||||
datasetIntersectsSelection,
|
||||
deduplicateTemporalSnapshots,
|
||||
downloadJsonFile,
|
||||
formatArea,
|
||||
formatBboxLabel,
|
||||
@@ -35,14 +34,12 @@ import {
|
||||
getFeatureCollectionBBox,
|
||||
getFeatureGeometrySummary,
|
||||
isMunicipalityAreaName,
|
||||
isSelectionBoundedDataset,
|
||||
normalizeBboxFromCorners,
|
||||
operationalScopeProjectLabel,
|
||||
parseBboxInput,
|
||||
persistedDatasetSupportsSelection,
|
||||
productCoversZones,
|
||||
readablePropertyName,
|
||||
resultCountLabel,
|
||||
resultMetricLabel,
|
||||
safeFileStem,
|
||||
selectedAreaCoverageZones,
|
||||
@@ -53,596 +50,34 @@ import {
|
||||
selectionFeatureLimit,
|
||||
selectionMetricLabel,
|
||||
splitSelectionBbox,
|
||||
temporalDatasetAreaMatch,
|
||||
} from './mapWorkspaceUtils'
|
||||
|
||||
const DEFAULT_SELECTED_FEATURE_FILENAME = 'selected-feature.geojson'
|
||||
const DEFAULT_AREA_SELECTION_FILENAME = 'area-selection.geojson'
|
||||
const EMPTY_TEMPORAL_SERIES: DatasetCreateResponse[] = []
|
||||
|
||||
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'
|
||||
|
||||
interface DataTheme {
|
||||
id: DataThemeId
|
||||
label: string
|
||||
shortLabel: string
|
||||
description: string
|
||||
tokens: string[]
|
||||
}
|
||||
|
||||
interface TemporalSeriesGroup {
|
||||
key: string
|
||||
label: string
|
||||
items: DatasetCreateResponse[]
|
||||
}
|
||||
|
||||
interface OnDemandMapProduct extends MapThemeAcquisition {
|
||||
theme: DataThemeId
|
||||
availabilityLabel: string
|
||||
attribution: string
|
||||
limitationMessage: string
|
||||
coverageZones: string[]
|
||||
}
|
||||
|
||||
interface PlannedOnDemandMapProduct extends OnDemandMapProduct {
|
||||
acquisitionBboxes: VectorSelectionBBox[]
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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'],
|
||||
},
|
||||
]
|
||||
|
||||
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',
|
||||
}
|
||||
|
||||
function coverageStatusLabel(status: CoverageStatus): string {
|
||||
const labels: Record<CoverageStatus, string> = {
|
||||
operational: 'Beschikbaar',
|
||||
partial: 'Gedeeltelijk',
|
||||
not_configured: 'Niet gekoppeld',
|
||||
unsupported: 'Niet ondersteund',
|
||||
}
|
||||
return labels[status]
|
||||
}
|
||||
|
||||
function coverageZoneLabel(zone: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
belgium: 'Belgie',
|
||||
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
|
||||
}
|
||||
|
||||
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' },
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
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 ?? ''),
|
||||
)
|
||||
}
|
||||
|
||||
function isPartitionedBathymetry(dataset: DatasetCreateResponse | null | undefined): boolean {
|
||||
return Boolean(
|
||||
dataset?.source_name === 'vmm_vha_bathymetry_profiles'
|
||||
&& dataset.source_metadata?.['regional_partitions_complete'] === true,
|
||||
)
|
||||
}
|
||||
|
||||
function datasetProductKey(dataset: DatasetCreateResponse): string {
|
||||
return String(dataset.source_metadata?.['product_key'] ?? '')
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 ?? '')))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function themeIdForDataset(dataset: DatasetCreateResponse | null): DataThemeId | null {
|
||||
return dataset
|
||||
? DATA_THEMES.find((theme) => datasetMatchesTheme(dataset, theme))?.id ?? null
|
||||
: null
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
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)}`
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
function floodScenarioLabel(dataset: DatasetCreateResponse): string {
|
||||
const configured = dataset.source_metadata?.['product_display_name']
|
||||
return typeof configured === 'string' && configured.trim() ? configured : getDatasetDisplayName(dataset)
|
||||
}
|
||||
import {
|
||||
COVERAGE_THEME_BY_MAP_THEME,
|
||||
DATA_THEMES,
|
||||
DATA_THEME_MAP_STYLES,
|
||||
DEFAULT_AREA_SELECTION_FILENAME,
|
||||
DEFAULT_SELECTED_FEATURE_FILENAME,
|
||||
EMPTY_TEMPORAL_SERIES,
|
||||
coverageStatusLabel,
|
||||
coverageZoneLabel,
|
||||
datasetCoversSelectedArea,
|
||||
datasetProductKey,
|
||||
floodScenarioLabel,
|
||||
formatDatasetObservation,
|
||||
formatObservationDate,
|
||||
isPartitionedBathymetry,
|
||||
isPartitionedRaster,
|
||||
listThemeTemporalSeries,
|
||||
pickThemeDataset,
|
||||
productSupportsSelection,
|
||||
rasterPartitionsForDataset,
|
||||
themeIdForDataset,
|
||||
type DataTheme,
|
||||
type DataThemeId,
|
||||
type OnDemandMapProduct,
|
||||
type PlannedOnDemandMapProduct,
|
||||
type TemporalSeriesGroup,
|
||||
} from './mapWorkspaceThemes'
|
||||
|
||||
interface MapWorkspaceProps {
|
||||
readOnly?: boolean
|
||||
|
||||
@@ -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: 'Belgie',
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user