feat: map the complete municipality of Mol
This commit is contained in:
+3
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
React + TypeScript + MapLibre foundation for project/area/dataset workflow.
|
||||
|
||||
Mol is the primary operating context. On a fresh session the workbench prefers a persisted Mol project or dataset, centers an empty MapLibre view on Mol and pre-fills a compact Mol AOI. Explicit project and dataset selections remain authoritative, and all broader Kempen workflows remain available.
|
||||
Mol is the primary operating context. On a fresh session the workbench first prefers the complete persisted `Mol Municipality Workbench` when its official NIS `13025` boundary is ready. Its lightweight municipality boundary opens before the complete GRB building reference layer, so initial map framing remains responsive while all municipality buildings stay selectable from the database-layer control. Explicit project and dataset selections remain authoritative, and all broader Kempen workflows remain available.
|
||||
|
||||
The workbench uses a task-based shell instead of a single long panel stack. `App.tsx` still owns shared orchestration state, but the UI is organized into Overview, Data, Map, QA/QC, AI Labs, Exports and System workspaces with a persistent top context bar and an optional selection-detail drawer.
|
||||
|
||||
@@ -14,6 +14,8 @@ Wide and ultrawide screens keep a readable sidebar and centered work area, expan
|
||||
|
||||
The Map workspace defaults to an OpenStreetMap road basemap with visible attribution so uploaded vectors, AOIs and QA overlays appear on a real street context. Set `VITE_MAP_STYLE_URL` to a managed MapLibre style URL to override this for production or high-volume deployments.
|
||||
|
||||
Municipality-scale GeoJSON bounds are scanned incrementally and memoized instead of materializing coordinate arrays. This supports the complete Mol boundary and roughly 37k persisted GRB buildings without JavaScript argument-spread failures. The official boundary uses a dark teal map treatment, GRB buildings use cyan/teal and existing detection/change-result colors remain distinct.
|
||||
|
||||
When the public OpenStreetMap fallback is active, the Map workspace shows a basemap usage notice. This keeps the local/demo default honest and reminds operators to configure a managed style URL before production or heavier tile traffic.
|
||||
|
||||
Operational GIS testing is now available directly in the Map workspace. Users can choose a persisted vector database layer, load it on the map, reuse the selected AOI or active layer extent, run the existing persisted `vector_features` bbox query, save the result as a derived dataset, export the selection GeoJSON, choose a reference dataset and launch QA/QC without creating fake data or a parallel backend path. The guided workflow also includes a one-click full run action that executes query, derived dataset save, GeoJSON export and optional QA/QC in sequence with visible status. For repeated review, switch the run mode from `Create new dataset/export` to `Reuse latest saved dataset for QA`; this reruns QA/QC against the latest saved derived dataset without creating another dataset/export pair.
|
||||
|
||||
@@ -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