import { useEffect, useRef, useState } from 'react' import maplibregl from 'maplibre-gl' import 'maplibre-gl/dist/maplibre-gl.css' import { PRIMARY_FOCUS_CENTER } from '../config/primaryFocus' import { featureCollectionBounds } from '../lib/geojsonBounds' import type { MapViewportState, VectorSelectionBBox } from '../types' interface GeoMapProps { data: GeoJSON.FeatureCollection | null areaData?: GeoJSON.FeatureCollection | null selectedFeature?: GeoJSON.Feature | null selectionData?: GeoJSON.FeatureCollection | null qaEvidenceData?: GeoJSON.FeatureCollection | null selectionBbox?: { min_x: number; min_y: number; max_x: number; max_y: number } | null bboxSelectionMode?: boolean visible?: boolean opacity?: number areaVisible?: boolean areaOpacity?: number 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 } const EMPTY_FEATURE_COLLECTION: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features: [], } const DEFAULT_ROAD_BASEMAP_STYLE: maplibregl.StyleSpecification = { version: 8, sources: { 'osm-standard': { type: 'raster', tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], tileSize: 256, attribution: '© OpenStreetMap contributors', maxzoom: 19, }, }, layers: [ { id: 'osm-standard', type: 'raster', source: 'osm-standard', }, ], } function defaultMapStyle(): string | maplibregl.StyleSpecification { return import.meta.env.VITE_MAP_STYLE_URL || DEFAULT_ROAD_BASEMAP_STYLE } function collectCoordinates(featureCollection: GeoJSON.FeatureCollection): maplibregl.LngLatBoundsLike | null { const bounds = featureCollectionBounds(featureCollection) if (!bounds) { return null } return [ [bounds.minX, bounds.minY], [bounds.maxX, bounds.maxY], ] } function mergeFeatureCollections(collections: Array): GeoJSON.FeatureCollection | null { const features = collections.flatMap((collection) => collection?.features ?? []) return features.length > 0 ? { type: 'FeatureCollection', features } : null } function bboxToFeatureCollection( bbox: { min_x: number; min_y: number; max_x: number; max_y: number } | null | undefined, ): GeoJSON.FeatureCollection { if (!bbox) { return EMPTY_FEATURE_COLLECTION } return { type: 'FeatureCollection', features: [ { type: 'Feature', geometry: { type: 'Polygon', coordinates: [ [ [bbox.min_x, bbox.min_y], [bbox.max_x, bbox.min_y], [bbox.max_x, bbox.max_y], [bbox.min_x, bbox.max_y], [bbox.min_x, bbox.min_y], ], ], }, properties: { layer_type: 'selection_bbox', }, }, ], } } function GeoMap({ data, areaData = null, selectedFeature = null, selectionData = null, qaEvidenceData = null, selectionBbox = null, bboxSelectionMode = false, visible = true, opacity = 0.4, areaVisible = true, areaOpacity = 0.18, 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) const [mapStyleReady, setMapStyleReady] = useState(false) useEffect(() => { onFeatureSelectRef.current = onFeatureSelect }, [onFeatureSelect]) useEffect(() => { onMapCoordinateSelectRef.current = onMapCoordinateSelect }, [onMapCoordinateSelect]) useEffect(() => { onMapBboxPreviewRef.current = onMapBboxPreview }, [onMapBboxPreview]) useEffect(() => { onMapBboxSelectRef.current = onMapBboxSelect }, [onMapBboxSelect]) useEffect(() => { onViewportChangeRef.current = onViewportChange }, [onViewportChange]) useEffect(() => { 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]) useEffect(() => { if (!containerRef.current || mapRef.current) { return } const map = new maplibregl.Map({ container: containerRef.current, style: defaultMapStyle(), center: PRIMARY_FOCUS_CENTER, zoom: 11, attributionControl: false, }) map.addControl(new maplibregl.NavigationControl(), 'top-right') map.addControl( new maplibregl.AttributionControl({ compact: true, }), 'bottom-right', ) const emitViewport = () => { const bounds = map.getBounds() onViewportChangeRef.current?.({ bbox: { min_x: bounds.getWest(), min_y: bounds.getSouth(), max_x: bounds.getEast(), max_y: bounds.getNorth(), crs: 'EPSG:4326', }, zoom: map.getZoom(), }) } map.on('load', () => { setMapStyleReady(true) 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 } const layers = [ 'qa-evidence-fill', 'qa-evidence-line', 'qa-evidence-circle', 'selection-result-fill', 'selection-result-line', 'selection-result-circle', 'dataset-fill', 'dataset-line', 'area-fill', 'area-line', ].filter((layerId) => map.getLayer(layerId)) if (layers.length === 0) { onFeatureSelectRef.current?.(null) return } const features = map.queryRenderedFeatures(event.point, { layers, }) if (features.length === 0) { onFeatureSelectRef.current?.(null) return } const feature = features[0] as unknown as GeoJSON.Feature onFeatureSelectRef.current?.(feature) }) mapRef.current = map return () => { map.remove() mapRef.current = null setMapStyleReady(false) } }, []) useEffect(() => { const map = mapRef.current if (!map || !mapStyleReady || !map.isStyleLoaded()) { return } if (map.getSource('dataset')) { if (data) { ;(map.getSource('dataset') as maplibregl.GeoJSONSource).setData(data) } else { if (map.getLayer('dataset-fill')) { map.removeLayer('dataset-fill') } if (map.getLayer('dataset-line')) { map.removeLayer('dataset-line') } map.removeSource('dataset') return } } else if (data) { map.addSource('dataset', { type: 'geojson', data }) map.addLayer({ id: 'dataset-fill', type: 'fill', source: 'dataset', paint: { 'fill-color': [ 'case', ['==', ['get', 'layer_type'], 'municipality_boundary'], '#0f766e', ['==', ['get', 'layer_type'], 'building'], '#0891b2', [ 'match', ['get', 'change_type'], 'added', '#16a34a', 'removed', '#dc2626', 'unchanged', '#2563eb', '#f97316', ], ], 'fill-opacity': 0.4, }, }) map.addLayer({ id: 'dataset-line', type: 'line', source: 'dataset', paint: { 'line-color': [ 'case', ['==', ['get', 'layer_type'], 'municipality_boundary'], '#0f5f59', ['==', ['get', 'layer_type'], 'building'], '#0e7490', [ 'match', ['get', 'change_type'], 'added', '#15803d', 'removed', '#b91c1c', 'unchanged', '#1d4ed8', '#ea580c', ], ], 'line-width': ['interpolate', ['linear'], ['zoom'], 8, 0.25, 12, 0.8, 16, 2], }, }) } if (data && fitDataOnChange) { const collection = data if (collection.type === 'FeatureCollection' && collection.features.length > 0) { const bounds = collectCoordinates(collection) if (bounds) { map.fitBounds(bounds, { padding: 40 }) } } } }, [data, fitDataOnChange, mapStyleReady]) useEffect(() => { const map = mapRef.current if (!map || !mapStyleReady || !map.isStyleLoaded()) { return } if (map.getSource('area')) { if (areaData) { ;(map.getSource('area') as maplibregl.GeoJSONSource).setData(areaData) } else { if (map.getLayer('area-fill')) { map.removeLayer('area-fill') } if (map.getLayer('area-line')) { map.removeLayer('area-line') } map.removeSource('area') return } } else if (areaData) { map.addSource('area', { type: 'geojson', data: areaData }) map.addLayer( { id: 'area-fill', type: 'fill', source: 'area', paint: { 'fill-color': '#0f766e', 'fill-opacity': 0.18, }, }, map.getLayer('dataset-fill') ? 'dataset-fill' : undefined, ) map.addLayer( { id: 'area-line', type: 'line', source: 'area', paint: { 'line-color': '#0f766e', 'line-width': 3, 'line-dasharray': [2, 1], }, }, map.getLayer('dataset-line') ? 'dataset-line' : undefined, ) } const activeCollection = fitDataOnChange ? mergeFeatureCollections([areaData, data]) : areaData const shouldFitArea = fitDataOnChange || lastFittedAreaRef.current !== areaData if (activeCollection && shouldFitArea) { const bounds = collectCoordinates(activeCollection) if (bounds) { map.fitBounds(bounds, { padding: 40 }) } } lastFittedAreaRef.current = areaData }, [areaData, data, fitDataOnChange, mapStyleReady]) useEffect(() => { const map = mapRef.current if (!map || !mapStyleReady || !map.isStyleLoaded()) { return } const visibility = visible ? 'visible' : 'none' if (map.getLayer('dataset-fill')) { map.setLayoutProperty('dataset-fill', 'visibility', visibility) map.setPaintProperty('dataset-fill', 'fill-opacity', opacity) } if (map.getLayer('dataset-line')) { map.setLayoutProperty('dataset-line', 'visibility', visibility) map.setPaintProperty('dataset-line', 'line-opacity', visible ? 1 : 0) } }, [visible, opacity, data, mapStyleReady]) useEffect(() => { const map = mapRef.current if (!map || !mapStyleReady || !map.isStyleLoaded()) { return } const visibility = areaVisible ? 'visible' : 'none' if (map.getLayer('area-fill')) { map.setLayoutProperty('area-fill', 'visibility', visibility) map.setPaintProperty('area-fill', 'fill-opacity', areaOpacity) } if (map.getLayer('area-line')) { map.setLayoutProperty('area-line', 'visibility', visibility) map.setPaintProperty('area-line', 'line-opacity', areaVisible ? 1 : 0) } }, [areaVisible, areaOpacity, areaData, mapStyleReady]) useEffect(() => { const map = mapRef.current if (!map || !mapStyleReady || !map.isStyleLoaded()) { return } const selectedCollection: GeoJSON.FeatureCollection = selectedFeature ? { type: 'FeatureCollection', features: [selectedFeature] } : EMPTY_FEATURE_COLLECTION if (map.getSource('selected-feature')) { ;(map.getSource('selected-feature') as maplibregl.GeoJSONSource).setData(selectedCollection) return } map.addSource('selected-feature', { type: 'geojson', data: selectedCollection }) map.addLayer({ id: 'selected-feature-fill', type: 'fill', source: 'selected-feature', filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon'], true, false], paint: { 'fill-color': '#fde047', 'fill-opacity': 0.32, }, }) map.addLayer({ id: 'selected-feature-line', type: 'line', source: 'selected-feature', filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon', 'LineString', 'MultiLineString'], true, false], paint: { 'line-color': '#854d0e', 'line-width': 4, }, }) map.addLayer({ id: 'selected-feature-circle', type: 'circle', source: 'selected-feature', filter: ['match', ['geometry-type'], ['Point', 'MultiPoint'], true, false], paint: { 'circle-color': '#fde047', 'circle-radius': 7, 'circle-stroke-color': '#854d0e', 'circle-stroke-width': 2, }, }) }, [selectedFeature, mapStyleReady]) useEffect(() => { const map = mapRef.current if (!map || !mapStyleReady || !map.isStyleLoaded()) { return } const bboxCollection = bboxToFeatureCollection(selectionBbox) if (map.getSource('selection-bbox')) { ;(map.getSource('selection-bbox') as maplibregl.GeoJSONSource).setData(bboxCollection) } else { map.addSource('selection-bbox', { type: 'geojson', data: bboxCollection }) map.addLayer({ id: 'selection-bbox-fill', type: 'fill', source: 'selection-bbox', paint: { 'fill-color': '#38bdf8', 'fill-opacity': 0.12, }, }) map.addLayer({ id: 'selection-bbox-line', type: 'line', source: 'selection-bbox', paint: { 'line-color': '#0369a1', 'line-width': 2, 'line-dasharray': [2, 1], }, }) } }, [selectionBbox, mapStyleReady]) useEffect(() => { const map = mapRef.current if (!map || !mapStyleReady || !map.isStyleLoaded()) { return } const resultCollection = selectionData ?? EMPTY_FEATURE_COLLECTION if (map.getSource('selection-result')) { ;(map.getSource('selection-result') as maplibregl.GeoJSONSource).setData(resultCollection) return } map.addSource('selection-result', { type: 'geojson', data: resultCollection }) map.addLayer({ id: 'selection-result-fill', type: 'fill', source: 'selection-result', filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon'], true, false], paint: { 'fill-color': '#7c3aed', 'fill-opacity': 0.24, }, }) map.addLayer({ id: 'selection-result-line', type: 'line', source: 'selection-result', filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon', 'LineString', 'MultiLineString'], true, false], paint: { 'line-color': '#5b21b6', 'line-width': 3, }, }) map.addLayer({ id: 'selection-result-circle', type: 'circle', source: 'selection-result', filter: ['match', ['geometry-type'], ['Point', 'MultiPoint'], true, false], paint: { 'circle-color': '#7c3aed', 'circle-radius': 6, 'circle-stroke-color': '#ffffff', 'circle-stroke-width': 2, }, }) }, [selectionData, mapStyleReady]) useEffect(() => { const map = mapRef.current if (!map || !mapStyleReady || !map.isStyleLoaded()) { return } const evidenceCollection = qaEvidenceData ?? EMPTY_FEATURE_COLLECTION if (map.getSource('qa-evidence')) { ;(map.getSource('qa-evidence') as maplibregl.GeoJSONSource).setData(evidenceCollection) return } map.addSource('qa-evidence', { type: 'geojson', data: evidenceCollection }) const evidenceColor = [ 'match', ['get', 'qa_evidence_role'], 'match_candidate', '#2563eb', 'match_reference', '#0f766e', 'false_positive', '#dc2626', 'false_negative', '#d97706', '#475569', ] as maplibregl.ExpressionSpecification map.addLayer({ id: 'qa-evidence-fill', type: 'fill', source: 'qa-evidence', filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon'], true, false], paint: { 'fill-color': evidenceColor, 'fill-opacity': 0.28, }, }) map.addLayer({ id: 'qa-evidence-line', type: 'line', source: 'qa-evidence', filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon', 'LineString', 'MultiLineString'], true, false], paint: { 'line-color': evidenceColor, 'line-width': [ 'match', ['get', 'qa_evidence_role'], 'false_positive', 4, 'false_negative', 4, 3, ], }, }) map.addLayer({ id: 'qa-evidence-circle', type: 'circle', source: 'qa-evidence', filter: ['match', ['geometry-type'], ['Point', 'MultiPoint'], true, false], paint: { 'circle-color': evidenceColor, 'circle-radius': 7, 'circle-stroke-color': '#ffffff', 'circle-stroke-width': 2, }, }) }, [qaEvidenceData, mapStyleReady]) return
} export default GeoMap