/** * ChatMapPanel.tsx - Collapsible MapLibre map panel for chat results * * A Material-UI styled map panel that displays heritage institutions * with coordinates returned from RAG queries. * * Features: * - Collapsible panel that shows/hides the map * - Real OSM/CartoDB tiles (light/dark mode) * - GLAMORCUBESFIXPHDNT type colors (19 types) * - Click markers to show institution details * - Automatic bounds fitting */ import React, { useRef, useEffect, useState, useMemo, useCallback } from 'react' import maplibregl from 'maplibre-gl' import type { StyleSpecification, MapLayerMouseEvent, GeoJSONSource } from 'maplibre-gl' import 'maplibre-gl/dist/maplibre-gl.css' import { Box, Paper, Typography, Collapse, IconButton, Chip, Link, } from '@mui/material' import ExpandMoreIcon from '@mui/icons-material/ExpandMore' import ExpandLessIcon from '@mui/icons-material/ExpandLess' import MapIcon from '@mui/icons-material/Map' import PlaceIcon from '@mui/icons-material/Place' import OpenInNewIcon from '@mui/icons-material/OpenInNew' import CloseIcon from '@mui/icons-material/Close' // NA Color palette (matches ChatPage.tsx) const naColors = { primary: '#007bc7', red: '#d52b1e', orange: '#e17000', green: '#39870c', cream: '#f7f5f3', darkBlue: '#154273', lightBlue: '#e5f0f9', } // Custodian type colors matching GLAMORCUBESFIXPHDNT taxonomy (19 types) const TYPE_COLORS: Record = { G: '#00bcd4', // Gallery - cyan L: '#2ecc71', // Library - green A: '#3498db', // Archive - blue M: '#e74c3c', // Museum - red O: '#f39c12', // Official - orange R: '#1abc9c', // Research - teal C: '#795548', // Corporation - brown U: '#9e9e9e', // Unknown - gray B: '#4caf50', // Botanical - green E: '#ff9800', // Education - amber S: '#9b59b6', // Society - purple F: '#95a5a6', // Features - gray I: '#673ab7', // Intangible - deep purple X: '#607d8b', // Mixed - blue gray P: '#8bc34a', // Personal - light green H: '#607d8b', // Holy sites - blue gray D: '#34495e', // Digital - dark gray N: '#e91e63', // NGO - pink T: '#ff5722', // Taste/smell - deep orange } const TYPE_NAMES: Record = { G: 'Galerie', L: 'Bibliotheek', A: 'Archief', M: 'Museum', O: 'Officieel', R: 'Onderzoek', C: 'Bedrijf', U: 'Onbekend', B: 'Botanisch', E: 'Onderwijs', S: 'Vereniging', F: 'Monumenten', I: 'Immaterieel', X: 'Gemengd', P: 'Persoonlijk', H: 'Heilige plaatsen', D: 'Digitaal', N: 'NGO', T: 'Smaak/geur', } // Map tile styles const getMapStyle = (isDarkMode: boolean): StyleSpecification => { if (isDarkMode) { return { version: 8, sources: { 'carto-dark': { type: 'raster', tiles: [ 'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', 'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', ], tileSize: 256, attribution: '© OpenStreetMap © CARTO', }, }, layers: [ { id: 'carto-dark-tiles', type: 'raster', source: 'carto-dark', minzoom: 0, maxzoom: 19, }, ], } } else { return { version: 8, sources: { 'osm': { type: 'raster', tiles: [ 'https://a.tile.openstreetmap.org/{z}/{x}/{y}.png', 'https://b.tile.openstreetmap.org/{z}/{x}/{y}.png', ], tileSize: 256, attribution: '© OpenStreetMap contributors', }, }, layers: [ { id: 'osm-tiles', type: 'raster', source: 'osm', minzoom: 0, maxzoom: 19, }, ], } } } // Institution interface (matches ChatPage.tsx) export interface Institution { name: string type?: string city?: string country?: string description?: string website?: string latitude?: number longitude?: number score?: number } interface ChatMapPanelProps { institutions: Institution[] defaultExpanded?: boolean } /** * Map a type name to single-letter code */ function mapTypeNameToCode(typeName?: string): string { if (!typeName) return 'U' const normalized = typeName.toLowerCase() if (normalized.includes('museum')) return 'M' if (normalized.includes('archief') || normalized.includes('archive')) return 'A' if (normalized.includes('bibliotheek') || normalized.includes('library')) return 'L' if (normalized.includes('galerie') || normalized.includes('gallery')) return 'G' if (normalized.includes('universiteit') || normalized.includes('university') || normalized.includes('onderwijs') || normalized.includes('education')) return 'E' if (normalized.includes('onderzoek') || normalized.includes('research')) return 'R' if (normalized.includes('vereniging') || normalized.includes('society')) return 'S' if (normalized.includes('botanisch') || normalized.includes('botanical') || normalized.includes('zoo')) return 'B' if (normalized.includes('officieel') || normalized.includes('official')) return 'O' if (normalized.includes('bedrijf') || normalized.includes('corporation')) return 'C' if (normalized.includes('monument') || normalized.includes('feature')) return 'F' if (normalized.includes('immaterieel') || normalized.includes('intangible')) return 'I' if (normalized.includes('persoonlijk') || normalized.includes('personal')) return 'P' if (normalized.includes('heilig') || normalized.includes('holy') || normalized.includes('kerk')) return 'H' if (normalized.includes('digitaal') || normalized.includes('digital')) return 'D' if (normalized.includes('ngo')) return 'N' if (normalized.includes('smaak') || normalized.includes('taste')) return 'T' if (normalized.includes('gemengd') || normalized.includes('mixed')) return 'X' return 'U' } /** * Convert institutions to GeoJSON FeatureCollection */ function institutionsToGeoJSON(institutions: Institution[]): GeoJSON.FeatureCollection { const validInstitutions = institutions.filter( inst => inst.latitude != null && inst.longitude != null ) return { type: 'FeatureCollection', features: validInstitutions.map((inst, index) => { const typeCode = mapTypeNameToCode(inst.type) return { type: 'Feature' as const, id: index, geometry: { type: 'Point' as const, coordinates: [inst.longitude!, inst.latitude!], }, properties: { index, name: inst.name, type: typeCode, typeName: inst.type || '', color: TYPE_COLORS[typeCode] || '#9e9e9e', city: inst.city || '', country: inst.country || '', website: inst.website || '', description: inst.description || '', }, } }), } } export const ChatMapPanel: React.FC = ({ institutions, defaultExpanded = true, }) => { const mapContainerRef = useRef(null) const mapRef = useRef(null) const [mapReady, setMapReady] = useState(false) const [expanded, setExpanded] = useState(defaultExpanded) const [selectedInstitution, setSelectedInstitution] = useState(null) const [isDarkMode] = useState(() => window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false ) // Filter institutions with valid coordinates const validInstitutions = useMemo(() => institutions.filter(inst => inst.latitude != null && inst.longitude != null), [institutions] ) // Convert to GeoJSON const geoJSON = useMemo(() => institutionsToGeoJSON(institutions), [institutions]) // Calculate bounds const bounds = useMemo(() => { if (validInstitutions.length === 0) return null const lngs = validInstitutions.map(i => i.longitude!) const lats = validInstitutions.map(i => i.latitude!) return new maplibregl.LngLatBounds( [Math.min(...lngs), Math.min(...lats)], [Math.max(...lngs), Math.max(...lats)] ) }, [validInstitutions]) // Initialize map useEffect(() => { if (!mapContainerRef.current || !expanded || mapRef.current) return const map = new maplibregl.Map({ container: mapContainerRef.current, style: getMapStyle(isDarkMode), center: bounds ? bounds.getCenter().toArray() as [number, number] : [5.2913, 52.1326], zoom: 7, attributionControl: true, }) mapRef.current = map map.on('load', () => { setMapReady(true) // Fit to bounds if we have markers if (bounds && validInstitutions.length > 1) { map.fitBounds(bounds, { padding: 50, maxZoom: 14 }) } else if (validInstitutions.length === 1) { map.setCenter([validInstitutions[0].longitude!, validInstitutions[0].latitude!]) map.setZoom(12) } }) map.addControl(new maplibregl.NavigationControl(), 'top-right') return () => { map.remove() mapRef.current = null setMapReady(false) } }, [expanded]) // Add/update GeoJSON source and layers useEffect(() => { if (!mapRef.current || !mapReady) return const map = mapRef.current const addLayers = () => { // Remove existing layers and source if they exist if (map.getLayer('institutions-circles')) map.removeLayer('institutions-circles') if (map.getLayer('institutions-stroke')) map.removeLayer('institutions-stroke') if (map.getSource('institutions')) map.removeSource('institutions') // Add source map.addSource('institutions', { type: 'geojson', data: geoJSON, }) // Add circle layer map.addLayer({ id: 'institutions-circles', type: 'circle', source: 'institutions', paint: { 'circle-radius': 8, 'circle-color': ['get', 'color'], 'circle-stroke-width': 2, 'circle-stroke-color': '#ffffff', 'circle-opacity': 0.85, }, }) // Fit bounds if (bounds && validInstitutions.length > 1) { map.fitBounds(bounds, { padding: 50, maxZoom: 14 }) } } if (map.isStyleLoaded()) { addLayers() } else { map.once('style.load', addLayers) } }, [geoJSON, mapReady, bounds, validInstitutions.length]) // Handle click events useEffect(() => { if (!mapRef.current || !mapReady) return const map = mapRef.current const handleClick = (e: MapLayerMouseEvent) => { if (!e.features || e.features.length === 0) return const feature = e.features[0] const props = feature.properties const index = props?.index if (index !== undefined && validInstitutions[index]) { setSelectedInstitution(validInstitutions[index]) } } const handleMouseEnter = () => { map.getCanvas().style.cursor = 'pointer' } const handleMouseLeave = () => { map.getCanvas().style.cursor = '' } // Wait for layer to exist const bindEvents = () => { if (map.getLayer('institutions-circles')) { map.on('click', 'institutions-circles', handleClick) map.on('mouseenter', 'institutions-circles', handleMouseEnter) map.on('mouseleave', 'institutions-circles', handleMouseLeave) } else { setTimeout(bindEvents, 100) } } bindEvents() return () => { if (map.getLayer('institutions-circles')) { map.off('click', 'institutions-circles', handleClick) map.off('mouseenter', 'institutions-circles', handleMouseEnter) map.off('mouseleave', 'institutions-circles', handleMouseLeave) } } }, [mapReady, validInstitutions]) // Don't render if no valid institutions if (validInstitutions.length === 0) { return null } // Get unique types for legend const uniqueTypes = Array.from(new Set(validInstitutions.map(i => mapTypeNameToCode(i.type)))) .filter(code => code !== 'U') .slice(0, 6) return ( {/* Header - Clickable to expand/collapse */} setExpanded(!expanded)} > {validInstitutions.length} instelling{validInstitutions.length !== 1 ? 'en' : ''} op de kaart {expanded ? : } {/* Map Container - Collapsible */} {/* Legend */} Legenda {uniqueTypes.map(code => ( {TYPE_NAMES[code]} ))} {/* Selected Institution Panel */} {selectedInstitution && ( {selectedInstitution.name} setSelectedInstitution(null)}> {selectedInstitution.type && ( )} {(selectedInstitution.city || selectedInstitution.country) && ( {[selectedInstitution.city, selectedInstitution.country].filter(Boolean).join(', ')} )} {selectedInstitution.description && ( {selectedInstitution.description.slice(0, 150)} {selectedInstitution.description.length > 150 ? '...' : ''} )} {selectedInstitution.website && ( Website )} )} ) } export default ChatMapPanel