diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fd1be3d..52cdff14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/backend/README.md b/backend/README.md index a91ae53a..61957897 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 diff --git a/backend/app/api/routes/datasets.py b/backend/app/api/routes/datasets.py index 889ad8d1..80843f72 100644 --- a/backend/app/api/routes/datasets.py +++ b/backend/app/api/routes/datasets.py @@ -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) diff --git a/backend/app/schemas/operations.py b/backend/app/schemas/operations.py index d2c3dc30..199cf50f 100644 --- a/backend/app/schemas/operations.py +++ b/backend/app/schemas/operations.py @@ -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 diff --git a/backend/app/services/vector_feature_service.py b/backend/app/services/vector_feature_service.py index 6cb3a598..02ce54c5 100644 --- a/backend/app/services/vector_feature_service.py +++ b/backend/app/services/vector_feature_service.py @@ -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": { diff --git a/backend/tests/test_sprint106_map_bbox_extract.py b/backend/tests/test_sprint106_map_bbox_extract.py index 302d5b23..5582a0ef 100644 --- a/backend/tests/test_sprint106_map_bbox_extract.py +++ b/backend/tests/test_sprint106_map_bbox_extract.py @@ -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] diff --git a/backend/tests/test_sprint186_map_first_geographic_explorer.py b/backend/tests/test_sprint186_map_first_geographic_explorer.py new file mode 100644 index 00000000..e91301d3 --- /dev/null +++ b/backend/tests/test_sprint186_map_first_geographic_explorer.py @@ -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('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 diff --git a/backend/tests/test_sprint39_frontend_orchestration_hooks.py b/backend/tests/test_sprint39_frontend_orchestration_hooks.py index bd7aa74d..98caf0ca 100644 --- a/backend/tests/test_sprint39_frontend_orchestration_hooks.py +++ b/backend/tests/test_sprint39_frontend_orchestration_hooks.py @@ -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('overview')" in app + assert "const [activeWorkspace, setActiveWorkspace] = useState('map')" in app def test_demo_workflow_hook_owns_demo_api_and_cross_module_selection() -> None: diff --git a/deploy/unraid/Dockerfile.all-in-one b/deploy/unraid/Dockerfile.all-in-one index 7ed26a63..cc8cb111 100644 --- a/deploy/unraid/Dockerfile.all-in-one +++ b/deploy/unraid/Dockerfile.all-in-one @@ -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 diff --git a/docs/API_CONTRACTS.md b/docs/API_CONTRACTS.md index 77259450..3d72216d 100644 --- a/docs/API_CONTRACTS.md +++ b/docs/API_CONTRACTS.md @@ -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. diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index dde09617..7bdac94c 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -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. diff --git a/docs/TODO.md b/docs/TODO.md index 159ed0f7..ff979872 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -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 diff --git a/frontend/README.md b/frontend/README.md index 1ddb5363..50133f78 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -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. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 42ca7c42..f8069336 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -54,7 +54,7 @@ const workspaceNavGroups: Array<{ label: string; keys: WorkspaceKey[] }> = [ ] function App(): JSX.Element { - const [activeWorkspace, setActiveWorkspace] = useState('overview') + const [activeWorkspace, setActiveWorkspace] = useState('map') const [inspectorOpen, setInspectorOpen] = useState(false) const [mapContentMode, setMapContentMode] = useState<'dataset' | 'analysis'>('dataset') useEffect(() => { @@ -910,6 +910,7 @@ function App(): JSX.Element { {activeWorkspace === 'map' ? ( 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(null) const mapRef = useRef(null) const onFeatureSelectRef = useRef(onFeatureSelect) const onMapCoordinateSelectRef = useRef(onMapCoordinateSelect) + const onMapBboxPreviewRef = useRef(onMapBboxPreview) + const onMapBboxSelectRef = useRef(onMapBboxSelect) const onViewportChangeRef = useRef(onViewportChange) const bboxSelectionModeRef = useRef(bboxSelectionMode) const lastFittedAreaRef = useRef(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 } diff --git a/frontend/src/components/map/MapWorkspace.tsx b/frontend/src/components/map/MapWorkspace.tsx index 106d5c9f..00dc1ff7 100644 --- a/frontend/src/components/map/MapWorkspace.tsx +++ b/frontend/src/components/map/MapWorkspace.tsx @@ -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('buildings') + const { + themeInsights, + themeInsightsLoading: themeResultsLoading, + themeInsightsError: themeResultsError, + loadThemeInsights, + clearThemeInsights, + } = useMapThemeSelectionInsights(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, + [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>() + 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() + 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 ( +
+
+
+

Mol · geografische verkenner

+

Wat bevindt zich in dit gebied?

+

Kies een datathema, teken een rechthoek en lees de beschikbare gegevens meteen uit.

+
+ +
+ +
+ + +
+
+
+ 2 +
+

Selecteer een gebied

+

{bboxSelectionMode ? 'Sleep nu een rechthoek op de kaart.' : 'Sleep een rechthoek of analyseer de volledige gemeente.'}

+
+
+
+ + + +
+
+ +
+ +
+ Gemeentegrens + {activeTheme.shortLabel} + Selectie +
+ {bboxSelectionMode ? ( +
+ Rechthoek tekenen + Houd de linkermuisknop ingedrukt, sleep over het gewenste gebied en laat los. +
+ ) : null} + {viewportVectorEnabled && viewportVectorStatus ? ( +
+ {viewportVectorStatus} +
+ ) : null} +
+
+ + +
+ +
+ Werkgebied: {selectedMapArea?.name ?? 'Geen gemeentegrens geselecteerd'} + Bron: {activeThemeDataset ? `${activeThemeDataset.source_name ?? activeThemeDataset.source} · ${activeThemeDataset.name}` : 'niet beschikbaar'} + {usesDefaultOsmBasemap ? Ondergrond: OpenStreetMap : null} +
+
+ ) + } + return (
+

Spatial review

diff --git a/frontend/src/hooks/useMapSelectionExtract.ts b/frontend/src/hooks/useMapSelectionExtract.ts index 1b388520..04f3c742 100644 --- a/frontend/src/hooks/useMapSelectionExtract.ts +++ b/frontend/src/hooks/useMapSelectionExtract.ts @@ -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 diff --git a/frontend/src/hooks/useMapThemeSelectionInsights.ts b/frontend/src/hooks/useMapThemeSelectionInsights.ts new file mode 100644 index 00000000..f84ee94c --- /dev/null +++ b/frontend/src/hooks/useMapThemeSelectionInsights.ts @@ -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 { + themeId: TThemeId + dataset: DatasetCreateResponse +} + +export interface MapThemeInsight extends MapThemeQuery { + result: VectorSelectionResponse +} + +export function useMapThemeSelectionInsights( + selectedProjectId: string | null, +) { + const [themeInsights, setThemeInsights] = useState>>([]) + const [themeInsightsLoading, setThemeInsightsLoading] = useState(false) + const [themeInsightsError, setThemeInsightsError] = useState(null) + + useEffect(() => { + setThemeInsights([]) + setThemeInsightsError(null) + }, [selectedProjectId]) + + const clearThemeInsights = () => { + setThemeInsights([]) + setThemeInsightsError(null) + } + + const loadThemeInsights = async ( + bbox: VectorSelectionBBox, + queries: Array>, + ): Promise>> => { + 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, + } +} diff --git a/frontend/src/hooks/useMapWorkspaceState.ts b/frontend/src/hooks/useMapWorkspaceState.ts index ac914503..cc85d75e 100644 --- a/frontend/src/hooks/useMapWorkspaceState.ts +++ b/frontend/src/hooks/useMapWorkspaceState.ts @@ -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]) diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 4cc102de..ebab8609 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -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 { diff --git a/frontend/src/types.ts b/frontend/src/types.ts index c0b8909d..aff86e1a 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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 diff --git a/scripts/README.md b/scripts/README.md index fc846b11..8d60bff9 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -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: diff --git a/scripts/provision_mol_context_layers.py b/scripts/provision_mol_context_layers.py new file mode 100644 index 00000000..8a181bab --- /dev/null +++ b/scripts/provision_mol_context_layers.py @@ -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()) diff --git a/scripts/run_readiness_check.sh b/scripts/run_readiness_check.sh index 470892d4..ad150071 100755 --- a/scripts/run_readiness_check.sh +++ b/scripts/run_readiness_check.sh @@ -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