feat: map the complete municipality of Mol
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-14 05:35:15 +02:00
parent f757a527cf
commit 490325e0cd
20 changed files with 1027 additions and 62 deletions
+38 -46
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import maplibregl from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import { PRIMARY_FOCUS_CENTER } from '../config/primaryFocus'
import { featureCollectionBounds } from '../lib/geojsonBounds'
interface GeoMapProps {
data: GeoJSON.FeatureCollection | null
@@ -49,36 +50,13 @@ function defaultMapStyle(): string | maplibregl.StyleSpecification {
}
function collectCoordinates(featureCollection: GeoJSON.FeatureCollection): maplibregl.LngLatBoundsLike | null {
const coordinates: [number, number][] = []
const walk = (coords: unknown) => {
if (!Array.isArray(coords)) {
return
}
if (coords.length === 2 && typeof coords[0] === 'number' && typeof coords[1] === 'number') {
coordinates.push([coords[0], coords[1]])
return
}
for (const item of coords) {
walk(item)
}
}
for (const feature of featureCollection.features) {
const geometry = feature.geometry as any
if (geometry && geometry.coordinates) {
walk(geometry.coordinates)
}
}
if (coordinates.length === 0) {
const bounds = featureCollectionBounds(featureCollection)
if (!bounds) {
return null
}
const xs = coordinates.map((point) => point[0])
const ys = coordinates.map((point) => point[1])
return [
[Math.min(...xs), Math.min(...ys)],
[Math.max(...xs), Math.max(...ys)],
[bounds.minX, bounds.minY],
[bounds.maxX, bounds.maxY],
]
}
@@ -244,15 +222,22 @@ function GeoMap({
source: 'dataset',
paint: {
'fill-color': [
'match',
['get', 'change_type'],
'added',
'#16a34a',
'removed',
'#dc2626',
'unchanged',
'#2563eb',
'#f97316',
'case',
['==', ['get', 'layer_type'], 'municipality_boundary'],
'#0f766e',
['==', ['get', 'layer_type'], 'building'],
'#0891b2',
[
'match',
['get', 'change_type'],
'added',
'#16a34a',
'removed',
'#dc2626',
'unchanged',
'#2563eb',
'#f97316',
],
],
'fill-opacity': 0.4,
},
@@ -263,17 +248,24 @@ function GeoMap({
source: 'dataset',
paint: {
'line-color': [
'match',
['get', 'change_type'],
'added',
'#15803d',
'removed',
'#b91c1c',
'unchanged',
'#1d4ed8',
'#ea580c',
'case',
['==', ['get', 'layer_type'], 'municipality_boundary'],
'#0f5f59',
['==', ['get', 'layer_type'], 'building'],
'#0e7490',
[
'match',
['get', 'change_type'],
'added',
'#15803d',
'removed',
'#b91c1c',
'unchanged',
'#1d4ed8',
'#ea580c',
],
],
'line-width': 2,
'line-width': ['interpolate', ['linear'], ['zoom'], 8, 0.25, 12, 0.8, 16, 2],
},
})
}
+11 -12
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import GeoMap from '../GeoMap'
import type { AreaRead, DatasetCreateResponse, QaComparisonResult, VectorSelectionBBox, VectorSelectionResponse } from '../../types'
import { featureCollectionBounds } from '../../lib/geojsonBounds'
const DEFAULT_SELECTED_FEATURE_FILENAME = 'selected-feature.geojson'
const DEFAULT_AREA_SELECTION_FILENAME = 'area-selection.geojson'
@@ -55,17 +56,15 @@ function getFeatureGeometrySummary(feature: GeoJSON.Feature | null) {
}
function getFeatureCollectionBBox(collection: GeoJSON.FeatureCollection | null): VectorSelectionBBox | null {
const points = collection?.features.flatMap((feature) => collectGeometryPoints(feature.geometry)) ?? []
if (points.length === 0) {
const bounds = featureCollectionBounds(collection)
if (!bounds) {
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),
min_x: bounds.minX,
min_y: bounds.minY,
max_x: bounds.maxX,
max_y: bounds.maxY,
crs: 'EPSG:4326',
}
}
@@ -303,9 +302,9 @@ export function MapWorkspace({
const featureExtractionEntries = featureProperties ? Object.entries(featureProperties).slice(0, 48) : []
const featureGeometrySummary = getFeatureGeometrySummary(selectedMapFeature)
const selectedFeatureGeoJson = selectedMapFeature ? selectedFeatureCollection(selectedMapFeature) : null
const selectedFeatureBbox = getFeatureBBox(selectedMapFeature)
const activeLayerBbox = getFeatureCollectionBBox(mapFeatureCollection)
const selectedAreaBbox = getFeatureCollectionBBox(areaFeatureCollection)
const selectedFeatureBbox = useMemo(() => getFeatureBBox(selectedMapFeature), [selectedMapFeature])
const activeLayerBbox = useMemo(() => getFeatureCollectionBBox(mapFeatureCollection), [mapFeatureCollection])
const selectedAreaBbox = useMemo(() => getFeatureCollectionBBox(areaFeatureCollection), [areaFeatureCollection])
const currentSelectionBbox = parseBboxInput(bboxInput)
const areaSelectionFeatures = mapSelectionResult?.geojson.features ?? []
const areaSelectionPreviewFeatures = areaSelectionFeatures.slice(0, 12)
+15
View File
@@ -4,6 +4,7 @@ export const PRIMARY_FOCUS_LABEL = 'Mol'
export const PRIMARY_FOCUS_REGION = 'Mol, Kempen'
export const PRIMARY_FOCUS_CENTER: [number, number] = [5.1167, 51.1919]
export const PRIMARY_FOCUS_AREA_NAME = 'Mol primary AOI'
export const PRIMARY_FOCUS_MUNICIPALITY_PROJECT_NAME = 'Mol Municipality Workbench'
export const PRIMARY_FOCUS_AREA_GEOJSON =
'{"type":"MultiPolygon","coordinates":[[[[5.109476746222376,51.18745107550715],[5.123924622861509,51.18745107550715],[5.123924622861509,51.19634848029239],[5.109476746222376,51.19634848029239],[5.109476746222376,51.18745107550715]]]]}'
@@ -17,6 +18,20 @@ export function isPrimaryFocusProject(project: ProjectRead): boolean {
return containsPrimaryFocus([project.name, project.description, project.region])
}
export function isPrimaryFocusMunicipalityProject(project: ProjectRead): boolean {
return project.name === PRIMARY_FOCUS_MUNICIPALITY_PROJECT_NAME
}
export function isPrimaryFocusMunicipalityBoundaryDataset(dataset: DatasetCreateResponse): boolean {
return (
dataset.status === 'ready' &&
(dataset.dataset_type === 'vector' || dataset.dataset_type === 'geojson') &&
dataset.source_name === 'vrbg' &&
dataset.source_metadata?.coverage_scope === 'municipality' &&
dataset.source_metadata?.nis_code === '13025'
)
}
export function isPrimaryFocusProjectData(
project: ProjectRead,
datasets: DatasetCreateResponse[],
+2
View File
@@ -11,6 +11,7 @@ import type {
VectorSummary,
} from '../types'
import { formatError } from '../lib/formatError'
import { isPrimaryFocusMunicipalityBoundaryDataset } from '../config/primaryFocus'
interface DatasetWorkflowOptions {
selectedProjectId: string | null
@@ -128,6 +129,7 @@ export function useDatasetWorkflow({
}
// Auto-open the first usable dataset so Data, Map and Exports start with real context.
const defaultDataset =
datasets.find(isPrimaryFocusMunicipalityBoundaryDataset) ??
datasets.find((dataset) => isVectorDatasetType(dataset.dataset_type) && dataset.status === 'ready') ??
datasets.find((dataset) => dataset.status === 'ready') ??
datasets[0]
+13
View File
@@ -3,6 +3,8 @@ import {
PRIMARY_FOCUS_AREA_GEOJSON,
PRIMARY_FOCUS_AREA_NAME,
PRIMARY_FOCUS_REGION,
isPrimaryFocusMunicipalityBoundaryDataset,
isPrimaryFocusMunicipalityProject,
isPrimaryFocusProject,
isPrimaryFocusProjectData,
} from '../config/primaryFocus'
@@ -76,6 +78,17 @@ export function useProjectWorkspace() {
if (selectedProjectId && items.some((project) => project.id === selectedProjectId)) {
return selectedProjectId
}
const municipalityProject = items.find(isPrimaryFocusMunicipalityProject)
if (municipalityProject) {
try {
const data = await fetchProjectData(municipalityProject.id)
if (data.areas.length > 0 && data.datasets.some(isPrimaryFocusMunicipalityBoundaryDataset)) {
return municipalityProject.id
}
} catch {
// Continue with the normal completeness ranking if the canonical workspace is temporarily unavailable.
}
}
const primaryProjects = items.filter(isPrimaryFocusProject)
const demoProjects = items.filter(isDemoProject)
const candidates = Array.from(
+75
View File
@@ -0,0 +1,75 @@
export interface GeoJsonBounds {
minX: number
minY: number
maxX: number
maxY: number
}
function extendBounds(bounds: GeoJsonBounds | null, x: number, y: number): GeoJsonBounds {
if (!bounds) {
return { minX: x, minY: y, maxX: x, maxY: y }
}
bounds.minX = Math.min(bounds.minX, x)
bounds.minY = Math.min(bounds.minY, y)
bounds.maxX = Math.max(bounds.maxX, x)
bounds.maxY = Math.max(bounds.maxY, y)
return bounds
}
function scanCoordinates(coordinates: unknown, initial: GeoJsonBounds | null): GeoJsonBounds | null {
let bounds = initial
const stack: unknown[] = [coordinates]
while (stack.length > 0) {
const value = stack.pop()
if (!Array.isArray(value)) {
continue
}
if (value.length >= 2 && typeof value[0] === 'number' && typeof value[1] === 'number') {
if (Number.isFinite(value[0]) && Number.isFinite(value[1])) {
bounds = extendBounds(bounds, value[0], value[1])
}
continue
}
for (const child of value) {
stack.push(child)
}
}
return bounds
}
export function geometryBounds(geometry: GeoJSON.Geometry | null | undefined): GeoJsonBounds | null {
if (!geometry) {
return null
}
if (geometry.type === 'GeometryCollection') {
return geometry.geometries.reduce<GeoJsonBounds | null>(
(bounds, child) => mergeBounds(bounds, geometryBounds(child)),
null,
)
}
return scanCoordinates(geometry.coordinates, null)
}
export function mergeBounds(left: GeoJsonBounds | null, right: GeoJsonBounds | null): GeoJsonBounds | null {
if (!left) {
return right ? { ...right } : null
}
if (!right) {
return left
}
left.minX = Math.min(left.minX, right.minX)
left.minY = Math.min(left.minY, right.minY)
left.maxX = Math.max(left.maxX, right.maxX)
left.maxY = Math.max(left.maxY, right.maxY)
return left
}
export function featureCollectionBounds(collection: GeoJSON.FeatureCollection | null | undefined): GeoJsonBounds | null {
if (!collection) {
return null
}
return collection.features.reduce<GeoJsonBounds | null>(
(bounds, feature) => mergeBounds(bounds, geometryBounds(feature.geometry)),
null,
)
}