import { useEffect, useRef, useState } from 'react' import maplibregl from 'maplibre-gl' import 'maplibre-gl/dist/maplibre-gl.css' import { PRIMARY_FOCUS_CENTER } from '../config/primaryFocus' import { featureCollectionBounds } from '../lib/geojsonBounds' interface GeoMapProps { data: GeoJSON.FeatureCollection | null 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 onFeatureSelect?: (feature: GeoJSON.Feature | null) => void onMapCoordinateSelect?: (coordinate: [number, number]) => 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, onFeatureSelect, onMapCoordinateSelect, }: GeoMapProps): JSX.Element { const containerRef = useRef(null) const mapRef = useRef(null) const onFeatureSelectRef = useRef(onFeatureSelect) const onMapCoordinateSelectRef = useRef(onMapCoordinateSelect) const bboxSelectionModeRef = useRef(bboxSelectionMode) const [mapStyleReady, setMapStyleReady] = useState(false) useEffect(() => { onFeatureSelectRef.current = onFeatureSelect }, [onFeatureSelect]) useEffect(() => { onMapCoordinateSelectRef.current = onMapCoordinateSelect }, [onMapCoordinateSelect]) useEffect(() => { bboxSelectionModeRef.current = bboxSelectionMode if (mapRef.current) { mapRef.current.getCanvas().style.cursor = bboxSelectionMode ? 'crosshair' : '' } }, [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', ) map.on('load', () => { setMapStyleReady(true) }) map.on('click', (event) => { if (bboxSelectionModeRef.current) { 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) { const collection = data if (collection.type === 'FeatureCollection' && collection.features.length > 0) { const bounds = collectCoordinates(collection) if (bounds) { map.fitBounds(bounds, { padding: 40 }) } } } }, [data, 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 = mergeFeatureCollections([areaData, data]) if (activeCollection) { const bounds = collectCoordinates(activeCollection) if (bounds) { map.fitBounds(bounds, { padding: 40 }) } } }, [areaData, data, 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