glam/apps/archief-assistent/src/pages/MapPage.tsx
2025-12-21 00:01:54 +01:00

367 lines
12 KiB
TypeScript

/**
* MapPage.tsx - Heritage Institution Map
*
* Interactive map showing heritage institutions from the GLAM database.
* Styled for Nationaal Archief theme.
*/
import { useEffect, useRef, useState } from 'react'
import maplibregl from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import {
Box,
Typography,
Paper,
CircularProgress,
Chip,
IconButton,
FormControlLabel,
Switch,
} from '@mui/material'
import CloseIcon from '@mui/icons-material/Close'
import PlaceIcon from '@mui/icons-material/Place'
import OpenInNewIcon from '@mui/icons-material/OpenInNew'
// NA Color palette
const naColors = {
primary: '#007bc7',
red: '#d52b1e',
orange: '#e17000',
green: '#39870c',
cream: '#f7f5f3',
darkBlue: '#154273',
lightBlue: '#e5f0f9',
}
// Institution type info
const TYPE_INFO: Record<string, { name: string; icon: string; color: string }> = {
'G': { name: 'Gallery', icon: '🖼️', color: '#00bcd4' },
'L': { name: 'Library', icon: '📚', color: '#2ecc71' },
'A': { name: 'Archive', icon: '📜', color: '#3498db' },
'M': { name: 'Museum', icon: '🏛️', color: '#e74c3c' },
'O': { name: 'Official', icon: '🏢', color: '#f39c12' },
'R': { name: 'Research', icon: '🔬', color: '#1abc9c' },
'C': { name: 'Corporation', icon: '🏭', color: '#795548' },
'U': { name: 'Unknown', icon: '❓', color: '#9e9e9e' },
'B': { name: 'Botanical/Zoo', icon: '🌿', color: '#4caf50' },
'E': { name: 'Education', icon: '🎓', color: '#ff9800' },
'S': { name: 'Society', icon: '👥', color: '#9b59b6' },
}
interface Institution {
id: string
name: string
type?: string
city?: string
country?: string
latitude?: number
longitude?: number
website?: string
description?: string
}
function MapPage() {
const mapContainer = useRef<HTMLDivElement>(null)
const map = useRef<maplibregl.Map | null>(null)
const [loading, setLoading] = useState(true)
const [institutions, setInstitutions] = useState<Institution[]>([])
const [selectedInstitution, setSelectedInstitution] = useState<Institution | null>(null)
const [showOnlyNetherlands, setShowOnlyNetherlands] = useState(true)
const [stats, setStats] = useState({ total: 0, displayed: 0 })
// Fetch institutions from GeoAPI - use lightweight endpoint for map rendering
useEffect(() => {
const fetchInstitutions = async () => {
try {
// Use /institutions/lite with country filter for faster loading
// This returns only essential fields: ghcid, name, type, city, country_code, rating, coordinates
const url = showOnlyNetherlands
? '/api/geo/institutions/lite?country=NL'
: '/api/geo/institutions/lite?limit=5000'
const response = await fetch(url)
if (!response.ok) throw new Error('Failed to fetch institutions')
const geojson = await response.json()
// Convert GeoJSON features to Institution objects
const insts: Institution[] = geojson.features?.map((f: {
properties: Record<string, unknown>
geometry?: { coordinates?: [number, number] }
}) => ({
id: f.properties.ghcid as string || f.properties.id as string || Math.random().toString(),
name: f.properties.name as string || 'Unknown',
type: f.properties.type as string,
city: f.properties.city as string,
country: f.properties.country_code as string,
latitude: f.geometry?.coordinates?.[1],
longitude: f.geometry?.coordinates?.[0],
website: f.properties.website as string,
description: f.properties.description as string,
})) || []
setInstitutions(insts)
setStats({ total: insts.length, displayed: insts.length })
} catch (error) {
console.error('Error fetching institutions:', error)
} finally {
setLoading(false)
}
}
fetchInstitutions()
}, [showOnlyNetherlands])
// Initialize map
useEffect(() => {
if (!mapContainer.current || map.current) return
map.current = new maplibregl.Map({
container: mapContainer.current,
style: 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
center: [5.2913, 52.1326], // Netherlands center
zoom: 7,
})
map.current.addControl(new maplibregl.NavigationControl(), 'top-right')
return () => {
map.current?.remove()
map.current = null
}
}, [])
// Add markers when institutions change
useEffect(() => {
if (!map.current || institutions.length === 0) return
// Filter institutions
const filtered = showOnlyNetherlands
? institutions.filter(i => i.country === 'NL')
: institutions
setStats(prev => ({ ...prev, displayed: filtered.length }))
// Clear existing markers
const existingMarkers = document.querySelectorAll('.institution-marker')
existingMarkers.forEach(m => m.remove())
// Add markers for each institution
filtered.forEach(inst => {
if (!inst.latitude || !inst.longitude) return
const typeInfo = TYPE_INFO[inst.type || 'U'] || TYPE_INFO['U']
// Create marker element
const el = document.createElement('div')
el.className = 'institution-marker'
el.style.cssText = `
width: 24px;
height: 24px;
background: ${typeInfo.color};
border: 2px solid white;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
`
el.innerHTML = typeInfo.icon
el.addEventListener('click', () => {
setSelectedInstitution(inst)
})
new maplibregl.Marker({ element: el })
.setLngLat([inst.longitude, inst.latitude])
.addTo(map.current!)
})
// Fit bounds if filtering
if (showOnlyNetherlands) {
map.current.flyTo({ center: [5.2913, 52.1326], zoom: 7 })
} else {
map.current.flyTo({ center: [5.2913, 52.1326], zoom: 3 })
}
}, [institutions, showOnlyNetherlands])
return (
<Box sx={{ position: 'relative', flex: 1, minHeight: 500, height: 'calc(100vh - 180px)', display: 'flex', flexDirection: 'column' }}>
{/* Header */}
<Box sx={{
position: 'absolute',
top: 16,
left: 16,
zIndex: 10,
display: 'flex',
flexDirection: 'column',
gap: 1,
}}>
<Paper sx={{ p: 2, bgcolor: 'rgba(255,255,255,0.95)' }}>
<Typography
variant="h5"
sx={{
fontFamily: 'Georgia, serif',
fontStyle: 'italic',
color: naColors.primary,
mb: 1,
}}
>
Erfgoedinstellingen Kaart
</Typography>
<Typography variant="body2" color="text.secondary">
{stats.displayed.toLocaleString()} van {stats.total.toLocaleString()} instellingen
</Typography>
<FormControlLabel
control={
<Switch
checked={showOnlyNetherlands}
onChange={(e) => setShowOnlyNetherlands(e.target.checked)}
sx={{
'& .MuiSwitch-switchBase.Mui-checked': { color: naColors.primary },
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: naColors.primary },
}}
/>
}
label="Alleen Nederland"
sx={{ mt: 1 }}
/>
</Paper>
{/* Legend */}
<Paper sx={{ p: 1.5, bgcolor: 'rgba(255,255,255,0.95)', maxWidth: 200 }}>
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 1 }}>
Legenda
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{Object.entries(TYPE_INFO).slice(0, 6).map(([code, info]) => (
<Chip
key={code}
label={info.icon + ' ' + info.name}
size="small"
sx={{
height: 22,
fontSize: '0.65rem',
bgcolor: info.color,
color: '#fff',
}}
/>
))}
</Box>
</Paper>
</Box>
{/* Map Container */}
<Box
ref={mapContainer}
sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 1,
}}
/>
{/* Loading Overlay */}
{loading && (
<Box sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'rgba(255,255,255,0.8)',
zIndex: 20,
}}>
<Box sx={{ textAlign: 'center' }}>
<CircularProgress sx={{ color: naColors.primary }} />
<Typography sx={{ mt: 2 }}>Kaart laden...</Typography>
</Box>
</Box>
)}
{/* Institution Detail Panel */}
{selectedInstitution && (
<Paper
sx={{
position: 'absolute',
top: 16,
right: 16,
width: 320,
maxHeight: 'calc(100% - 32px)',
overflow: 'auto',
zIndex: 15,
p: 2,
}}
elevation={4}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
<Typography variant="h6" sx={{ fontWeight: 600, color: naColors.primary }}>
{selectedInstitution.name}
</Typography>
<IconButton size="small" onClick={() => setSelectedInstitution(null)}>
<CloseIcon />
</IconButton>
</Box>
{selectedInstitution.type && (
<Chip
label={TYPE_INFO[selectedInstitution.type]?.name || selectedInstitution.type}
size="small"
sx={{
mb: 2,
bgcolor: TYPE_INFO[selectedInstitution.type]?.color || '#999',
color: '#fff',
}}
/>
)}
{(selectedInstitution.city || selectedInstitution.country) && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<PlaceIcon sx={{ fontSize: 18, color: 'text.secondary' }} />
<Typography variant="body2" color="text.secondary">
{[selectedInstitution.city, selectedInstitution.country].filter(Boolean).join(', ')}
</Typography>
</Box>
)}
{selectedInstitution.description && (
<Typography variant="body2" sx={{ mt: 2, lineHeight: 1.6 }}>
{selectedInstitution.description}
</Typography>
)}
{selectedInstitution.website && (
<Box
component="a"
href={selectedInstitution.website}
target="_blank"
rel="noopener noreferrer"
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
mt: 2,
color: naColors.primary,
textDecoration: 'none',
'&:hover': { textDecoration: 'underline' },
}}
>
Website <OpenInNewIcon sx={{ fontSize: 14 }} />
</Box>
)}
</Paper>
)}
</Box>
)
}
export default MapPage