glam/apps/archief-assistent/src/components/ChatMapPanel.tsx

555 lines
17 KiB
TypeScript

/**
* 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<string, string> = {
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<string, string> = {
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: '&copy; OpenStreetMap &copy; 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: '&copy; 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<ChatMapPanelProps> = ({
institutions,
defaultExpanded = true,
}) => {
const mapContainerRef = useRef<HTMLDivElement>(null)
const mapRef = useRef<maplibregl.Map | null>(null)
const [mapReady, setMapReady] = useState(false)
const [expanded, setExpanded] = useState(defaultExpanded)
const [selectedInstitution, setSelectedInstitution] = useState<Institution | null>(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 (
<Paper
elevation={1}
sx={{
mt: 2,
mb: 2,
overflow: 'hidden',
border: `1px solid ${naColors.lightBlue}`,
}}
>
{/* Header - Clickable to expand/collapse */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 2,
py: 1,
bgcolor: naColors.lightBlue,
cursor: 'pointer',
}}
onClick={() => setExpanded(!expanded)}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<MapIcon sx={{ color: naColors.primary }} />
<Typography variant="subtitle2" sx={{ color: naColors.primary, fontWeight: 600 }}>
{validInstitutions.length} instelling{validInstitutions.length !== 1 ? 'en' : ''} op de kaart
</Typography>
</Box>
<IconButton size="small" sx={{ color: naColors.primary }}>
{expanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</IconButton>
</Box>
{/* Map Container - Collapsible */}
<Collapse in={expanded}>
<Box sx={{ position: 'relative', height: 350 }}>
<Box
ref={mapContainerRef}
sx={{ width: '100%', height: '100%' }}
/>
{/* Legend */}
<Paper
sx={{
position: 'absolute',
bottom: 10,
left: 10,
p: 1,
bgcolor: 'rgba(255,255,255,0.9)',
borderRadius: 1,
maxWidth: 160,
}}
elevation={2}
>
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 0.5 }}>
Legenda
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
{uniqueTypes.map(code => (
<Box key={code} sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Box
sx={{
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: TYPE_COLORS[code],
flexShrink: 0,
}}
/>
<Typography variant="caption" sx={{ fontSize: '0.65rem' }}>
{TYPE_NAMES[code]}
</Typography>
</Box>
))}
</Box>
</Paper>
{/* Selected Institution Panel */}
{selectedInstitution && (
<Paper
sx={{
position: 'absolute',
top: 10,
right: 10,
p: 2,
maxWidth: 280,
bgcolor: 'rgba(255,255,255,0.95)',
}}
elevation={3}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: naColors.primary, pr: 2 }}>
{selectedInstitution.name}
</Typography>
<IconButton size="small" onClick={() => setSelectedInstitution(null)}>
<CloseIcon sx={{ fontSize: 18 }} />
</IconButton>
</Box>
{selectedInstitution.type && (
<Chip
label={selectedInstitution.type}
size="small"
sx={{
mt: 0.5,
height: 20,
fontSize: '0.7rem',
bgcolor: TYPE_COLORS[mapTypeNameToCode(selectedInstitution.type)],
color: '#fff',
}}
/>
)}
{(selectedInstitution.city || selectedInstitution.country) && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 1 }}>
<PlaceIcon sx={{ fontSize: 14, color: 'text.secondary' }} />
<Typography variant="caption" color="text.secondary">
{[selectedInstitution.city, selectedInstitution.country].filter(Boolean).join(', ')}
</Typography>
</Box>
)}
{selectedInstitution.description && (
<Typography variant="body2" sx={{ mt: 1, fontSize: '0.75rem', lineHeight: 1.4 }}>
{selectedInstitution.description.slice(0, 150)}
{selectedInstitution.description.length > 150 ? '...' : ''}
</Typography>
)}
{selectedInstitution.website && (
<Link
href={selectedInstitution.website}
target="_blank"
rel="noopener noreferrer"
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
mt: 1,
fontSize: '0.75rem',
color: naColors.primary,
}}
>
Website <OpenInNewIcon sx={{ fontSize: 12 }} />
</Link>
)}
</Paper>
)}
</Box>
</Collapse>
</Paper>
)
}
export default ChatMapPanel