367 lines
12 KiB
TypeScript
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
|