555 lines
17 KiB
TypeScript
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: '© 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<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
|