Complete regional raster exploration
This commit is contained in:
@@ -158,14 +158,15 @@ const DATA_THEME_MAP_STYLES: Record<DataThemeId, { fill: string; line: string }>
|
||||
parcels: { fill: '#a7792f', line: '#7d571f' },
|
||||
}
|
||||
|
||||
function datasetAvailabilityLabel(dataset: DatasetCreateResponse): string {
|
||||
function datasetAvailabilityLabel(dataset: DatasetCreateResponse, partitionCount = 1): string {
|
||||
const regionalSuffix = partitionCount > 1 ? ` · ${partitionCount} gemeenten` : ''
|
||||
if (dataset.dataset_type === 'raster' && dataset.source_name === 'digitaal_vlaanderen_dhmv') {
|
||||
const resolution = Number(dataset.source_metadata?.['analysis_resolution_m'])
|
||||
return `${Number.isFinite(resolution) ? `${resolution.toLocaleString('nl-BE')} m` : 'Raster'} hoogtegrid beschikbaar`
|
||||
return `${Number.isFinite(resolution) ? `${resolution.toLocaleString('nl-BE')} m` : 'Raster'} hoogtegrid${regionalSuffix}`
|
||||
}
|
||||
if (dataset.dataset_type === 'raster' && dataset.source_name === 'vmm_flood_hazard') {
|
||||
const resolution = Number(dataset.source_metadata?.['analysis_resolution_m'])
|
||||
return `${Number.isFinite(resolution) ? `${resolution.toLocaleString('nl-BE')} m` : 'Raster'} overstromingsscenario`
|
||||
return `${Number.isFinite(resolution) ? `${resolution.toLocaleString('nl-BE')} m` : 'Raster'} overstromingsscenario${regionalSuffix}`
|
||||
}
|
||||
if (dataset.dataset_type === 'raster' && dataset.source_name === 'department_omgeving_thematic_raster') {
|
||||
const resolution = Number(dataset.source_metadata?.['analysis_resolution_m'])
|
||||
@@ -213,21 +214,69 @@ function datasetMatchesTheme(dataset: DatasetCreateResponse, theme: DataTheme):
|
||||
return theme.tokens.some((token) => searchText.includes(token))
|
||||
}
|
||||
|
||||
function datasetCoversSelectedArea(dataset: DatasetCreateResponse, selectedAreaId: string | null): boolean {
|
||||
function isMunicipalityAreaName(name: string | null | undefined): boolean {
|
||||
return /^Gemeente\s/i.test(name ?? '')
|
||||
}
|
||||
|
||||
function isPartitionedRaster(dataset: DatasetCreateResponse | null | undefined): boolean {
|
||||
return Boolean(
|
||||
dataset?.dataset_type === 'raster'
|
||||
&& ['digitaal_vlaanderen_dhmv', 'vmm_flood_hazard'].includes(dataset.source_name ?? ''),
|
||||
)
|
||||
}
|
||||
|
||||
function datasetProductKey(dataset: DatasetCreateResponse): string {
|
||||
return String(dataset.source_metadata?.['product_key'] ?? '')
|
||||
}
|
||||
|
||||
function datasetCoversSelectedArea(
|
||||
dataset: DatasetCreateResponse,
|
||||
selectedAreaId: string | null,
|
||||
regionalScope = false,
|
||||
): boolean {
|
||||
const coverageScope = String(dataset.source_metadata?.['coverage_scope'] ?? '')
|
||||
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,
|
||||
regionalScope: boolean,
|
||||
): DatasetCreateResponse[] {
|
||||
if (!representative) {
|
||||
return []
|
||||
}
|
||||
if (!regionalScope || !isPartitionedRaster(representative)) {
|
||||
return [representative]
|
||||
}
|
||||
const productKey = datasetProductKey(representative)
|
||||
return datasets
|
||||
.filter(
|
||||
(dataset) =>
|
||||
dataset.source_name === representative.source_name
|
||||
&& datasetProductKey(dataset) === productKey
|
||||
&& datasetCoversSelectedArea(dataset, selectedAreaId, true),
|
||||
)
|
||||
.sort((left, right) => String(left.area_id ?? '').localeCompare(String(right.area_id ?? '')))
|
||||
}
|
||||
|
||||
function pickThemeDataset(
|
||||
datasets: DatasetCreateResponse[],
|
||||
theme: DataTheme,
|
||||
selectedAreaId: string | null,
|
||||
regionalScope = false,
|
||||
): DatasetCreateResponse | null {
|
||||
const candidates = datasets.filter(
|
||||
(dataset) => datasetMatchesTheme(dataset, theme) && datasetCoversSelectedArea(dataset, selectedAreaId),
|
||||
(dataset) =>
|
||||
datasetMatchesTheme(dataset, theme)
|
||||
&& datasetCoversSelectedArea(dataset, selectedAreaId, regionalScope),
|
||||
)
|
||||
candidates.sort((left, right) => {
|
||||
const score = (dataset: DatasetCreateResponse) =>
|
||||
@@ -745,6 +794,7 @@ export function MapWorkspace({
|
||||
const [fullWorkflowError, setFullWorkflowError] = useState<string | null>(null)
|
||||
const [fullWorkflowMode, setFullWorkflowMode] = useState<'new' | 'reuse'>('new')
|
||||
const selectedMapArea = areas.find((area) => area.id === selectedMapAreaId)
|
||||
const regionalScopeSelected = Boolean(selectedMapArea && !isMunicipalityAreaName(selectedMapArea.name))
|
||||
const featureProperties = selectedMapFeature?.properties ?? null
|
||||
const featureSummaryEntries = featureProperties
|
||||
? Object.entries(featureProperties)
|
||||
@@ -767,70 +817,138 @@ export function MapWorkspace({
|
||||
const selectedMapDataset = availableMapDatasets.find((dataset) => dataset.id === selectedMapDatasetId) ?? null
|
||||
const usesDefaultOsmBasemap = !import.meta.env.VITE_MAP_STYLE_URL
|
||||
const floodHazardDatasets = useMemo(
|
||||
() => availableMapDatasets
|
||||
.filter((dataset) => dataset.source_name === 'vmm_flood_hazard' && datasetCoversSelectedArea(dataset, selectedMapAreaId))
|
||||
.sort((left, right) => floodScenarioLabel(left).localeCompare(floodScenarioLabel(right), 'nl')),
|
||||
[availableMapDatasets, selectedMapAreaId],
|
||||
() => {
|
||||
const scoped = availableMapDatasets
|
||||
.filter(
|
||||
(dataset) =>
|
||||
dataset.source_name === 'vmm_flood_hazard'
|
||||
&& datasetCoversSelectedArea(dataset, selectedMapAreaId, regionalScopeSelected),
|
||||
)
|
||||
.sort((left, right) => floodScenarioLabel(left).localeCompare(floodScenarioLabel(right), 'nl'))
|
||||
if (!regionalScopeSelected) {
|
||||
return scoped
|
||||
}
|
||||
const products = new Map<string, DatasetCreateResponse>()
|
||||
for (const dataset of scoped) {
|
||||
const key = datasetProductKey(dataset)
|
||||
if (key && !products.has(key)) {
|
||||
products.set(key, dataset)
|
||||
}
|
||||
}
|
||||
return Array.from(products.values())
|
||||
},
|
||||
[availableMapDatasets, regionalScopeSelected, selectedMapAreaId],
|
||||
)
|
||||
const themeDatasetMap = useMemo(() => {
|
||||
const result = Object.fromEntries(
|
||||
DATA_THEMES.map((theme) => [theme.id, pickThemeDataset(availableMapDatasets, theme, selectedMapAreaId)]),
|
||||
DATA_THEMES.map((theme) => [
|
||||
theme.id,
|
||||
pickThemeDataset(availableMapDatasets, theme, selectedMapAreaId, regionalScopeSelected),
|
||||
]),
|
||||
) as Record<DataThemeId, DatasetCreateResponse | null>
|
||||
const selectedFloodHazard = floodHazardDatasets.find((dataset) => dataset.id === selectedFloodHazardDatasetId)
|
||||
if (selectedFloodHazard) {
|
||||
result.flood_hazard = selectedFloodHazard
|
||||
}
|
||||
return result
|
||||
}, [availableMapDatasets, floodHazardDatasets, selectedFloodHazardDatasetId, selectedMapAreaId])
|
||||
}, [availableMapDatasets, floodHazardDatasets, regionalScopeSelected, selectedFloodHazardDatasetId, selectedMapAreaId])
|
||||
const themePartitionMap = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
DATA_THEMES.map((theme) => [
|
||||
theme.id,
|
||||
rasterPartitionsForDataset(
|
||||
availableMapDatasets,
|
||||
themeDatasetMap[theme.id],
|
||||
selectedMapAreaId,
|
||||
regionalScopeSelected,
|
||||
),
|
||||
]),
|
||||
) as Record<DataThemeId, DatasetCreateResponse[]>,
|
||||
[availableMapDatasets, regionalScopeSelected, selectedMapAreaId, themeDatasetMap],
|
||||
)
|
||||
const activeTheme = DATA_THEMES.find((theme) => theme.id === activeThemeId) ?? DATA_THEMES[0]
|
||||
const activeThemeMapStyle = DATA_THEME_MAP_STYLES[activeTheme.id]
|
||||
const analysisOverlayActive = mapContentMode === 'analysis' && analysisLayerAvailable && Boolean(mapFeatureCollection)
|
||||
const selectedOrthophotoProduct = orthophotoProducts.find((item) => item.key === selectedOrthophotoProductKey) ?? null
|
||||
const orthophotoImageOverlay = orthophotoResult && orthophotoImageUrl && orthophotoResult.bbox_epsg4326.length === 4
|
||||
? {
|
||||
url: orthophotoImageUrl,
|
||||
bbox: orthophotoResult.bbox_epsg4326 as [number, number, number, number],
|
||||
label: orthophotoResult.display_name,
|
||||
opacity: 0.9,
|
||||
}
|
||||
: null
|
||||
const orthophotoImageOverlay = useMemo(
|
||||
() => orthophotoResult && orthophotoImageUrl && orthophotoResult.bbox_epsg4326.length === 4
|
||||
? {
|
||||
url: orthophotoImageUrl,
|
||||
bbox: orthophotoResult.bbox_epsg4326 as [number, number, number, number],
|
||||
label: orthophotoResult.display_name,
|
||||
opacity: 0.9,
|
||||
}
|
||||
: null,
|
||||
[orthophotoImageUrl, orthophotoResult],
|
||||
)
|
||||
const activeThemeDataset = themeDatasetMap[activeTheme.id]
|
||||
const terrainBounds = activeThemeDataset?.source_name === 'digitaal_vlaanderen_dhmv'
|
||||
? activeThemeDataset.source_metadata?.['bbox_epsg4326']
|
||||
: null
|
||||
const terrainImageOverlay = activeTheme.id === 'elevation' && activeThemeDataset && selectedProjectId && Array.isArray(terrainBounds) && terrainBounds.length === 4
|
||||
? {
|
||||
url: terrainImageUrl(selectedProjectId, activeThemeDataset.id),
|
||||
bbox: terrainBounds.map(Number) as [number, number, number, number],
|
||||
label: getDatasetDisplayName(activeThemeDataset),
|
||||
opacity: 0.82,
|
||||
}
|
||||
: null
|
||||
const floodHazardBounds = activeThemeDataset?.source_name === 'vmm_flood_hazard'
|
||||
? activeThemeDataset.source_metadata?.['bbox_epsg4326']
|
||||
: null
|
||||
const floodHazardImageOverlay = activeTheme.id === 'flood_hazard' && activeThemeDataset && selectedProjectId && Array.isArray(floodHazardBounds) && floodHazardBounds.length === 4
|
||||
? {
|
||||
url: floodHazardImageUrl(selectedProjectId, activeThemeDataset.id),
|
||||
bbox: floodHazardBounds.map(Number) as [number, number, number, number],
|
||||
label: floodScenarioLabel(activeThemeDataset),
|
||||
opacity: 0.82,
|
||||
}
|
||||
: null
|
||||
const activeThemePartitions = themePartitionMap[activeTheme.id]
|
||||
const regionalRasterThemeActive = regionalScopeSelected && isPartitionedRaster(activeThemeDataset)
|
||||
const terrainImageOverlays = useMemo(
|
||||
() =>
|
||||
activeTheme.id === 'elevation' && selectedProjectId
|
||||
? activeThemePartitions.flatMap((dataset) => {
|
||||
const bounds = dataset.source_metadata?.['bbox_epsg4326']
|
||||
return dataset.source_name === 'digitaal_vlaanderen_dhmv'
|
||||
&& Array.isArray(bounds)
|
||||
&& bounds.length === 4
|
||||
? [{
|
||||
url: terrainImageUrl(selectedProjectId, dataset.id),
|
||||
bbox: bounds.map(Number) as [number, number, number, number],
|
||||
label: getDatasetDisplayName(dataset),
|
||||
opacity: 0.82,
|
||||
}]
|
||||
: []
|
||||
})
|
||||
: [],
|
||||
[activeTheme.id, activeThemePartitions, selectedProjectId],
|
||||
)
|
||||
const floodHazardImageOverlays = useMemo(
|
||||
() =>
|
||||
activeTheme.id === 'flood_hazard' && selectedProjectId
|
||||
? activeThemePartitions.flatMap((dataset) => {
|
||||
const bounds = dataset.source_metadata?.['bbox_epsg4326']
|
||||
return dataset.source_name === 'vmm_flood_hazard'
|
||||
&& Array.isArray(bounds)
|
||||
&& bounds.length === 4
|
||||
? [{
|
||||
url: floodHazardImageUrl(selectedProjectId, dataset.id),
|
||||
bbox: bounds.map(Number) as [number, number, number, number],
|
||||
label: floodScenarioLabel(dataset),
|
||||
opacity: 0.82,
|
||||
}]
|
||||
: []
|
||||
})
|
||||
: [],
|
||||
[activeTheme.id, activeThemePartitions, selectedProjectId],
|
||||
)
|
||||
const thematicRasterBounds = activeThemeDataset?.source_name === 'department_omgeving_thematic_raster'
|
||||
? activeThemeDataset.source_metadata?.['bbox_epsg4326']
|
||||
: null
|
||||
const thematicRasterImageOverlay = activeThemeDataset?.source_name === 'department_omgeving_thematic_raster' && selectedProjectId && Array.isArray(thematicRasterBounds) && thematicRasterBounds.length === 4
|
||||
? {
|
||||
url: thematicRasterImageUrl(selectedProjectId, activeThemeDataset.id),
|
||||
bbox: thematicRasterBounds.map(Number) as [number, number, number, number],
|
||||
label: getDatasetDisplayName(activeThemeDataset),
|
||||
opacity: 0.78,
|
||||
}
|
||||
: null
|
||||
const thematicRasterImageOverlays = useMemo(
|
||||
() => activeThemeDataset?.source_name === 'department_omgeving_thematic_raster' && selectedProjectId && Array.isArray(thematicRasterBounds) && thematicRasterBounds.length === 4
|
||||
? [{
|
||||
url: thematicRasterImageUrl(selectedProjectId, activeThemeDataset.id),
|
||||
bbox: thematicRasterBounds.map(Number) as [number, number, number, number],
|
||||
label: getDatasetDisplayName(activeThemeDataset),
|
||||
opacity: 0.78,
|
||||
}]
|
||||
: [],
|
||||
[activeThemeDataset, selectedProjectId, thematicRasterBounds],
|
||||
)
|
||||
const thematicLegendMin = String(activeThemeDataset?.source_metadata?.['legend_min_label'] ?? 'Lagere waarde')
|
||||
const thematicLegendMax = String(activeThemeDataset?.source_metadata?.['legend_max_label'] ?? 'Hogere waarde')
|
||||
const activeImageOverlay = thematicRasterImageOverlay ?? floodHazardImageOverlay ?? terrainImageOverlay ?? orthophotoImageOverlay
|
||||
const activeImageOverlays = useMemo(
|
||||
() => thematicRasterImageOverlays.length > 0
|
||||
? thematicRasterImageOverlays
|
||||
: floodHazardImageOverlays.length > 0
|
||||
? floodHazardImageOverlays
|
||||
: terrainImageOverlays.length > 0
|
||||
? terrainImageOverlays
|
||||
: orthophotoImageOverlay ? [orthophotoImageOverlay] : [],
|
||||
[floodHazardImageOverlays, orthophotoImageOverlay, terrainImageOverlays, thematicRasterImageOverlays],
|
||||
)
|
||||
const activeScopeProject = projects.find((project) => project.id === selectedProjectId) ?? null
|
||||
const activeScopeLabel = activeScopeProject ? operationalScopeProjectLabel(activeScopeProject) : 'Werkgebied'
|
||||
const municipalityAreaCount = areas.filter((area) => /^Gemeente\s/i.test(area.name)).length
|
||||
@@ -865,7 +983,7 @@ export function MapWorkspace({
|
||||
[themeInsights],
|
||||
)
|
||||
const activeSelectionResult = themeResults.find((item) => item.theme.id === activeThemeId)?.result
|
||||
?? (selectedMapDataset?.id === activeThemeDataset?.id ? mapSelectionResult : null)
|
||||
?? (!regionalRasterThemeActive && selectedMapDataset?.id === activeThemeDataset?.id ? mapSelectionResult : null)
|
||||
const selectedAreaSquareMetres = useMemo(
|
||||
() =>
|
||||
bboxesEqual(mapSelectionBbox, selectedAreaBbox) && selectedMapArea?.area_m2
|
||||
@@ -1106,14 +1224,23 @@ export function MapWorkspace({
|
||||
const loadAllThemeResults = async (bbox: VectorSelectionBBox, areaId?: string) => {
|
||||
const availableThemes = DATA_THEMES.flatMap((theme) => {
|
||||
const dataset = themeDatasetMap[theme.id]
|
||||
return dataset ? [{ themeId: theme.id, dataset }] : []
|
||||
return dataset
|
||||
? [{
|
||||
themeId: theme.id,
|
||||
dataset,
|
||||
partitioned: regionalScopeSelected && isPartitionedRaster(dataset),
|
||||
}]
|
||||
: []
|
||||
})
|
||||
await loadThemeInsights(bbox, availableThemes, areaId)
|
||||
}
|
||||
|
||||
const analyzeSelection = async (bbox: VectorSelectionBBox, areaId?: string) => {
|
||||
setSelectionBbox(bbox)
|
||||
const tasks: Array<Promise<unknown>> = [onRunMapSelectionExtract(bbox, areaId), loadAllThemeResults(bbox, areaId)]
|
||||
const tasks: Array<Promise<unknown>> = [loadAllThemeResults(bbox, areaId)]
|
||||
if (!regionalRasterThemeActive) {
|
||||
tasks.push(onRunMapSelectionExtract(bbox, areaId))
|
||||
}
|
||||
if (analysisMode === 'evolution' && earlierDatasetId && laterDatasetId) {
|
||||
tasks.push(compareTemporalSnapshots(earlierDatasetId, laterDatasetId, bbox, areaId))
|
||||
}
|
||||
@@ -1267,6 +1394,7 @@ export function MapWorkspace({
|
||||
<div className="geo-theme-list">
|
||||
{DATA_THEMES.map((theme) => {
|
||||
const dataset = themeDatasetMap[theme.id]
|
||||
const partitionCount = themePartitionMap[theme.id].length
|
||||
const temporalGroups = themeTemporalSeriesMap[theme.id]
|
||||
const temporalGroup = temporalGroups[0]
|
||||
const evolutionAvailable = temporalGroups.some((group) => group.items.length >= 2)
|
||||
@@ -1296,7 +1424,7 @@ export function MapWorkspace({
|
||||
? 'Alleen huidige toestand'
|
||||
: 'Bron nog niet ingeladen'
|
||||
: dataset
|
||||
? datasetAvailabilityLabel(dataset)
|
||||
? datasetAvailabilityLabel(dataset, partitionCount)
|
||||
: 'Bron nog niet ingeladen'}
|
||||
</small>
|
||||
</span>
|
||||
@@ -1416,7 +1544,13 @@ export function MapWorkspace({
|
||||
<span>2</span>
|
||||
<div>
|
||||
<h3>Selecteer een gebied</h3>
|
||||
<p>{bboxSelectionMode ? 'Sleep nu een rechthoek op de kaart.' : 'Sleep een rechthoek of analyseer het volledige werkgebied.'}</p>
|
||||
<p>
|
||||
{bboxSelectionMode
|
||||
? 'Sleep nu een rechthoek op de kaart.'
|
||||
: regionalRasterThemeActive
|
||||
? 'Teken een rechthoek; de juiste gemeentelijke rasters worden automatisch gecombineerd.'
|
||||
: 'Sleep een rechthoek of analyseer het volledige werkgebied.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="geo-map-actions">
|
||||
@@ -1430,11 +1564,12 @@ export function MapWorkspace({
|
||||
</button>
|
||||
<button
|
||||
className="secondary-action"
|
||||
disabled={!activeThemeDataset || (analysisMode === 'evolution' && activeTemporalSeries.length < 2) || !selectedAreaBbox || mapSelectionLoading || themeResultsLoading}
|
||||
disabled={!activeThemeDataset || regionalRasterThemeActive || (analysisMode === 'evolution' && activeTemporalSeries.length < 2) || !selectedAreaBbox || mapSelectionLoading || themeResultsLoading}
|
||||
type="button"
|
||||
title={regionalRasterThemeActive ? 'Teken een begrensde rechthoek voor een regionale rasteranalyse.' : undefined}
|
||||
onClick={() => selectedAreaBbox && void analyzeSelection(selectedAreaBbox, selectedMapArea?.id)}
|
||||
>
|
||||
Volledig werkgebied
|
||||
{regionalRasterThemeActive ? 'Selecteer een deelgebied' : 'Volledig werkgebied'}
|
||||
</button>
|
||||
<button className="secondary-action" disabled={!mapSelectionBbox} type="button" onClick={clearAreaSelection}>
|
||||
Wis selectie
|
||||
@@ -1450,7 +1585,7 @@ export function MapWorkspace({
|
||||
areaData={areaFeatureCollection}
|
||||
selectedFeature={selectedFeature}
|
||||
selectionData={analysisMode === 'current' ? mapSelectionResult?.geojson ?? null : null}
|
||||
imageOverlay={activeImageOverlay}
|
||||
imageOverlays={activeImageOverlays}
|
||||
selectionBbox={mapSelectionBbox}
|
||||
bboxSelectionMode={bboxSelectionMode}
|
||||
visible={mapLayerVisible}
|
||||
@@ -1466,12 +1601,17 @@ export function MapWorkspace({
|
||||
/>
|
||||
<div className="geo-map-legend" aria-label="Kaartlegende">
|
||||
<span><i className="geo-legend-area" /> Werkgebied</span>
|
||||
{thematicRasterImageOverlay ? (
|
||||
{thematicRasterImageOverlays.length > 0 ? (
|
||||
<span className="geo-legend-thematic">
|
||||
<i className={`geo-legend-ramp geo-legend-ramp-${activeTheme.id}`} />
|
||||
<small>{thematicLegendMin} → {thematicLegendMax}</small>
|
||||
</span>
|
||||
) : activeImageOverlay ? <span><i className="geo-legend-imagery" /> {activeImageOverlay.label}</span> : null}
|
||||
) : activeImageOverlays.length > 0 ? (
|
||||
<span>
|
||||
<i className="geo-legend-imagery" /> {activeImageOverlays[0].label}
|
||||
{activeImageOverlays.length > 1 ? ` · ${activeImageOverlays.length} gemeenten` : ''}
|
||||
</span>
|
||||
) : null}
|
||||
{analysisOverlayActive ? (
|
||||
<>
|
||||
<span><i className="geo-legend-layer geo-legend-layer-buildings" /> AI-kandidaten</span>
|
||||
|
||||
Reference in New Issue
Block a user