feat: make Mol explorer map-first
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-14 13:21:53 +02:00
parent e340b7f1ec
commit 2958c25b6f
24 changed files with 1901 additions and 14 deletions
+9
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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)
+1
View File
@@ -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
+11 -3
View File
@@ -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:
+1
View File
@@ -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
+3 -1
View File
@@ -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.
+21
View File
@@ -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.
+9
View File
@@ -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
View File
@@ -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.
+2 -1
View File
@@ -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}
+60 -1
View File
@@ -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
}
+469 -2
View File
@@ -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 themas</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>
+4 -1
View File
@@ -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,
}
}
+11 -1
View File
@@ -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])
+693
View File
@@ -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 {
+1
View File
@@ -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
+20
View File
@@ -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:
+431
View File
@@ -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())
+1
View File
@@ -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