feat: make Mol explorer map-first
This commit is contained in:
@@ -7,6 +7,15 @@
|
||||
|
||||
# Changelog
|
||||
|
||||
## Sprint 186 Map-first Mol geographic explorer (2026-07-14)
|
||||
|
||||
- Replaced the default dashboard entry with a calm map-first workflow: choose a real data theme, drag a rectangle, query PostGIS automatically and review results.
|
||||
- Kept the previous technical map, QA, export and AI controls available behind the advanced workbench instead of mixing them into the primary path.
|
||||
- Added explicit source availability for buildings, population, forest, water, roads and parcels; unavailable themes never receive simulated values.
|
||||
- Added true drag-to-select behavior in MapLibre and exact total intersection counts alongside the bounded 1,000-feature map preview.
|
||||
- Made the official full Mol municipality area and the largest authoritative building layer the initial map context.
|
||||
- Added an idempotent operator provisioner for official Mol GRB roads, water and parcels through the existing API/DatasetService/PostGIS persistence flow.
|
||||
|
||||
## Sprint 185 Coverage-aware Mol operational benchmark (2026-07-14)
|
||||
|
||||
- Extended real detection quality-matrix evidence with persisted inference coverage, raw/evaluated/excluded/clipped populations and diagnostic-only box-to-footprint mismatch counts.
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
FastAPI backend for GeoIntel Kempen Foundation Sprints.
|
||||
|
||||
The map-first explorer uses the existing persisted vector selection endpoint. Its bounded GeoJSON preview reports `feature_count`, while `total_feature_count` reports the exact PostGIS intersection count before the 1,000-feature response cap. This keeps municipality-scale analysis honest without sending unbounded geometry to the browser.
|
||||
|
||||
## Scope implemented
|
||||
- Project CRUD
|
||||
- Area CRUD with PostGIS geometry
|
||||
|
||||
@@ -203,7 +203,7 @@ def select_vector_features(
|
||||
bbox=payload.bbox.model_dump(),
|
||||
limit=payload.limit,
|
||||
)
|
||||
return envelope(VectorSelectionResponse(**result).model_dump())
|
||||
return envelope(VectorSelectionResponse(**result).model_dump(exclude_none=True))
|
||||
|
||||
|
||||
@router.post("/datasets/{dataset_id}/vector/select/derive", status_code=201, response_model=dict)
|
||||
|
||||
@@ -220,6 +220,7 @@ class VectorSelectionDeriveRequest(VectorSelectionRequest):
|
||||
class VectorSelectionResponse(BaseModel):
|
||||
selection_bbox: VectorSelectionBBox
|
||||
feature_count: int
|
||||
total_feature_count: int | None = None
|
||||
limit: int
|
||||
truncated: bool
|
||||
geojson: dict
|
||||
|
||||
@@ -92,7 +92,7 @@ class VectorFeatureService:
|
||||
normalized_bbox = VectorFeatureService._normalize_selection_bbox(bbox)
|
||||
safe_limit = max(1, min(int(limit), 1000))
|
||||
|
||||
rows = (
|
||||
query = (
|
||||
db.query(VectorFeature)
|
||||
.filter(VectorFeature.dataset_id == dataset_id)
|
||||
.filter(
|
||||
@@ -107,17 +107,25 @@ class VectorFeatureService:
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by(VectorFeature.created_at.asc())
|
||||
)
|
||||
if hasattr(query, "count"):
|
||||
total_feature_count = int(query.count())
|
||||
else: # Lightweight unit-test sessions do not always implement Query.count().
|
||||
total_feature_count = len(query.all())
|
||||
|
||||
rows = (
|
||||
query.order_by(VectorFeature.created_at.asc())
|
||||
.limit(safe_limit + 1)
|
||||
.all()
|
||||
)
|
||||
truncated = len(rows) > safe_limit
|
||||
truncated = total_feature_count > safe_limit
|
||||
selected_rows = rows[:safe_limit]
|
||||
features = [VectorFeatureService._row_to_geojson_feature(row) for row in selected_rows]
|
||||
|
||||
return {
|
||||
"selection_bbox": normalized_bbox,
|
||||
"feature_count": len(features),
|
||||
"total_feature_count": total_feature_count,
|
||||
"limit": safe_limit,
|
||||
"truncated": truncated,
|
||||
"geojson": {
|
||||
|
||||
@@ -79,6 +79,7 @@ def test_vector_feature_service_extracts_bbox_geojson_from_persisted_rows() -> N
|
||||
)
|
||||
|
||||
assert result["feature_count"] == 1
|
||||
assert result["total_feature_count"] == 1
|
||||
assert result["truncated"] is False
|
||||
assert result["geojson"]["type"] == "FeatureCollection"
|
||||
feature = result["geojson"]["features"][0]
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def read(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_map_first_explorer_is_the_default_product_flow() -> None:
|
||||
app = read("frontend/src/App.tsx")
|
||||
workspace = read("frontend/src/components/map/MapWorkspace.tsx")
|
||||
|
||||
assert "useState<WorkspaceKey>('map')" in app
|
||||
assert "Wat bevindt zich in dit gebied?" in workspace
|
||||
assert "Kies een datathema" in workspace
|
||||
assert "Teken rechthoek" in workspace
|
||||
assert "Volledige gemeente" in workspace
|
||||
assert "Alle beschikbare thema" in workspace
|
||||
assert "Bron nog niet ingeladen" in workspace
|
||||
assert "useMapThemeSelectionInsights" in workspace
|
||||
assert "datasetsApi.selectVectorFeatures" in read("frontend/src/hooks/useMapThemeSelectionInsights.ts")
|
||||
|
||||
|
||||
def test_map_rectangle_drag_is_wired_to_automatic_analysis() -> None:
|
||||
workspace = read("frontend/src/components/map/MapWorkspace.tsx")
|
||||
geomap = read("frontend/src/components/GeoMap.tsx")
|
||||
|
||||
assert "onMapBboxPreview={handleMapBboxPreview}" in workspace
|
||||
assert "onMapBboxSelect={handleMapBboxSelect}" in workspace
|
||||
assert "void analyzeSelection(bbox)" in workspace
|
||||
assert "map.on('mousedown'" in geomap
|
||||
assert "map.on('mousemove'" in geomap
|
||||
assert "map.on('mouseup'" in geomap
|
||||
assert "onMapBboxSelectRef.current?.(bbox)" in geomap
|
||||
|
||||
|
||||
def test_selection_contract_reports_total_intersections_separately_from_preview() -> None:
|
||||
schema = read("backend/app/schemas/operations.py")
|
||||
service = read("backend/app/services/vector_feature_service.py")
|
||||
frontend_types = read("frontend/src/types.ts")
|
||||
|
||||
assert "total_feature_count: int | None = None" in schema
|
||||
assert '"total_feature_count": total_feature_count' in service
|
||||
assert "total_feature_count?: number | null" in frontend_types
|
||||
|
||||
|
||||
def test_official_mol_context_provisioner_uses_existing_dataset_flow() -> None:
|
||||
script = read("scripts/provision_mol_context_layers.py")
|
||||
dockerfile = read("deploy/unraid/Dockerfile.all-in-one")
|
||||
readiness = read("scripts/run_readiness_check.sh")
|
||||
|
||||
assert '("Wegsegment",)' in script
|
||||
assert '("WTZ", "WLAS", "WGR")' in script
|
||||
assert '("ADP",)' in script
|
||||
assert '"dataset_role": "reference"' in script
|
||||
assert '"source_name": "grb"' in script
|
||||
assert "/datasets/upload" in script
|
||||
assert "provision_mol_context_layers.py" in dockerfile
|
||||
assert "py_compile scripts/provision_mol_context_layers.py" in readiness
|
||||
@@ -39,7 +39,7 @@ def test_app_entrypoint_has_clean_encoding_and_react_imports() -> None:
|
||||
assert "FormEvent" not in app
|
||||
assert app.count("useEffect(") == 1
|
||||
assert "window.scrollTo({ top: 0, left: 0 })" in app
|
||||
assert "const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceKey>('overview')" in app
|
||||
assert "const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceKey>('map')" in app
|
||||
|
||||
|
||||
def test_demo_workflow_hook_owns_demo_api_and_cross_module_selection() -> None:
|
||||
|
||||
@@ -73,6 +73,7 @@ RUN python scripts/gis_import_smoke.py \
|
||||
|
||||
COPY scripts/prepare_operator_real_data_samples.py /app/scripts/prepare_operator_real_data_samples.py
|
||||
COPY scripts/provision_mol_municipality_workspace.py /app/scripts/provision_mol_municipality_workspace.py
|
||||
COPY scripts/provision_mol_context_layers.py /app/scripts/provision_mol_context_layers.py
|
||||
COPY scripts/export_operator_yolo_tile_dataset.py /app/scripts/export_operator_yolo_tile_dataset.py
|
||||
COPY scripts/audit_operator_yolo_dataset_quality.py /app/scripts/audit_operator_yolo_dataset_quality.py
|
||||
COPY scripts/render_operator_yolo_label_qa_contact_sheets.py /app/scripts/render_operator_yolo_label_qa_contact_sheets.py
|
||||
|
||||
@@ -389,6 +389,7 @@ Response:
|
||||
"crs": "EPSG:4326"
|
||||
},
|
||||
"feature_count": 2,
|
||||
"total_feature_count": 2,
|
||||
"limit": 250,
|
||||
"truncated": false,
|
||||
"geojson": {
|
||||
@@ -404,7 +405,8 @@ Rules:
|
||||
- Only vector/GeoJSON datasets are supported.
|
||||
- Coordinates are EPSG:4326 longitude/latitude.
|
||||
- Results are generated from persisted PostGIS `vector_features`, not from client-side map data.
|
||||
- The response is capped by `limit` and returns `truncated=true` when more matching rows exist.
|
||||
- `feature_count` is the number of GeoJSON features returned in the bounded preview. `total_feature_count` is the exact number of persisted rows intersecting the bbox.
|
||||
- The response is capped by `limit` and returns `truncated=true` when `total_feature_count` exceeds the returned preview.
|
||||
- `limit` is bounded to `1..1000`. Municipality-scale clients must page spatially by viewport instead of requesting an unbounded municipality FeatureCollection.
|
||||
- The Map workspace uses this existing endpoint for vector datasets above 5,000 features. It starts delivery at zoom level 14, debounces `moveend` requests and explicitly reports `truncated=true` as a request to zoom further in. This is a client delivery policy, not a second API or persistence path.
|
||||
|
||||
|
||||
@@ -7743,3 +7743,24 @@ Open:
|
||||
- Final internal-browser verification loaded the official Mol municipality
|
||||
boundary over the OpenStreetMap road basemap from the persisted PostGIS
|
||||
workspace. The live Tower page reported no console warnings or errors.
|
||||
## Sprint 186 Map-first Mol geographic explorer (2026-07-14)
|
||||
|
||||
Changed:
|
||||
- Made Map the default application workspace and reduced the primary task to data theme, rectangle selection and evidence review.
|
||||
- Added a dedicated three-column explorer with explicit available/missing themes, full-Mol scope, true MapLibre drag selection, automatic PostGIS queries, exact totals, density, property evidence and GeoJSON handoff.
|
||||
- Preserved the previous technical workflow behind `Geavanceerde werkbank` and kept QA, AI and export persistence unchanged.
|
||||
- Added exact `total_feature_count` to the vector bbox-selection response while retaining the existing 1,000-feature geometry cap.
|
||||
- Added `provision_mol_context_layers.py` for official GRB roads (`Wegsegment`), water (`WTZ`, `WLAS`, `WGR`) and parcels (`ADP`) clipped to NIS 13025 and imported through the public dataset API.
|
||||
|
||||
Validated so far:
|
||||
- Frontend typecheck and production build passed.
|
||||
- Focused map, orchestration and new explorer tests passed.
|
||||
- Official fetch-only smoke produced 8,444 Mol road features, 3,668 water features and 32,961 parcels with complete pagination and no truncation.
|
||||
- Full backend suite reached 516 tests; one legacy component-boundary guard initially failed and was resolved by moving theme API orchestration into `useMapThemeSelectionInsights`.
|
||||
|
||||
Limitations:
|
||||
- Population and forest/green remain unavailable rather than simulated until suitable authoritative sources and semantics are selected.
|
||||
- Live Tower import and browser verification follow after the full readiness gate and deployment.
|
||||
|
||||
Next:
|
||||
- Deploy, import the three official Mol context layers, validate rectangle analysis in the in-app browser and then define authoritative population and land-cover source adapters.
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# GeoIntel TODO
|
||||
|
||||
## Map-first Mol explorer
|
||||
|
||||
- [x] Make the Mol geographic explorer the default workflow.
|
||||
- [x] Add drag rectangle selection with automatic persisted theme queries.
|
||||
- [x] Separate exact bbox intersection totals from the bounded map preview.
|
||||
- [x] Add official Mol GRB roads, water and parcels provisioning support.
|
||||
- [ ] Select and validate an official Mol population/statistical-sector source before enabling the population theme.
|
||||
- [ ] Select and validate an authoritative Flemish land-cover source before enabling the forest/green theme.
|
||||
|
||||
This file now starts with the current implementation status. Older preparation/backlog sections are preserved below as historical planning context and should not be treated as the live sprint board without checking `docs/CODEX_EXECUTION_LOG.md`.
|
||||
|
||||
## Release hardening status
|
||||
|
||||
+6
-2
@@ -2,9 +2,13 @@
|
||||
|
||||
React + TypeScript + MapLibre foundation for project/area/dataset workflow.
|
||||
|
||||
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.
|
||||
Mol is the primary operating context. On a fresh session the application opens the map-first geographic explorer, prefers the persisted `Mol Municipality Workbench`, selects the official NIS `13025` municipality boundary and activates the largest available authoritative building layer. 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.
|
||||
The primary workflow is deliberately short: choose a data theme, drag a rectangle on the MapLibre map and read the resulting PostGIS evidence. Releasing the drag runs the active theme query and every other available theme query for the same EPSG:4326 bbox. The result panel shows selection area, exact intersection totals, active-theme density, source identity and bounded feature properties. Map rendering remains capped at 1,000 features while `total_feature_count` reports the exact database count.
|
||||
|
||||
The theme catalog currently recognizes buildings, population, forest/green, water, roads and parcels from dataset names and canonical `reference_layer_name` metadata. A theme is enabled only when a ready persisted vector dataset exists; otherwise it states `Bron nog niet ingeladen`. This prevents missing population or land-cover sources from appearing as zero-valued observations. The previous technical Map workspace remains available through `Geavanceerde werkbank` for derived datasets, QA/QC evidence and export operations.
|
||||
|
||||
The workbench uses a task-based shell instead of a single long panel stack. `App.tsx` still owns shared orchestration state, but Map is the default product entry and Overview, Data, QA/QC, AI Labs, Exports and System remain secondary workspaces with a persistent top context bar and an optional selection-detail drawer.
|
||||
|
||||
The premium V1 presentation layer lives in `src/styles/premium.css`. It groups navigation by Workspace, Analyze and Deliver, removes the permanent inspector column, gives desktop/ultrawide workspaces stable readable widths and switches narrow screens to full-width content with a horizontally scrollable navigation rail. API calls and workflow state remain owned by the existing hooks.
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ const workspaceNavGroups: Array<{ label: string; keys: WorkspaceKey[] }> = [
|
||||
]
|
||||
|
||||
function App(): JSX.Element {
|
||||
const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceKey>('overview')
|
||||
const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceKey>('map')
|
||||
const [inspectorOpen, setInspectorOpen] = useState(false)
|
||||
const [mapContentMode, setMapContentMode] = useState<'dataset' | 'analysis'>('dataset')
|
||||
useEffect(() => {
|
||||
@@ -910,6 +910,7 @@ function App(): JSX.Element {
|
||||
|
||||
{activeWorkspace === 'map' ? (
|
||||
<MapWorkspace
|
||||
selectedProjectId={selectedProjectId}
|
||||
areas={areas}
|
||||
selectedMapAreaId={selectedMapAreaId}
|
||||
areaFeatureCollection={areaFeatureCollection}
|
||||
|
||||
@@ -3,7 +3,7 @@ import maplibregl from 'maplibre-gl'
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
import { PRIMARY_FOCUS_CENTER } from '../config/primaryFocus'
|
||||
import { featureCollectionBounds } from '../lib/geojsonBounds'
|
||||
import type { MapViewportState } from '../types'
|
||||
import type { MapViewportState, VectorSelectionBBox } from '../types'
|
||||
|
||||
interface GeoMapProps {
|
||||
data: GeoJSON.FeatureCollection | null
|
||||
@@ -20,6 +20,8 @@ interface GeoMapProps {
|
||||
fitDataOnChange?: boolean
|
||||
onFeatureSelect?: (feature: GeoJSON.Feature | null) => void
|
||||
onMapCoordinateSelect?: (coordinate: [number, number]) => void
|
||||
onMapBboxPreview?: (bbox: VectorSelectionBBox) => void
|
||||
onMapBboxSelect?: (bbox: VectorSelectionBBox) => void
|
||||
onViewportChange?: (viewport: MapViewportState) => void
|
||||
}
|
||||
|
||||
@@ -114,12 +116,16 @@ function GeoMap({
|
||||
fitDataOnChange = true,
|
||||
onFeatureSelect,
|
||||
onMapCoordinateSelect,
|
||||
onMapBboxPreview,
|
||||
onMapBboxSelect,
|
||||
onViewportChange,
|
||||
}: GeoMapProps): JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const mapRef = useRef<maplibregl.Map | null>(null)
|
||||
const onFeatureSelectRef = useRef<GeoMapProps['onFeatureSelect']>(onFeatureSelect)
|
||||
const onMapCoordinateSelectRef = useRef<GeoMapProps['onMapCoordinateSelect']>(onMapCoordinateSelect)
|
||||
const onMapBboxPreviewRef = useRef<GeoMapProps['onMapBboxPreview']>(onMapBboxPreview)
|
||||
const onMapBboxSelectRef = useRef<GeoMapProps['onMapBboxSelect']>(onMapBboxSelect)
|
||||
const onViewportChangeRef = useRef<GeoMapProps['onViewportChange']>(onViewportChange)
|
||||
const bboxSelectionModeRef = useRef(bboxSelectionMode)
|
||||
const lastFittedAreaRef = useRef<GeoJSON.FeatureCollection | null>(null)
|
||||
@@ -133,6 +139,14 @@ function GeoMap({
|
||||
onMapCoordinateSelectRef.current = onMapCoordinateSelect
|
||||
}, [onMapCoordinateSelect])
|
||||
|
||||
useEffect(() => {
|
||||
onMapBboxPreviewRef.current = onMapBboxPreview
|
||||
}, [onMapBboxPreview])
|
||||
|
||||
useEffect(() => {
|
||||
onMapBboxSelectRef.current = onMapBboxSelect
|
||||
}, [onMapBboxSelect])
|
||||
|
||||
useEffect(() => {
|
||||
onViewportChangeRef.current = onViewportChange
|
||||
}, [onViewportChange])
|
||||
@@ -141,6 +155,11 @@ function GeoMap({
|
||||
bboxSelectionModeRef.current = bboxSelectionMode
|
||||
if (mapRef.current) {
|
||||
mapRef.current.getCanvas().style.cursor = bboxSelectionMode ? 'crosshair' : ''
|
||||
if (bboxSelectionMode) {
|
||||
mapRef.current.dragPan.disable()
|
||||
} else {
|
||||
mapRef.current.dragPan.enable()
|
||||
}
|
||||
}
|
||||
}, [bboxSelectionMode])
|
||||
|
||||
@@ -181,8 +200,48 @@ function GeoMap({
|
||||
emitViewport()
|
||||
})
|
||||
map.on('moveend', emitViewport)
|
||||
let dragStart: [number, number] | null = null
|
||||
let draggedSelection = false
|
||||
const bboxFromCoordinates = (start: [number, number], end: [number, number]): VectorSelectionBBox => ({
|
||||
min_x: Math.min(start[0], end[0]),
|
||||
min_y: Math.min(start[1], end[1]),
|
||||
max_x: Math.max(start[0], end[0]),
|
||||
max_y: Math.max(start[1], end[1]),
|
||||
crs: 'EPSG:4326',
|
||||
})
|
||||
map.on('mousedown', (event) => {
|
||||
if (!bboxSelectionModeRef.current || event.originalEvent.button !== 0) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
dragStart = [event.lngLat.lng, event.lngLat.lat]
|
||||
draggedSelection = false
|
||||
map.getCanvas().style.cursor = 'crosshair'
|
||||
})
|
||||
map.on('mousemove', (event) => {
|
||||
if (!bboxSelectionModeRef.current || !dragStart) {
|
||||
return
|
||||
}
|
||||
draggedSelection = true
|
||||
onMapBboxPreviewRef.current?.(bboxFromCoordinates(dragStart, [event.lngLat.lng, event.lngLat.lat]))
|
||||
})
|
||||
map.on('mouseup', (event) => {
|
||||
if (!bboxSelectionModeRef.current || !dragStart) {
|
||||
return
|
||||
}
|
||||
const start = dragStart
|
||||
dragStart = null
|
||||
const bbox = bboxFromCoordinates(start, [event.lngLat.lng, event.lngLat.lat])
|
||||
if (bbox.max_x > bbox.min_x && bbox.max_y > bbox.min_y) {
|
||||
onMapBboxSelectRef.current?.(bbox)
|
||||
}
|
||||
})
|
||||
map.on('click', (event) => {
|
||||
if (bboxSelectionModeRef.current) {
|
||||
if (draggedSelection) {
|
||||
draggedSelection = false
|
||||
return
|
||||
}
|
||||
onMapCoordinateSelectRef.current?.([event.lngLat.lng, event.lngLat.lat])
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2,10 +2,129 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import GeoMap from '../GeoMap'
|
||||
import type { AreaRead, DatasetCreateResponse, MapViewportState, QaComparisonResult, VectorSelectionBBox, VectorSelectionResponse } from '../../types'
|
||||
import { featureCollectionBounds } from '../../lib/geojsonBounds'
|
||||
import { useMapThemeSelectionInsights } from '../../hooks/useMapThemeSelectionInsights'
|
||||
|
||||
const DEFAULT_SELECTED_FEATURE_FILENAME = 'selected-feature.geojson'
|
||||
const DEFAULT_AREA_SELECTION_FILENAME = 'area-selection.geojson'
|
||||
|
||||
type DataThemeId = 'buildings' | 'population' | 'forest' | 'water' | 'roads' | 'parcels'
|
||||
|
||||
interface DataTheme {
|
||||
id: DataThemeId
|
||||
label: string
|
||||
shortLabel: string
|
||||
description: string
|
||||
tokens: string[]
|
||||
}
|
||||
|
||||
const DATA_THEMES: DataTheme[] = [
|
||||
{
|
||||
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: '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: 'water',
|
||||
label: 'Water',
|
||||
shortLabel: 'Water',
|
||||
description: 'Waterlopen, grachten, kanalen en wateroppervlakken.',
|
||||
tokens: ['waterways', 'waterway', 'water', 'hydro', 'river', 'stream', 'canal', 'waterloop'],
|
||||
},
|
||||
{
|
||||
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'],
|
||||
},
|
||||
]
|
||||
|
||||
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'],
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
function datasetMatchesTheme(dataset: DatasetCreateResponse, theme: DataTheme): boolean {
|
||||
const searchText = datasetSearchText(dataset)
|
||||
return theme.tokens.some((token) => searchText.includes(token))
|
||||
}
|
||||
|
||||
function pickThemeDataset(datasets: DatasetCreateResponse[], theme: DataTheme): DatasetCreateResponse | null {
|
||||
const candidates = datasets.filter((dataset) => datasetMatchesTheme(dataset, theme))
|
||||
candidates.sort((left, right) => {
|
||||
const score = (dataset: DatasetCreateResponse) =>
|
||||
(dataset.reference_layer_name && theme.tokens.includes(dataset.reference_layer_name.toLowerCase()) ? 1_000_000 : 0) +
|
||||
(dataset.source_name === 'grb' ? 100_000 : 0) +
|
||||
(dataset.dataset_role === 'reference' ? 10_000 : 0) +
|
||||
(dataset.feature_count ?? dataset.vector_summary?.feature_count ?? 0)
|
||||
return score(right) - score(left)
|
||||
})
|
||||
return candidates[0] ?? null
|
||||
}
|
||||
|
||||
function selectionAreaSquareMetres(bbox: VectorSelectionBBox | null): number | null {
|
||||
if (!bbox) {
|
||||
return null
|
||||
}
|
||||
const middleLatitudeRadians = ((bbox.min_y + bbox.max_y) / 2) * (Math.PI / 180)
|
||||
const widthMetres = (bbox.max_x - bbox.min_x) * 111_320 * Math.cos(middleLatitudeRadians)
|
||||
const heightMetres = (bbox.max_y - bbox.min_y) * 110_574
|
||||
return Math.max(0, widthMetres * heightMetres)
|
||||
}
|
||||
|
||||
function formatArea(areaSquareMetres: number | null): string {
|
||||
if (areaSquareMetres === null) {
|
||||
return 'Nog niet geselecteerd'
|
||||
}
|
||||
if (areaSquareMetres >= 1_000_000) {
|
||||
return `${(areaSquareMetres / 1_000_000).toLocaleString('nl-BE', { maximumFractionDigits: 2 })} km2`
|
||||
}
|
||||
return `${(areaSquareMetres / 10_000).toLocaleString('nl-BE', { maximumFractionDigits: 2 })} ha`
|
||||
}
|
||||
|
||||
function resultCountLabel(result: VectorSelectionResponse): string {
|
||||
const total = result.total_feature_count ?? result.feature_count
|
||||
return result.truncated && result.total_feature_count == null ? `${result.feature_count.toLocaleString('nl-BE')}+` : total.toLocaleString('nl-BE')
|
||||
}
|
||||
|
||||
function readablePropertyName(value: string): string {
|
||||
return value.replace(/_/g, ' ').replace(/\b\w/g, (character) => character.toUpperCase())
|
||||
}
|
||||
|
||||
function collectGeometryPoints(geometry: GeoJSON.Geometry | null | undefined): Array<[number, number]> {
|
||||
const points: Array<[number, number]> = []
|
||||
const walk = (coords: unknown) => {
|
||||
@@ -171,6 +290,7 @@ function downloadJsonFile(filename: string, payload: unknown): void {
|
||||
}
|
||||
|
||||
interface MapWorkspaceProps {
|
||||
selectedProjectId: string | null
|
||||
areas: AreaRead[]
|
||||
selectedMapAreaId: string
|
||||
areaFeatureCollection: GeoJSON.FeatureCollection | null
|
||||
@@ -237,6 +357,7 @@ interface MapWorkspaceProps {
|
||||
}
|
||||
|
||||
export function MapWorkspace({
|
||||
selectedProjectId,
|
||||
areas,
|
||||
selectedMapAreaId,
|
||||
areaFeatureCollection,
|
||||
@@ -301,6 +422,15 @@ export function MapWorkspace({
|
||||
onOpenMapSelectionQualityEvidence,
|
||||
onClearQualityEvidence,
|
||||
}: MapWorkspaceProps): JSX.Element {
|
||||
const [advancedMode, setAdvancedMode] = useState(false)
|
||||
const [activeThemeId, setActiveThemeId] = useState<DataThemeId>('buildings')
|
||||
const {
|
||||
themeInsights,
|
||||
themeInsightsLoading: themeResultsLoading,
|
||||
themeInsightsError: themeResultsError,
|
||||
loadThemeInsights,
|
||||
clearThemeInsights,
|
||||
} = useMapThemeSelectionInsights<DataThemeId>(selectedProjectId)
|
||||
const [bboxSelectionMode, setBboxSelectionMode] = useState(false)
|
||||
const [firstSelectionCorner, setFirstSelectionCorner] = useState<[number, number] | null>(null)
|
||||
const [bboxInput, setBboxInput] = useState(bboxToInputState(mapSelectionBbox))
|
||||
@@ -330,11 +460,69 @@ export function MapWorkspace({
|
||||
const selectedFeatureFilename = selectedFeatureStem === 'selected-feature' ? DEFAULT_SELECTED_FEATURE_FILENAME : `${selectedFeatureStem}.geojson`
|
||||
const selectedMapDataset = availableMapDatasets.find((dataset) => dataset.id === selectedMapDatasetId) ?? null
|
||||
const usesDefaultOsmBasemap = !import.meta.env.VITE_MAP_STYLE_URL
|
||||
const themeDatasetMap = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
DATA_THEMES.map((theme) => [theme.id, pickThemeDataset(availableMapDatasets, theme)]),
|
||||
) as Record<DataThemeId, DatasetCreateResponse | null>,
|
||||
[availableMapDatasets],
|
||||
)
|
||||
const activeTheme = DATA_THEMES.find((theme) => theme.id === activeThemeId) ?? DATA_THEMES[0]
|
||||
const activeThemeDataset = themeDatasetMap[activeTheme.id]
|
||||
const selectedAreaSquareMetres = useMemo(() => selectionAreaSquareMetres(mapSelectionBbox), [mapSelectionBbox])
|
||||
const selectedResultTotal = mapSelectionResult?.total_feature_count ?? mapSelectionResult?.feature_count ?? 0
|
||||
const selectedDensity = selectedAreaSquareMetres && selectedAreaSquareMetres > 0
|
||||
? selectedResultTotal / (selectedAreaSquareMetres / 1_000_000)
|
||||
: null
|
||||
const selectedResultProperties = useMemo(() => {
|
||||
const keys = new Map<string, Set<string>>()
|
||||
for (const feature of mapSelectionResult?.geojson.features ?? []) {
|
||||
for (const [key, value] of Object.entries(feature.properties ?? {})) {
|
||||
if (value === null || value === undefined || typeof value === 'object' || key.endsWith('_id')) {
|
||||
continue
|
||||
}
|
||||
const values = keys.get(key) ?? new Set<string>()
|
||||
if (values.size < 4) {
|
||||
values.add(String(value))
|
||||
}
|
||||
keys.set(key, values)
|
||||
}
|
||||
}
|
||||
return Array.from(keys.entries())
|
||||
.filter(([, values]) => values.size > 0)
|
||||
.slice(0, 8)
|
||||
.map(([key, values]) => ({ key, values: Array.from(values) }))
|
||||
}, [mapSelectionResult])
|
||||
const themeResults = useMemo(
|
||||
() =>
|
||||
themeInsights.flatMap((insight) => {
|
||||
const theme = DATA_THEMES.find((candidate) => candidate.id === insight.themeId)
|
||||
return theme ? [{ theme, dataset: insight.dataset, result: insight.result }] : []
|
||||
}),
|
||||
[themeInsights],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setBboxInput(bboxToInputState(mapSelectionBbox))
|
||||
}, [mapSelectionBbox])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedMapDatasetId || !themeDatasetMap.buildings) {
|
||||
return
|
||||
}
|
||||
onOpenDatasetInMap(themeDatasetMap.buildings)
|
||||
}, [onOpenDatasetInMap, selectedMapDatasetId, themeDatasetMap.buildings])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedMapDataset) {
|
||||
return
|
||||
}
|
||||
const matchingTheme = DATA_THEMES.find((theme) => datasetMatchesTheme(selectedMapDataset, theme))
|
||||
if (matchingTheme) {
|
||||
setActiveThemeId(matchingTheme.id)
|
||||
}
|
||||
}, [selectedMapDataset])
|
||||
|
||||
const downloadSelectedMapFeature = () => {
|
||||
if (!selectedFeatureGeoJson) {
|
||||
return
|
||||
@@ -353,6 +541,7 @@ export function MapWorkspace({
|
||||
|
||||
const startBboxSelection = () => {
|
||||
setFirstSelectionCorner(null)
|
||||
clearThemeInsights()
|
||||
setBboxSelectionMode(true)
|
||||
}
|
||||
|
||||
@@ -365,6 +554,7 @@ export function MapWorkspace({
|
||||
setSelectionBbox(bbox)
|
||||
setFirstSelectionCorner(null)
|
||||
setBboxSelectionMode(false)
|
||||
void analyzeSelection(bbox)
|
||||
}
|
||||
|
||||
const runAreaExtract = () => {
|
||||
@@ -372,13 +562,14 @@ export function MapWorkspace({
|
||||
if (!bbox) {
|
||||
return
|
||||
}
|
||||
onRunMapSelectionExtract(bbox)
|
||||
void analyzeSelection(bbox)
|
||||
}
|
||||
|
||||
const clearAreaSelection = () => {
|
||||
setBboxSelectionMode(false)
|
||||
setFirstSelectionCorner(null)
|
||||
setBboxInput(bboxToInputState(null))
|
||||
clearThemeInsights()
|
||||
onClearMapSelectionExtract()
|
||||
}
|
||||
|
||||
@@ -416,13 +607,45 @@ export function MapWorkspace({
|
||||
}
|
||||
}
|
||||
|
||||
const selectDataTheme = (theme: DataTheme) => {
|
||||
const dataset = themeDatasetMap[theme.id]
|
||||
if (!dataset) {
|
||||
return
|
||||
}
|
||||
setActiveThemeId(theme.id)
|
||||
onOpenDatasetInMap(dataset)
|
||||
}
|
||||
|
||||
const loadAllThemeResults = async (bbox: VectorSelectionBBox) => {
|
||||
const availableThemes = DATA_THEMES.flatMap((theme) => {
|
||||
const dataset = themeDatasetMap[theme.id]
|
||||
return dataset ? [{ themeId: theme.id, dataset }] : []
|
||||
})
|
||||
await loadThemeInsights(bbox, availableThemes)
|
||||
}
|
||||
|
||||
const analyzeSelection = async (bbox: VectorSelectionBBox) => {
|
||||
setSelectionBbox(bbox)
|
||||
await Promise.all([onRunMapSelectionExtract(bbox), loadAllThemeResults(bbox)])
|
||||
}
|
||||
|
||||
const handleMapBboxPreview = (bbox: VectorSelectionBBox) => {
|
||||
setSelectionBbox(bbox)
|
||||
}
|
||||
|
||||
const handleMapBboxSelect = (bbox: VectorSelectionBBox) => {
|
||||
setFirstSelectionCorner(null)
|
||||
setBboxSelectionMode(false)
|
||||
void analyzeSelection(bbox)
|
||||
}
|
||||
|
||||
const runQuickAoiExtract = () => {
|
||||
const bbox = selectedAreaBbox ?? activeLayerBbox
|
||||
if (!bbox) {
|
||||
return
|
||||
}
|
||||
setSelectionBbox(bbox)
|
||||
onRunMapSelectionExtract(bbox)
|
||||
void analyzeSelection(bbox)
|
||||
}
|
||||
|
||||
const runFullGisWorkflow = async () => {
|
||||
@@ -494,8 +717,252 @@ export function MapWorkspace({
|
||||
}
|
||||
}
|
||||
|
||||
if (!advancedMode) {
|
||||
return (
|
||||
<section className="geo-explorer" data-testid="map-workspace" aria-label="Gebiedsverkenner Mol">
|
||||
<header className="geo-explorer-header">
|
||||
<div>
|
||||
<p className="eyebrow">Mol · geografische verkenner</p>
|
||||
<h2>Wat bevindt zich in dit gebied?</h2>
|
||||
<p>Kies een datathema, teken een rechthoek en lees de beschikbare gegevens meteen uit.</p>
|
||||
</div>
|
||||
<button className="secondary-action geo-explorer-advanced" type="button" onClick={() => setAdvancedMode(true)}>
|
||||
Geavanceerde werkbank
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="geo-explorer-layout">
|
||||
<aside className="geo-theme-panel" aria-label="Datathema kiezen">
|
||||
<div className="geo-panel-heading">
|
||||
<span>1</span>
|
||||
<div>
|
||||
<h3>Kies een datathema</h3>
|
||||
<p>Dit zijn databronnen, geen AI-modellen.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="geo-theme-list">
|
||||
{DATA_THEMES.map((theme) => {
|
||||
const dataset = themeDatasetMap[theme.id]
|
||||
const active = activeThemeId === theme.id
|
||||
return (
|
||||
<button
|
||||
className={active ? 'geo-theme-option geo-theme-option-active' : 'geo-theme-option'}
|
||||
disabled={!dataset}
|
||||
key={theme.id}
|
||||
type="button"
|
||||
onClick={() => selectDataTheme(theme)}
|
||||
aria-pressed={active}
|
||||
>
|
||||
<span className={`geo-theme-symbol geo-theme-symbol-${theme.id}`} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>{theme.label}</strong>
|
||||
<small>{dataset ? `${(dataset.feature_count ?? dataset.vector_summary?.feature_count ?? 0).toLocaleString('nl-BE')} objecten beschikbaar` : 'Bron nog niet ingeladen'}</small>
|
||||
</span>
|
||||
<i>{dataset ? 'Beschikbaar' : 'Ontbreekt'}</i>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="geo-source-summary">
|
||||
<span>Actieve bron</span>
|
||||
<strong>{activeThemeDataset?.name ?? 'Geen databron beschikbaar'}</strong>
|
||||
<small>
|
||||
{activeThemeDataset
|
||||
? `${activeThemeDataset.source_name ?? activeThemeDataset.source} · ${activeThemeDataset.dataset_role ?? 'source'} · EPSG:4326`
|
||||
: activeTheme.description}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<label className="geo-scope-select">
|
||||
Werkgebied
|
||||
<select value={selectedMapAreaId} onChange={(event) => onSelectMapArea(event.target.value)} disabled={areas.length === 0}>
|
||||
<option value="">Geen werkgebied</option>
|
||||
{areas.map((area) => (
|
||||
<option key={area.id} value={area.id}>{area.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</aside>
|
||||
|
||||
<div className="geo-map-stage">
|
||||
<div className="geo-map-toolbar" aria-label="Gebied selecteren">
|
||||
<div className="geo-panel-heading geo-map-step">
|
||||
<span>2</span>
|
||||
<div>
|
||||
<h3>Selecteer een gebied</h3>
|
||||
<p>{bboxSelectionMode ? 'Sleep nu een rechthoek op de kaart.' : 'Sleep een rechthoek of analyseer de volledige gemeente.'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="geo-map-actions">
|
||||
<button
|
||||
className={bboxSelectionMode ? 'primary-action geo-draw-active' : 'primary-action'}
|
||||
disabled={!activeThemeDataset || mapSelectionLoading || themeResultsLoading}
|
||||
type="button"
|
||||
onClick={startBboxSelection}
|
||||
>
|
||||
{bboxSelectionMode ? 'Teken op de kaart…' : 'Teken rechthoek'}
|
||||
</button>
|
||||
<button
|
||||
className="secondary-action"
|
||||
disabled={!activeThemeDataset || !selectedAreaBbox || mapSelectionLoading || themeResultsLoading}
|
||||
type="button"
|
||||
onClick={() => selectedAreaBbox && void analyzeSelection(selectedAreaBbox)}
|
||||
>
|
||||
Volledige gemeente
|
||||
</button>
|
||||
<button className="secondary-action" disabled={!mapSelectionBbox} type="button" onClick={clearAreaSelection}>
|
||||
Wis selectie
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={bboxSelectionMode ? 'geo-map-canvas geo-map-canvas-drawing' : 'geo-map-canvas'}>
|
||||
<GeoMap
|
||||
data={mapFeatureCollection}
|
||||
areaData={areaFeatureCollection}
|
||||
selectedFeature={selectedFeature}
|
||||
selectionData={mapSelectionResult?.geojson ?? null}
|
||||
selectionBbox={mapSelectionBbox}
|
||||
bboxSelectionMode={bboxSelectionMode}
|
||||
visible={mapLayerVisible}
|
||||
opacity={mapLayerOpacity}
|
||||
areaVisible={areaLayerVisible}
|
||||
areaOpacity={areaLayerOpacity}
|
||||
fitDataOnChange={fitMapDataOnChange}
|
||||
onFeatureSelect={onSelectMapFeature}
|
||||
onMapCoordinateSelect={handleMapCoordinateSelect}
|
||||
onMapBboxPreview={handleMapBboxPreview}
|
||||
onMapBboxSelect={handleMapBboxSelect}
|
||||
onViewportChange={onMapViewportChange}
|
||||
/>
|
||||
<div className="geo-map-legend" aria-label="Kaartlegende">
|
||||
<span><i className="geo-legend-area" /> Gemeentegrens</span>
|
||||
<span><i className="geo-legend-layer" /> {activeTheme.shortLabel}</span>
|
||||
<span><i className="geo-legend-selection" /> Selectie</span>
|
||||
</div>
|
||||
{bboxSelectionMode ? (
|
||||
<div className="geo-draw-instruction" role="status">
|
||||
<strong>Rechthoek tekenen</strong>
|
||||
<span>Houd de linkermuisknop ingedrukt, sleep over het gewenste gebied en laat los.</span>
|
||||
</div>
|
||||
) : null}
|
||||
{viewportVectorEnabled && viewportVectorStatus ? (
|
||||
<div className={`geo-viewport-status geo-viewport-status-${viewportVectorTone}`} role={viewportVectorTone === 'error' ? 'alert' : 'status'}>
|
||||
{viewportVectorStatus}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="geo-results-panel" aria-label="Gebiedsanalyse">
|
||||
<div className="geo-panel-heading">
|
||||
<span>3</span>
|
||||
<div>
|
||||
<h3>Resultaten</h3>
|
||||
<p>Alleen gemeten gegevens uit beschikbare bronnen.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!mapSelectionBbox ? (
|
||||
<div className="geo-results-empty">
|
||||
<strong>Nog geen gebied geselecteerd</strong>
|
||||
<p>Teken een rechthoek op de kaart. De analyse start automatisch zodra je loslaat.</p>
|
||||
</div>
|
||||
) : mapSelectionLoading || themeResultsLoading ? (
|
||||
<div className="geo-results-loading" role="status">
|
||||
<span />
|
||||
<strong>Gegevens worden uit PostGIS gelezen…</strong>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="geo-primary-metrics">
|
||||
<div>
|
||||
<span>Oppervlakte selectie</span>
|
||||
<strong>{formatArea(selectedAreaSquareMetres)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{activeTheme.shortLabel}</span>
|
||||
<strong>{mapSelectionResult ? resultCountLabel(mapSelectionResult) : 'Geen resultaat'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Dichtheid</span>
|
||||
<strong>{selectedDensity === null ? 'n.v.t.' : `${selectedDensity.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} / km2`}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="geo-theme-results">
|
||||
<div className="geo-results-title-row">
|
||||
<h4>Alle beschikbare thema’s</h4>
|
||||
<span>{themeResults.length} bevraagd</span>
|
||||
</div>
|
||||
{DATA_THEMES.map((theme) => {
|
||||
const dataset = themeDatasetMap[theme.id]
|
||||
const item = themeResults.find((result) => result.theme.id === theme.id)
|
||||
return (
|
||||
<div className="geo-theme-result-row" key={theme.id}>
|
||||
<span className={`geo-theme-symbol geo-theme-symbol-${theme.id}`} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>{theme.label}</strong>
|
||||
<small>{dataset?.source_name ?? dataset?.source ?? 'Geen bron gekoppeld'}</small>
|
||||
</span>
|
||||
<b>{item ? resultCountLabel(item.result) : dataset ? 'Niet bevraagd' : 'Bron ontbreekt'}</b>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{mapSelectionResult?.truncated ? (
|
||||
<p className="geo-data-notice">De telling is volledig; op de kaart en in de tabel worden maximaal {mapSelectionResult.limit.toLocaleString('nl-BE')} objecten getoond.</p>
|
||||
) : null}
|
||||
{mapSelectionError ? <p className="error">{mapSelectionError}</p> : null}
|
||||
{themeResultsError ? <p className="error">{themeResultsError}</p> : null}
|
||||
|
||||
{selectedResultProperties.length > 0 ? (
|
||||
<details className="geo-result-details">
|
||||
<summary>Kenmerken van de gevonden objecten</summary>
|
||||
<dl>
|
||||
{selectedResultProperties.map(({ key, values }) => (
|
||||
<div key={key}>
|
||||
<dt>{readablePropertyName(key)}</dt>
|
||||
<dd>{values.join(', ')}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</details>
|
||||
) : null}
|
||||
|
||||
{selectedMapFeature ? (
|
||||
<div className="geo-selected-feature">
|
||||
<span>Geselecteerd object</span>
|
||||
<strong>{String(selectedMapFeature.properties?.['name'] ?? selectedMapFeature.properties?.['source_feature_id'] ?? selectedMapFeature.id ?? 'Object')}</strong>
|
||||
<small>Klik elders op de kaart om een ander object uit te lezen.</small>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="geo-result-actions">
|
||||
<button className="secondary-action" disabled={!mapSelectionResult} type="button" onClick={downloadAreaSelection}>Download GeoJSON</button>
|
||||
<button className="secondary-action" disabled={!mapSelectionResult} type="button" onClick={copyAreaSelection}>Kopieer gegevens</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<footer className="geo-explorer-footer">
|
||||
<span><strong>Werkgebied:</strong> {selectedMapArea?.name ?? 'Geen gemeentegrens geselecteerd'}</span>
|
||||
<span><strong>Bron:</strong> {activeThemeDataset ? `${activeThemeDataset.source_name ?? activeThemeDataset.source} · ${activeThemeDataset.name}` : 'niet beschikbaar'}</span>
|
||||
{usesDefaultOsmBasemap ? <span><strong>Ondergrond:</strong> OpenStreetMap</span> : null}
|
||||
</footer>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="map-workspace-shell" data-testid="map-workspace">
|
||||
<button className="secondary-action" type="button" onClick={() => setAdvancedMode(false)}>
|
||||
Terug naar gebiedsverkenner
|
||||
</button>
|
||||
<div className="panel-title-row">
|
||||
<div>
|
||||
<p className="eyebrow">Spatial review</p>
|
||||
|
||||
@@ -21,6 +21,9 @@ export function useMapSelectionExtract({
|
||||
|
||||
useEffect(() => {
|
||||
setMapSelectionBbox(null)
|
||||
}, [selectedProjectId])
|
||||
|
||||
useEffect(() => {
|
||||
setMapSelectionResult(null)
|
||||
setMapSelectionError(null)
|
||||
}, [selectedProjectId, selectedDataset?.id])
|
||||
@@ -41,7 +44,7 @@ export function useMapSelectionExtract({
|
||||
try {
|
||||
const response = await datasetsApi.selectVectorFeatures(selectedProjectId, selectedDataset.id, {
|
||||
bbox: { ...bbox, crs: 'EPSG:4326' },
|
||||
limit: 250,
|
||||
limit: 1000,
|
||||
})
|
||||
setMapSelectionResult(response)
|
||||
return response
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { formatError } from '../lib/formatError'
|
||||
import { datasetsApi } from '../services/api/datasets'
|
||||
import type { DatasetCreateResponse, VectorSelectionBBox, VectorSelectionResponse } from '../types'
|
||||
|
||||
export interface MapThemeQuery<TThemeId extends string> {
|
||||
themeId: TThemeId
|
||||
dataset: DatasetCreateResponse
|
||||
}
|
||||
|
||||
export interface MapThemeInsight<TThemeId extends string> extends MapThemeQuery<TThemeId> {
|
||||
result: VectorSelectionResponse
|
||||
}
|
||||
|
||||
export function useMapThemeSelectionInsights<TThemeId extends string>(
|
||||
selectedProjectId: string | null,
|
||||
) {
|
||||
const [themeInsights, setThemeInsights] = useState<Array<MapThemeInsight<TThemeId>>>([])
|
||||
const [themeInsightsLoading, setThemeInsightsLoading] = useState(false)
|
||||
const [themeInsightsError, setThemeInsightsError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setThemeInsights([])
|
||||
setThemeInsightsError(null)
|
||||
}, [selectedProjectId])
|
||||
|
||||
const clearThemeInsights = () => {
|
||||
setThemeInsights([])
|
||||
setThemeInsightsError(null)
|
||||
}
|
||||
|
||||
const loadThemeInsights = async (
|
||||
bbox: VectorSelectionBBox,
|
||||
queries: Array<MapThemeQuery<TThemeId>>,
|
||||
): Promise<Array<MapThemeInsight<TThemeId>>> => {
|
||||
if (!selectedProjectId) {
|
||||
setThemeInsights([])
|
||||
setThemeInsightsError('Open eerst een project om de selectie te analyseren.')
|
||||
return []
|
||||
}
|
||||
|
||||
setThemeInsightsLoading(true)
|
||||
setThemeInsightsError(null)
|
||||
try {
|
||||
const settled = await Promise.allSettled(
|
||||
queries.map(async ({ themeId, dataset }) => ({
|
||||
themeId,
|
||||
dataset,
|
||||
result: await datasetsApi.selectVectorFeatures(selectedProjectId, dataset.id, {
|
||||
bbox,
|
||||
limit: 1000,
|
||||
}),
|
||||
})),
|
||||
)
|
||||
const successful = settled.flatMap((item) => (item.status === 'fulfilled' ? [item.value] : []))
|
||||
const failureCount = settled.length - successful.length
|
||||
setThemeInsights(successful)
|
||||
if (failureCount > 0) {
|
||||
setThemeInsightsError(
|
||||
`${failureCount} beschikbare databron${failureCount === 1 ? '' : 'nen'} kon niet worden bevraagd.`,
|
||||
)
|
||||
}
|
||||
return successful
|
||||
} catch (error) {
|
||||
setThemeInsights([])
|
||||
setThemeInsightsError(formatError(error, 'De gebiedsanalyse is mislukt.'))
|
||||
return []
|
||||
} finally {
|
||||
setThemeInsightsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
themeInsights,
|
||||
themeInsightsLoading,
|
||||
themeInsightsError,
|
||||
loadThemeInsights,
|
||||
clearThemeInsights,
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,17 @@ export function useMapWorkspaceState({
|
||||
if (areas.length === 0) {
|
||||
setSelectedMapAreaId('')
|
||||
} else if (!selectedMapAreaId || !areas.some((area) => area.id === selectedMapAreaId)) {
|
||||
setSelectedMapAreaId(areas[0].id)
|
||||
const municipalityArea = areas.find((area) => /gemeente mol|municipality/i.test(area.name))
|
||||
if (municipalityArea) {
|
||||
setSelectedMapAreaId(municipalityArea.id)
|
||||
} else {
|
||||
const largestArea = [...areas].sort((left, right) => (right.area_m2 ?? 0) - (left.area_m2 ?? 0))[0]
|
||||
if (largestArea?.area_m2) {
|
||||
setSelectedMapAreaId(largestArea.id)
|
||||
} else {
|
||||
setSelectedMapAreaId(areas[0].id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [areas, selectedMapAreaId])
|
||||
|
||||
|
||||
@@ -5412,6 +5412,699 @@ section {
|
||||
}
|
||||
}
|
||||
|
||||
/* Map-first geographic explorer: one calm path from theme to selection to evidence. */
|
||||
.geo-explorer {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.geo-explorer-header {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
border-bottom: 1px solid #d7e0dc;
|
||||
padding: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.geo-explorer-header h2 {
|
||||
margin: 0.12rem 0 0;
|
||||
color: #17211e;
|
||||
font-size: clamp(1.35rem, 2vw, 1.85rem);
|
||||
letter-spacing: 0;
|
||||
line-height: 1.12;
|
||||
}
|
||||
|
||||
.geo-explorer-header p:last-child {
|
||||
max-width: 48rem;
|
||||
margin: 0.32rem 0 0;
|
||||
color: #5a6964;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.geo-explorer-advanced {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.geo-explorer-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(15.5rem, 17rem) minmax(30rem, 1fr) minmax(18rem, 20rem);
|
||||
gap: 0.72rem;
|
||||
align-items: stretch;
|
||||
min-width: 0;
|
||||
min-height: calc(100dvh - 13.8rem);
|
||||
}
|
||||
|
||||
.geo-theme-panel,
|
||||
.geo-results-panel,
|
||||
.geo-map-stage {
|
||||
min-width: 0;
|
||||
border: 1px solid #d7e0dc;
|
||||
border-radius: 7px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 1px 2px rgba(23, 33, 30, 0.05);
|
||||
}
|
||||
|
||||
.geo-theme-panel,
|
||||
.geo-results-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.7rem;
|
||||
padding: 0.78rem;
|
||||
}
|
||||
|
||||
.geo-panel-heading {
|
||||
display: grid;
|
||||
grid-template-columns: 1.65rem minmax(0, 1fr);
|
||||
gap: 0.55rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.geo-panel-heading > span {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 1.65rem;
|
||||
height: 1.65rem;
|
||||
border-radius: 5px;
|
||||
background: #173e38;
|
||||
color: #ffffff;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.geo-panel-heading h3,
|
||||
.geo-results-title-row h4 {
|
||||
margin: 0;
|
||||
color: #17211e;
|
||||
font-size: 0.9rem;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.geo-panel-heading p {
|
||||
margin: 0.16rem 0 0;
|
||||
color: #6a7773;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.geo-theme-list {
|
||||
display: grid;
|
||||
gap: 0.36rem;
|
||||
}
|
||||
|
||||
.geo-theme-option {
|
||||
display: grid;
|
||||
grid-template-columns: 0.72rem minmax(0, 1fr) auto;
|
||||
gap: 0.48rem;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 3.25rem;
|
||||
border: 1px solid #e0e7e4;
|
||||
border-radius: 6px;
|
||||
padding: 0.48rem 0.52rem;
|
||||
background: #ffffff;
|
||||
color: #26332f;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.geo-theme-option:not(:disabled):hover {
|
||||
border-color: #8eb8ac;
|
||||
background: #f5faf8;
|
||||
}
|
||||
|
||||
.geo-theme-option-active {
|
||||
border-color: #397c6e;
|
||||
background: #edf7f4;
|
||||
box-shadow: inset 3px 0 0 #176a5c;
|
||||
}
|
||||
|
||||
.geo-theme-option:disabled {
|
||||
cursor: not-allowed;
|
||||
background: #f6f8f7;
|
||||
color: #7c8884;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.geo-theme-option > span:nth-child(2) {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.geo-theme-option strong,
|
||||
.geo-theme-option small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.geo-theme-option strong {
|
||||
font-size: 0.79rem;
|
||||
}
|
||||
|
||||
.geo-theme-option small {
|
||||
margin-top: 0.12rem;
|
||||
color: #65736e;
|
||||
font-size: 0.66rem;
|
||||
}
|
||||
|
||||
.geo-theme-option i {
|
||||
color: #357061;
|
||||
font-size: 0.58rem;
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.geo-theme-option:disabled i {
|
||||
color: #89938f;
|
||||
}
|
||||
|
||||
.geo-theme-symbol {
|
||||
display: block;
|
||||
width: 0.6rem;
|
||||
height: 1.85rem;
|
||||
border-radius: 2px;
|
||||
background: #5c7c73;
|
||||
}
|
||||
|
||||
.geo-theme-symbol-buildings { background: #d45f3d; }
|
||||
.geo-theme-symbol-population { background: #7559a6; }
|
||||
.geo-theme-symbol-forest { background: #347950; }
|
||||
.geo-theme-symbol-water { background: #2676a8; }
|
||||
.geo-theme-symbol-roads { background: #6b7280; }
|
||||
.geo-theme-symbol-parcels { background: #a7792f; }
|
||||
|
||||
.geo-source-summary {
|
||||
display: grid;
|
||||
gap: 0.18rem;
|
||||
border-top: 1px solid #e3e9e6;
|
||||
padding-top: 0.65rem;
|
||||
}
|
||||
|
||||
.geo-source-summary span,
|
||||
.geo-primary-metrics span,
|
||||
.geo-selected-feature > span {
|
||||
color: #6a7773;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.geo-source-summary strong {
|
||||
overflow: hidden;
|
||||
color: #26332f;
|
||||
font-size: 0.76rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.geo-source-summary small {
|
||||
color: #697671;
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.geo-scope-select {
|
||||
margin-top: auto;
|
||||
color: #4c5b56;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.geo-scope-select select {
|
||||
min-height: 2.35rem;
|
||||
margin-top: 0.28rem;
|
||||
}
|
||||
|
||||
.geo-map-stage {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(30rem, 1fr);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.geo-map-toolbar {
|
||||
display: flex;
|
||||
gap: 0.7rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid #d7e0dc;
|
||||
padding: 0.62rem 0.7rem;
|
||||
background: #fbfcfc;
|
||||
}
|
||||
|
||||
.geo-map-step {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.geo-map-actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.geo-map-actions button,
|
||||
.geo-result-actions button {
|
||||
min-height: 2.15rem;
|
||||
padding: 0.4rem 0.58rem;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.geo-draw-active {
|
||||
background: #9a4d24;
|
||||
}
|
||||
|
||||
.geo-map-canvas {
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
background: #e7ece9;
|
||||
}
|
||||
|
||||
.geo-map-canvas .map-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 30rem;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.geo-map-canvas-drawing .map-container {
|
||||
box-shadow: inset 0 0 0 3px #b5572d;
|
||||
}
|
||||
|
||||
.geo-map-legend {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
right: 0.65rem;
|
||||
bottom: 0.65rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.55rem;
|
||||
align-items: center;
|
||||
border: 1px solid rgba(23, 33, 30, 0.18);
|
||||
border-radius: 5px;
|
||||
padding: 0.38rem 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
color: #42504b;
|
||||
font-size: 0.64rem;
|
||||
box-shadow: 0 2px 8px rgba(23, 33, 30, 0.1);
|
||||
}
|
||||
|
||||
.geo-map-legend span {
|
||||
display: inline-flex;
|
||||
gap: 0.3rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.geo-map-legend i {
|
||||
display: inline-block;
|
||||
width: 0.9rem;
|
||||
height: 0.55rem;
|
||||
border: 2px solid #176a5c;
|
||||
background: rgba(23, 106, 92, 0.15);
|
||||
}
|
||||
|
||||
.geo-map-legend .geo-legend-layer {
|
||||
border-color: #d45f3d;
|
||||
background: rgba(212, 95, 61, 0.24);
|
||||
}
|
||||
|
||||
.geo-map-legend .geo-legend-selection {
|
||||
border-color: #6b4aaa;
|
||||
background: rgba(107, 74, 170, 0.18);
|
||||
}
|
||||
|
||||
.geo-draw-instruction,
|
||||
.geo-viewport-status {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
left: 50%;
|
||||
display: grid;
|
||||
width: min(31rem, calc(100% - 2rem));
|
||||
transform: translateX(-50%);
|
||||
border-radius: 6px;
|
||||
padding: 0.58rem 0.72rem;
|
||||
box-shadow: 0 4px 16px rgba(23, 33, 30, 0.18);
|
||||
}
|
||||
|
||||
.geo-draw-instruction {
|
||||
top: 0.72rem;
|
||||
border: 1px solid #d8936c;
|
||||
background: rgba(255, 248, 242, 0.96);
|
||||
color: #6f3218;
|
||||
}
|
||||
|
||||
.geo-draw-instruction strong,
|
||||
.geo-draw-instruction span {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.geo-viewport-status {
|
||||
bottom: 2.8rem;
|
||||
border: 1px solid #b7ccc5;
|
||||
background: rgba(248, 252, 250, 0.95);
|
||||
color: #38534b;
|
||||
font-size: 0.68rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.geo-viewport-status-error {
|
||||
border-color: #e3a6a6;
|
||||
background: rgba(255, 246, 246, 0.96);
|
||||
color: #8b2d2d;
|
||||
}
|
||||
|
||||
.geo-results-empty,
|
||||
.geo-results-loading {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
flex: 1 1 auto;
|
||||
min-height: 15rem;
|
||||
border: 1px dashed #ccd7d3;
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
background: #fafcfa;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.geo-results-empty strong,
|
||||
.geo-results-loading strong {
|
||||
color: #31413b;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.geo-results-empty p {
|
||||
max-width: 15rem;
|
||||
margin: 0.35rem auto 0;
|
||||
color: #6a7773;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.geo-results-loading span {
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
margin: 0 auto 0.6rem;
|
||||
border: 2px solid #c6d6d0;
|
||||
border-top-color: #176a5c;
|
||||
border-radius: 50%;
|
||||
animation: geo-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes geo-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.geo-primary-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.geo-primary-metrics > div {
|
||||
display: grid;
|
||||
gap: 0.18rem;
|
||||
min-width: 0;
|
||||
border: 1px solid #e1e8e5;
|
||||
border-radius: 5px;
|
||||
padding: 0.48rem;
|
||||
background: #f8faf9;
|
||||
}
|
||||
|
||||
.geo-primary-metrics strong {
|
||||
overflow: hidden;
|
||||
color: #1e302a;
|
||||
font-size: 0.86rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.geo-theme-results {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
border: 1px solid #e1e8e5;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.geo-results-title-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.48rem 0.55rem;
|
||||
background: #f4f7f5;
|
||||
}
|
||||
|
||||
.geo-results-title-row span {
|
||||
color: #697671;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.geo-theme-result-row {
|
||||
display: grid;
|
||||
grid-template-columns: 0.55rem minmax(0, 1fr) auto;
|
||||
gap: 0.45rem;
|
||||
align-items: center;
|
||||
min-height: 2.65rem;
|
||||
border-top: 1px solid #e7ecea;
|
||||
padding: 0.38rem 0.5rem;
|
||||
}
|
||||
|
||||
.geo-theme-result-row .geo-theme-symbol {
|
||||
width: 0.45rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.geo-theme-result-row > span:nth-child(2) {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.geo-theme-result-row strong {
|
||||
font-size: 0.73rem;
|
||||
}
|
||||
|
||||
.geo-theme-result-row small {
|
||||
overflow: hidden;
|
||||
color: #71807a;
|
||||
font-size: 0.61rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.geo-theme-result-row b {
|
||||
color: #24342e;
|
||||
font-size: 0.7rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.geo-data-notice {
|
||||
margin: 0;
|
||||
border-left: 3px solid #b17a31;
|
||||
padding: 0.45rem 0.55rem;
|
||||
background: #fffbeb;
|
||||
color: #76501d;
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.geo-result-details {
|
||||
border: 1px solid #e1e8e5;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.geo-result-details summary {
|
||||
padding: 0.5rem 0.55rem;
|
||||
color: #40514b;
|
||||
cursor: pointer;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.geo-result-details dl {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
margin: 0;
|
||||
border-top: 1px solid #e7ecea;
|
||||
}
|
||||
|
||||
.geo-result-details dl > div {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(6rem, 0.8fr) minmax(0, 1.2fr);
|
||||
gap: 0.5rem;
|
||||
border-top: 1px solid #edf1ef;
|
||||
padding: 0.36rem 0.5rem;
|
||||
}
|
||||
|
||||
.geo-result-details dl > div:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.geo-result-details dt,
|
||||
.geo-result-details dd {
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.geo-result-details dt {
|
||||
color: #66736f;
|
||||
}
|
||||
|
||||
.geo-result-details dd {
|
||||
margin: 0;
|
||||
color: #283832;
|
||||
}
|
||||
|
||||
.geo-selected-feature {
|
||||
display: grid;
|
||||
gap: 0.16rem;
|
||||
border-left: 3px solid #a7792f;
|
||||
padding: 0.48rem 0.55rem;
|
||||
background: #fffaf0;
|
||||
}
|
||||
|
||||
.geo-selected-feature strong {
|
||||
overflow-wrap: anywhere;
|
||||
color: #4d3d22;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.geo-selected-feature small {
|
||||
color: #786a51;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.geo-result-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.35rem;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.geo-explorer-footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem 1rem;
|
||||
align-items: center;
|
||||
color: #687570;
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.geo-explorer-footer strong {
|
||||
color: #45534f;
|
||||
}
|
||||
|
||||
@media (min-width: 1800px) {
|
||||
.geo-explorer-layout {
|
||||
grid-template-columns: minmax(17rem, 19rem) minmax(38rem, 1fr) minmax(20rem, 23rem);
|
||||
min-height: calc(100dvh - 14.2rem);
|
||||
}
|
||||
|
||||
.geo-map-canvas .map-container {
|
||||
min-height: 38rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1320px) {
|
||||
.geo-explorer-layout {
|
||||
grid-template-columns: minmax(14rem, 16rem) minmax(28rem, 1fr);
|
||||
}
|
||||
|
||||
.geo-results-panel {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(12rem, 0.6fr) repeat(2, minmax(15rem, 1fr));
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.geo-results-panel > .geo-panel-heading,
|
||||
.geo-results-panel > .error,
|
||||
.geo-results-panel > .geo-data-notice,
|
||||
.geo-results-panel > .geo-result-actions {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.geo-results-empty,
|
||||
.geo-results-loading {
|
||||
grid-column: 2 / -1;
|
||||
min-height: 9rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.geo-explorer-header {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.geo-explorer-layout {
|
||||
display: block;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.geo-theme-panel,
|
||||
.geo-map-stage,
|
||||
.geo-results-panel {
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
|
||||
.geo-theme-list {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.geo-map-toolbar {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.geo-map-actions {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.geo-results-panel {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.geo-explorer-header {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.geo-explorer-advanced {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.geo-theme-list,
|
||||
.geo-primary-metrics {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.geo-map-toolbar {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.geo-map-actions,
|
||||
.geo-result-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.geo-map-actions button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.geo-map-canvas .map-container {
|
||||
min-height: 24rem;
|
||||
}
|
||||
|
||||
.geo-map-legend {
|
||||
right: 0.4rem;
|
||||
bottom: 0.4rem;
|
||||
left: 0.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.workspace-grid-data,
|
||||
.map-inspection-surface {
|
||||
|
||||
@@ -309,6 +309,7 @@ export interface VectorSelectionDeriveRequest extends VectorSelectionRequest {
|
||||
export interface VectorSelectionResponse {
|
||||
selection_bbox: VectorSelectionBBox
|
||||
feature_count: number
|
||||
total_feature_count?: number | null
|
||||
limit: number
|
||||
truncated: boolean
|
||||
geojson: GeoJSON.FeatureCollection
|
||||
|
||||
@@ -1196,6 +1196,26 @@ without changing application persistence. The internal API default is
|
||||
import; override it with `--base-url` when running outside the all-in-one
|
||||
container.
|
||||
|
||||
After the municipality boundary/building workspace exists, provision the
|
||||
official GRB road, water and parcel context layers:
|
||||
|
||||
```bash
|
||||
docker exec -it geointel python3 \
|
||||
/app/scripts/provision_mol_context_layers.py
|
||||
```
|
||||
|
||||
The command reads `Wegsegment` for roads, `WTZ`/`WLAS`/`WGR` for water and
|
||||
`ADP` for parcels from the Digitaal Vlaanderen OGC API, clips every geometry
|
||||
to the persisted official Mol boundary and uploads each artifact through the
|
||||
normal dataset API. Artifacts and manifests are retained below
|
||||
`/app/storage/operator-data/mol-context`. Repeat runs reuse both artifacts and
|
||||
datasets; use `--force` only for an explicit source refresh. Use
|
||||
`--layers roads,water` or `--fetch-only` for a bounded operator run.
|
||||
|
||||
The context provisioner does not add population or forest values. Those themes
|
||||
remain visibly unavailable until an authoritative/statistically appropriate
|
||||
source is imported; zero is never substituted for missing source data.
|
||||
|
||||
## Tower deployment
|
||||
|
||||
Push the local branch to Gitea, then rebuild the Unraid/Tower Docker runtime:
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
"""Provision official Mol roads, water and parcel context layers.
|
||||
|
||||
This explicit operator command reads Digitaal Vlaanderen OGC API Features,
|
||||
clips every feature to the official Mol municipality boundary and imports the
|
||||
result through the existing GeoIntel dataset upload API. It is idempotent and
|
||||
never runs automatically during application startup.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
from shapely.geometry import mapping, shape
|
||||
from shapely.validation import make_valid
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
|
||||
MUNICIPALITY_NAME = "Mol"
|
||||
MUNICIPALITY_NIS_CODE = "13025"
|
||||
PROJECT_NAME = "Mol Municipality Workbench"
|
||||
AREA_NAME = "Gemeente Mol - officiele grens"
|
||||
BOUNDARY_URL = "https://geo.api.vlaanderen.be/VRBG/ogc/features/v1/collections/Refgem/items"
|
||||
GRB_COLLECTION_URL = "https://geo.api.vlaanderen.be/GRB/ogc/features/v1/collections/{collection}/items"
|
||||
ATTRIBUTION = "Bron: Grootschalig Referentie Bestand Vlaanderen, Digitaal Vlaanderen"
|
||||
DEFAULT_OUTPUT_DIR = Path("/app/storage/operator-data/mol-context")
|
||||
DEFAULT_BOUNDARY_PATH = Path("/app/storage/operator-data/mol-municipality/mol_municipality_boundary.geojson")
|
||||
DEFAULT_API_URL = "http://127.0.0.1:8000"
|
||||
RETRYABLE_STATUS_CODES = (429, 500, 502, 503, 504)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LayerDefinition:
|
||||
key: str
|
||||
filename: str
|
||||
collections: tuple[str, ...]
|
||||
reference_layer_name: str
|
||||
layer_type: str
|
||||
|
||||
|
||||
LAYERS = (
|
||||
LayerDefinition("roads", "mol_grb_roads.geojson", ("Wegsegment",), "roads", "road"),
|
||||
LayerDefinition("water", "mol_grb_water.geojson", ("WTZ", "WLAS", "WGR"), "water", "water"),
|
||||
LayerDefinition("parcels", "mol_grb_parcels.geojson", ("ADP",), "parcels", "parcel"),
|
||||
)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Provision official GRB context layers for the municipality of Mol.")
|
||||
parser.add_argument("--base-url", default=os.environ.get("GEOINTEL_INTERNAL_API_URL", DEFAULT_API_URL))
|
||||
parser.add_argument("--project-name", default=PROJECT_NAME)
|
||||
parser.add_argument("--output-dir", type=Path, default=Path(os.environ.get("MOL_CONTEXT_OUTPUT_DIR", DEFAULT_OUTPUT_DIR)))
|
||||
parser.add_argument("--boundary-path", type=Path, default=Path(os.environ.get("MOL_BOUNDARY_PATH", DEFAULT_BOUNDARY_PATH)))
|
||||
parser.add_argument("--page-limit", type=int, default=int(os.environ.get("MOL_CONTEXT_PAGE_LIMIT", "1000")))
|
||||
parser.add_argument("--max-features", type=int, default=int(os.environ.get("MOL_CONTEXT_MAX_FEATURES", "150000")))
|
||||
parser.add_argument("--request-timeout", type=int, default=int(os.environ.get("MOL_CONTEXT_REQUEST_TIMEOUT", "180")))
|
||||
parser.add_argument("--import-timeout", type=int, default=int(os.environ.get("MOL_CONTEXT_IMPORT_TIMEOUT", "1800")))
|
||||
parser.add_argument("--layers", default="roads,water,parcels", help="Comma-separated subset: roads,water,parcels")
|
||||
parser.add_argument("--force", action="store_true", help="Refetch artifacts even when a complete artifact exists.")
|
||||
parser.add_argument("--fetch-only", action="store_true")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def build_session() -> requests.Session:
|
||||
retry = Retry(
|
||||
total=5,
|
||||
connect=5,
|
||||
read=5,
|
||||
status=5,
|
||||
backoff_factor=1.0,
|
||||
status_forcelist=RETRYABLE_STATUS_CODES,
|
||||
allowed_methods=frozenset({"GET"}),
|
||||
raise_on_status=True,
|
||||
)
|
||||
session = requests.Session()
|
||||
session.headers.update({"User-Agent": "GeoIntel-Mol-Context-Operator/1.0"})
|
||||
adapter = HTTPAdapter(max_retries=retry)
|
||||
session.mount("https://", adapter)
|
||||
session.mount("http://", adapter)
|
||||
return session
|
||||
|
||||
|
||||
def next_page_url(payload: dict[str, Any]) -> str | None:
|
||||
for link in payload.get("links") or []:
|
||||
if link.get("rel") == "next" and link.get("href"):
|
||||
return str(link["href"])
|
||||
return None
|
||||
|
||||
|
||||
def normalized_geometry(payload: dict[str, Any] | None):
|
||||
if not payload:
|
||||
return None
|
||||
geometry = shape(payload)
|
||||
if geometry.is_empty:
|
||||
return None
|
||||
if not geometry.is_valid:
|
||||
geometry = make_valid(geometry)
|
||||
return geometry if not geometry.is_empty and geometry.is_valid else None
|
||||
|
||||
|
||||
def geometry_dimension(geometry) -> int:
|
||||
geometry_type = geometry.geom_type
|
||||
if "Polygon" in geometry_type:
|
||||
return 2
|
||||
if "LineString" in geometry_type or geometry_type == "LinearRing":
|
||||
return 1
|
||||
if "Point" in geometry_type:
|
||||
return 0
|
||||
if geometry_type == "GeometryCollection":
|
||||
return max((geometry_dimension(part) for part in geometry.geoms), default=-1)
|
||||
return -1
|
||||
|
||||
|
||||
def load_boundary(path: Path, session: requests.Session, timeout: int):
|
||||
if path.exists():
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
features = payload.get("features") or []
|
||||
if len(features) == 1:
|
||||
boundary = normalized_geometry(features[0].get("geometry"))
|
||||
if boundary is not None:
|
||||
return boundary
|
||||
|
||||
response = session.get(
|
||||
BOUNDARY_URL,
|
||||
params={"f": "application/geo+json", "limit": "10", "filter": "NAAM='Mol'", "filter-lang": "cql2-text"},
|
||||
timeout=timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
matches = [
|
||||
feature
|
||||
for feature in response.json().get("features") or []
|
||||
if str((feature.get("properties") or {}).get("NISCODE")) == MUNICIPALITY_NIS_CODE
|
||||
]
|
||||
if len(matches) != 1:
|
||||
raise RuntimeError(f"Expected one official Mol boundary, received {len(matches)}")
|
||||
boundary = normalized_geometry(matches[0].get("geometry"))
|
||||
if boundary is None:
|
||||
raise RuntimeError("Official Mol boundary is invalid")
|
||||
return boundary
|
||||
|
||||
|
||||
def iter_collection_pages(
|
||||
session: requests.Session,
|
||||
collection: str,
|
||||
bbox: tuple[float, float, float, float],
|
||||
*,
|
||||
page_limit: int,
|
||||
timeout: int,
|
||||
) -> Iterable[tuple[dict[str, Any], str]]:
|
||||
url: str | None = GRB_COLLECTION_URL.format(collection=collection)
|
||||
params = {
|
||||
"f": "application/geo+json",
|
||||
"limit": str(page_limit),
|
||||
"bbox": ",".join(f"{value:.8f}" for value in bbox),
|
||||
}
|
||||
first = True
|
||||
seen: set[str] = set()
|
||||
while url:
|
||||
if url in seen:
|
||||
raise RuntimeError(f"Pagination loop for GRB collection {collection}")
|
||||
seen.add(url)
|
||||
response = session.get(url, params=params if first else None, timeout=timeout)
|
||||
first = False
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
yield payload, response.url
|
||||
url = next_page_url(payload)
|
||||
|
||||
|
||||
def build_layer(
|
||||
definition: LayerDefinition,
|
||||
boundary,
|
||||
session: requests.Session,
|
||||
*,
|
||||
page_limit: int,
|
||||
max_features: int,
|
||||
timeout: int,
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
features: list[dict[str, Any]] = []
|
||||
source_urls: list[str] = []
|
||||
seen_ids: set[str] = set()
|
||||
rejected = 0
|
||||
clipped = 0
|
||||
|
||||
for collection in definition.collections:
|
||||
for page, source_url in iter_collection_pages(
|
||||
session,
|
||||
collection,
|
||||
boundary.bounds,
|
||||
page_limit=page_limit,
|
||||
timeout=timeout,
|
||||
):
|
||||
source_urls.append(source_url)
|
||||
for source_feature in page.get("features") or []:
|
||||
raw_id = str(source_feature.get("id") or "")
|
||||
feature_id = f"{collection}:{raw_id}" if raw_id else hashlib.sha256(
|
||||
json.dumps(source_feature.get("geometry"), sort_keys=True).encode("utf-8")
|
||||
).hexdigest()
|
||||
if feature_id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(feature_id)
|
||||
geometry = normalized_geometry(source_feature.get("geometry"))
|
||||
if geometry is None or not geometry.intersects(boundary):
|
||||
rejected += 1
|
||||
continue
|
||||
if not geometry.within(boundary):
|
||||
source_dimension = geometry_dimension(geometry)
|
||||
geometry = normalized_geometry(mapping(geometry.intersection(boundary)))
|
||||
if geometry is not None and geometry_dimension(geometry) < source_dimension:
|
||||
geometry = None
|
||||
clipped += 1
|
||||
if geometry is None:
|
||||
rejected += 1
|
||||
continue
|
||||
if len(features) >= max_features:
|
||||
raise RuntimeError(
|
||||
f"{definition.key} exceeds the {max_features} feature cap; refusing a truncated artifact"
|
||||
)
|
||||
properties = dict(source_feature.get("properties") or {})
|
||||
properties.update(
|
||||
{
|
||||
"source_name": "grb",
|
||||
"source_collection": collection,
|
||||
"source_feature_id": feature_id,
|
||||
"reference_layer_name": definition.reference_layer_name,
|
||||
"layer_type": definition.layer_type,
|
||||
"authority_level": "authoritative",
|
||||
"coverage_scope": "municipality",
|
||||
"municipality": MUNICIPALITY_NAME,
|
||||
"nis_code": MUNICIPALITY_NIS_CODE,
|
||||
"attribution": ATTRIBUTION,
|
||||
}
|
||||
)
|
||||
features.append(
|
||||
{"type": "Feature", "id": feature_id, "geometry": mapping(geometry), "properties": properties}
|
||||
)
|
||||
|
||||
if not features:
|
||||
raise RuntimeError(f"No {definition.key} features intersect Mol")
|
||||
generated_at = utc_now()
|
||||
payload = {
|
||||
"type": "FeatureCollection",
|
||||
"name": f"GRB {definition.key} - complete municipality Mol",
|
||||
"features": features,
|
||||
"source": f"Digitaal Vlaanderen GRB OGC API collections {', '.join(definition.collections)}",
|
||||
"attribution": ATTRIBUTION,
|
||||
"municipality": MUNICIPALITY_NAME,
|
||||
"nis_code": MUNICIPALITY_NIS_CODE,
|
||||
"coverage_scope": "municipality",
|
||||
"reference_truncated": False,
|
||||
"generated_at": generated_at,
|
||||
}
|
||||
summary = {
|
||||
"layer": definition.key,
|
||||
"collections": list(definition.collections),
|
||||
"feature_count": len(features),
|
||||
"rejected_count": rejected,
|
||||
"clipped_count": clipped,
|
||||
"pages_fetched": len(source_urls),
|
||||
"source_urls": source_urls,
|
||||
"generated_at": generated_at,
|
||||
"reference_truncated": False,
|
||||
}
|
||||
return payload, summary
|
||||
|
||||
|
||||
def response_data(response: requests.Response) -> Any:
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(f"GeoIntel API returned non-JSON ({response.status_code}): {response.text[:300]}") from exc
|
||||
if not response.ok:
|
||||
raise RuntimeError(f"GeoIntel API failed ({response.status_code}): {json.dumps(payload, ensure_ascii=False)[:800]}")
|
||||
if not isinstance(payload, dict) or "data" not in payload:
|
||||
raise RuntimeError("GeoIntel API response does not use the canonical data envelope")
|
||||
return payload["data"]
|
||||
|
||||
|
||||
def locate_workspace(session: requests.Session, base_url: str, project_name: str, timeout: int) -> tuple[str, str, list[dict[str, Any]]]:
|
||||
projects = response_data(session.get(f"{base_url}/api/v1/projects", params={"limit": 200}, timeout=timeout))
|
||||
project = next((item for item in projects.get("items") or [] if item.get("name") == project_name), None)
|
||||
if not project:
|
||||
raise RuntimeError(f"Project {project_name!r} is missing; run provision_mol_municipality_workspace.py first")
|
||||
project_id = str(project["id"])
|
||||
areas = response_data(session.get(f"{base_url}/api/v1/projects/{project_id}/areas", params={"limit": 200}, timeout=timeout))
|
||||
area_items = list(areas.get("items") or [])
|
||||
area = next((item for item in area_items if "gemeente mol" in str(item.get("name", "")).lower()), None)
|
||||
if not area:
|
||||
area = max(area_items, key=lambda item: float(item.get("area_m2") or 0), default=None)
|
||||
if not area:
|
||||
raise RuntimeError("Mol municipality area is missing")
|
||||
datasets = response_data(session.get(f"{base_url}/api/v1/projects/{project_id}/datasets", params={"limit": 200}, timeout=timeout))
|
||||
return project_id, str(area["id"]), list(datasets.get("items") or [])
|
||||
|
||||
|
||||
def upload_layer(
|
||||
session: requests.Session,
|
||||
base_url: str,
|
||||
project_id: str,
|
||||
area_id: str,
|
||||
definition: LayerDefinition,
|
||||
path: Path,
|
||||
summary: dict[str, Any],
|
||||
timeout: int,
|
||||
) -> dict[str, Any]:
|
||||
source_metadata = {
|
||||
"provider": "Digitaal Vlaanderen",
|
||||
"collections": list(definition.collections),
|
||||
"authority_level": "authoritative",
|
||||
"coverage_scope": "municipality",
|
||||
"municipality": MUNICIPALITY_NAME,
|
||||
"nis_code": MUNICIPALITY_NIS_CODE,
|
||||
"feature_count": summary["feature_count"],
|
||||
"attribution": ATTRIBUTION,
|
||||
}
|
||||
provenance_metadata = {
|
||||
"operator_tool": "provision_mol_context_layers.py",
|
||||
"operator_explicit_fetch": True,
|
||||
"generated_at": summary["generated_at"],
|
||||
"source_urls": summary["source_urls"],
|
||||
"reference_truncated": False,
|
||||
}
|
||||
with path.open("rb") as handle:
|
||||
response = session.post(
|
||||
f"{base_url}/api/v1/projects/{project_id}/datasets/upload",
|
||||
data={
|
||||
"dataset_type": "vector",
|
||||
"source": "operator_official_import",
|
||||
"dataset_role": "reference",
|
||||
"source_name": "grb",
|
||||
"reference_layer_name": definition.reference_layer_name,
|
||||
"source_metadata_json": json.dumps(source_metadata, ensure_ascii=False),
|
||||
"provenance_metadata_json": json.dumps(provenance_metadata, ensure_ascii=False),
|
||||
"area_id": area_id,
|
||||
},
|
||||
files={"file": (path.name, handle, "application/geo+json")},
|
||||
timeout=timeout,
|
||||
)
|
||||
return response_data(response)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
requested = {item.strip().lower() for item in args.layers.split(",") if item.strip()}
|
||||
selected = [definition for definition in LAYERS if definition.key in requested]
|
||||
unknown = requested - {definition.key for definition in LAYERS}
|
||||
if unknown or not selected:
|
||||
print(json.dumps({"status": "error", "message": f"Unsupported layers: {sorted(unknown)}"}), file=sys.stderr)
|
||||
return 2
|
||||
if args.page_limit <= 0 or args.max_features <= 0:
|
||||
print(json.dumps({"status": "error", "message": "Limits must be positive"}), file=sys.stderr)
|
||||
return 2
|
||||
|
||||
args.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
results: list[dict[str, Any]] = []
|
||||
try:
|
||||
with build_session() as source_session:
|
||||
boundary = load_boundary(args.boundary_path, source_session, args.request_timeout)
|
||||
prepared: list[tuple[LayerDefinition, Path, dict[str, Any]]] = []
|
||||
for definition in selected:
|
||||
path = args.output_dir / definition.filename
|
||||
summary_path = path.with_suffix(".manifest.json")
|
||||
if not args.force and path.exists() and summary_path.exists():
|
||||
summary = json.loads(summary_path.read_text(encoding="utf-8"))
|
||||
else:
|
||||
payload, summary = build_layer(
|
||||
definition,
|
||||
boundary,
|
||||
source_session,
|
||||
page_limit=args.page_limit,
|
||||
max_features=args.max_features,
|
||||
timeout=args.request_timeout,
|
||||
)
|
||||
path.write_text(json.dumps(payload, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
|
||||
summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
prepared.append((definition, path, summary))
|
||||
|
||||
if args.fetch_only:
|
||||
results = [
|
||||
{"layer": definition.key, "path": str(path), "feature_count": summary["feature_count"], "status": "prepared"}
|
||||
for definition, path, summary in prepared
|
||||
]
|
||||
else:
|
||||
base_url = args.base_url.rstrip("/")
|
||||
with requests.Session() as api_session:
|
||||
project_id, area_id, existing = locate_workspace(
|
||||
api_session, base_url, args.project_name, args.import_timeout
|
||||
)
|
||||
for definition, path, summary in prepared:
|
||||
dataset = next((item for item in existing if item.get("original_filename") == path.name), None)
|
||||
if dataset:
|
||||
results.append(
|
||||
{"layer": definition.key, "dataset_id": dataset["id"], "feature_count": dataset.get("feature_count"), "status": "existing"}
|
||||
)
|
||||
continue
|
||||
dataset = upload_layer(
|
||||
api_session,
|
||||
base_url,
|
||||
project_id,
|
||||
area_id,
|
||||
definition,
|
||||
path,
|
||||
summary,
|
||||
args.import_timeout,
|
||||
)
|
||||
results.append(
|
||||
{"layer": definition.key, "dataset_id": dataset["id"], "feature_count": dataset.get("feature_count"), "status": "imported"}
|
||||
)
|
||||
except (OSError, RuntimeError, requests.RequestException, ValueError, KeyError) as exc:
|
||||
print(json.dumps({"status": "error", "message": str(exc)}, ensure_ascii=False), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(json.dumps({"status": "ok", "municipality": MUNICIPALITY_NAME, "layers": results}, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -43,6 +43,7 @@ ${PYTHON_BIN} -m py_compile scripts/yolo_preflight.py
|
||||
${PYTHON_BIN} -m py_compile backend/scripts/yolo_preflight.py
|
||||
${PYTHON_BIN} -m py_compile scripts/prepare_operator_real_data_samples.py
|
||||
${PYTHON_BIN} -m py_compile scripts/provision_mol_municipality_workspace.py
|
||||
${PYTHON_BIN} -m py_compile scripts/provision_mol_context_layers.py
|
||||
${PYTHON_BIN} -m py_compile scripts/export_operator_yolo_dataset.py
|
||||
${PYTHON_BIN} -m py_compile scripts/export_operator_yolo_tile_dataset.py
|
||||
${PYTHON_BIN} -m py_compile scripts/audit_operator_yolo_dataset_quality.py
|
||||
|
||||
Reference in New Issue
Block a user