feat: map the complete municipality of Mol
This commit is contained in:
@@ -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],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user