glam/frontend/src/pages/NDEMapPageMapLibre.tsx

2482 lines
97 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* NDE Map Page - Interactive map of Dutch heritage institutions
*
* MapLibre GL version - replaces Leaflet for better performance and no CSS warnings.
*
* Displays 712+ heritage institutions with coordinates from Wikidata enrichment.
* Uses MapLibre GL with GeoJSON layers for performance.
*
* Supports URL query params for filtering/highlighting:
* - ?province=Noord-Holland - Filter by province
* - ?type=M - Filter by institution type code
* - ?city=Amsterdam - Filter by city
* - ?highlight=Museum%20Name - Highlight a specific institution
*/
import { useEffect, useRef, useState, useMemo, useCallback } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import maplibregl from 'maplibre-gl';
import type { StyleSpecification, MapLayerMouseEvent, GeoJSONSource } from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { useLanguage } from '../contexts/LanguageContext';
import { useUIState } from '../contexts/UIStateContext';
import { useFullscreen, useCollapsibleHeader } from '../hooks/useCollapsibleHeader';
import { useWerkgebiedMapLibre, type ServiceArea } from '../hooks/useWerkgebiedMapLibre';
import { useDuckLakeInstitutions } from '../hooks/useDuckLakeInstitutions';
import { useGeoApiInstitutions, useLiteInstitutions, useInstitutionDetail, type LiteInstitution } from '../hooks/useGeoApiInstitutions';
import { useProgressiveInstitutions } from '../hooks/useProgressiveInstitutions';
import { useGeoApiCountries } from '../hooks/useGeoApiCountries';
import { InstitutionInfoPanel, type Institution } from '../components/map/InstitutionInfoPanel';
import { TimelineSlider, type TemporalData } from '../components/map/TimelineSlider';
import { LoadingScreen } from '../components/LoadingScreen';
import type { GenealogiewerkbalkData } from '../types/werkgebied';
import { Menu, X, ChevronDown, ChevronRight, AlertTriangle, Database, Globe, Zap } from 'lucide-react';
import { CacheStatusIndicator } from '../components/common/CacheStatusIndicator';
import './NDEMapPage.css';
import '../styles/collapsible.css';
// Mobile detection and swipe gesture constants
const SWIPE_THRESHOLD = 50;
const EDGE_ZONE = 30;
// Zoom level threshold for switching between country polygons and institution points
// At zoom < COUNTRY_ZOOM_THRESHOLD, show country polygons with counts
// At zoom >= COUNTRY_ZOOM_THRESHOLD, show individual institution markers
const COUNTRY_ZOOM_THRESHOLD = 5;
// Bilingual text for the search UI
const SEARCH_TEXT = {
placeholder: { nl: 'Zoek erfgoedinstelling...', en: 'Search heritage custodian...' },
noResults: { nl: 'Geen resultaten gevonden', en: 'No results found' },
resultsCount: { nl: 'resultaten', en: 'results' },
showHeader: { nl: 'Toon kop', en: 'Show header' },
hideHeader: { nl: 'Verberg kop', en: 'Hide header' },
showingFirst: { nl: 'Toon eerste', en: 'Showing first' },
of: { nl: 'van', en: 'of' },
showMore: { nl: 'Toon meer', en: 'Show more' },
showAll: { nl: 'Toon alle', en: 'Show all' },
};
// Maximum results to show initially before "show more"
const MAX_INITIAL_RESULTS = 10;
// Custodian type colors matching the 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, { nl: string; en: string }> = {
G: { nl: 'Galerie', en: 'Gallery' },
L: { nl: 'Bibliotheek', en: 'Library' },
A: { nl: 'Archief', en: 'Archive' },
M: { nl: 'Museum', en: 'Museum' },
O: { nl: 'Officieel', en: 'Official' },
R: { nl: 'Onderzoek', en: 'Research' },
C: { nl: 'Bedrijf', en: 'Corporation' },
U: { nl: 'Onbekend', en: 'Unknown' },
B: { nl: 'Botanisch', en: 'Botanical' },
E: { nl: 'Onderwijs', en: 'Education' },
S: { nl: 'Vereniging', en: 'Society' },
F: { nl: 'Monumenten', en: 'Features' },
I: { nl: 'Immaterieel', en: 'Intangible' },
X: { nl: 'Gemengd', en: 'Mixed' },
P: { nl: 'Persoonlijk', en: 'Personal' },
H: { nl: 'Heilige plaatsen', en: 'Holy sites' },
D: { nl: 'Digitaal', en: 'Digital' },
N: { nl: 'NGO', en: 'NGO' },
T: { nl: 'Smaak/geur', en: 'Taste/smell' },
};
// Map tile styles for light and dark modes
const getMapStyle = (isDarkMode: boolean): StyleSpecification => {
if (isDarkMode) {
// CartoDB Dark Matter - dark mode tiles
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',
'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
],
tileSize: 256,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>',
},
},
layers: [
{
id: 'carto-dark-tiles',
type: 'raster',
source: 'carto-dark',
minzoom: 0,
maxzoom: 19,
},
],
};
} else {
// OpenStreetMap - light mode tiles
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',
'https://c.tile.openstreetmap.org/{z}/{x}/{y}.png',
],
tileSize: 256,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
},
},
layers: [
{
id: 'osm-tiles',
type: 'raster',
source: 'osm',
minzoom: 0,
maxzoom: 19,
},
],
};
}
};
// Static JSON fallback URLs (used when DuckLake is not connected)
const INSTITUTIONS_URL = '/data/nde_institutions.json';
const METADATA_URL = '/data/nde_metadata.json';
interface EnrichmentSource {
name: string;
name_nl: string;
count: number;
description: string;
description_nl: string;
}
interface Metadata {
generated_at: string;
total_entries: number;
total_with_coordinates: number;
enrichment_sources: Record<string, EnrichmentSource>;
}
// Convert institutions to GeoJSON FeatureCollection
function institutionsToGeoJSON(institutions: Institution[]): GeoJSON.FeatureCollection {
return {
type: 'FeatureCollection',
features: institutions.map((inst, index) => ({
type: 'Feature' as const,
id: index,
geometry: {
type: 'Point' as const,
coordinates: [inst.lon, inst.lat],
},
properties: {
index,
name: inst.name,
type: inst.type,
color: inst.color || TYPE_COLORS[inst.type] || '#6b7280',
city: inst.city || '',
province: inst.province || '',
rating: inst.rating || null,
website: inst.website || '',
wikidata_id: inst.wikidata_id || '',
google_place_id: inst.google_place_id || '',
isil_code: inst.isil?.code || '',
museum_register: inst.museum_register || false,
youtube: inst.youtube || '',
has_genealogiewerkbalk: !!inst.genealogiewerkbalk,
},
})),
};
}
export default function NDEMapPage() {
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<maplibregl.Map | null>(null);
const [mapReady, setMapReady] = useState(false);
const mapContainerRef = useRef<HTMLDivElement>(null);
const sidebarRef = useRef<HTMLDivElement>(null);
const [institutions, setInstitutions] = useState<Institution[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [usingFallback, setUsingFallback] = useState(false); // True when DuckLake fails and we use static JSON
// dataSourceMode is now managed via UIState context (see useUIState below)
const [activeDataSource, setActiveDataSource] = useState<'ducklake' | 'geoapi' | 'geoapi-lite' | 'progressive' | 'fallback'>('progressive'); // Which source is actually being used
// State for on-demand detail fetching (used with geoapi-lite mode)
const [selectedGhcid, setSelectedGhcid] = useState<string | null>(null);
const [_isDetailLoading, setIsDetailLoading] = useState(false);
const [selectedTypes, setSelectedTypes] = useState<Set<string>>(new Set(Object.keys(TYPE_COLORS)));
const [_metadata, setMetadata] = useState<Metadata | null>(null);
// Search state
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<Institution[]>([]);
const [showSearchResults, setShowSearchResults] = useState(false);
const [visibleResultsCount, setVisibleResultsCount] = useState(MAX_INITIAL_RESULTS);
const searchInputRef = useRef<HTMLInputElement>(null);
const [stats, setStats] = useState<{
total: number;
byType: Record<string, number>;
}>({
total: 0,
byType: {},
});
// Collapsible filter section state
const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>({
custodianType: false,
dataSource: true,
location: true,
reviewScore: true,
});
// Selected filters
const [selectedSources, setSelectedSources] = useState<Set<string>>(new Set());
const [selectedProvinces, setSelectedProvinces] = useState<Set<string>>(new Set());
const [selectedCities, setSelectedCities] = useState<Set<string>>(new Set());
const [selectedMinRating, setSelectedMinRating] = useState<number | null>(null);
// Timeline state
const MIN_YEAR = 0; // Year 0 to support ancient custodians
const MAX_YEAR = new Date().getFullYear();
const [timelineRange, setTimelineRange] = useState<[number, number]>([MIN_YEAR, MAX_YEAR]);
const [isTimelineActive, setIsTimelineActive] = useState(false);
// DEBUG: Log isTimelineActive state changes
useEffect(() => {
console.log(`[Timeline State] isTimelineActive changed to: ${isTimelineActive}`);
}, [isTimelineActive]);
// Selected institution state
const [selectedInstitution, setSelectedInstitution] = useState<Institution | null>(null);
const [markerScreenPosition, setMarkerScreenPosition] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
// Pinned institutions
const [pinnedInstitutions, setPinnedInstitutions] = useState<Map<string, { institution: Institution; position: { x: number; y: number } }>>(new Map());
// Zoom level state for switching between country polygons and institution points
const [currentZoom, setCurrentZoom] = useState(7); // Default zoom level
const showCountryPolygons = currentZoom < COUNTRY_ZOOM_THRESHOLD;
// Map bounds state for bbox filtering when zoomed in
// Currently tracked for future server-side bbox queries (when dataset grows beyond client-side capacity)
// For now, all institutions are loaded and filtered client-side
const [_mapBounds, setMapBounds] = useState<[number, number, number, number] | null>(null);
// Loading state for view transitions
const [isTransitioningView, setIsTransitioningView] = useState(false);
// Hover popup state for countries
const [hoveredCountry, setHoveredCountry] = useState<{name: string; count: number; position: {x: number; y: number}} | null>(null);
// Werkgebied state - track which institution's werkgebied is shown
const [werkgebiedInstitutionKey, setWerkgebiedInstitutionKey] = useState<string | null>(null);
const { t, language } = useLanguage();
const { state: uiState } = useUIState();
// Data source mode from persistent settings (selection UI is in SettingsPanel)
const dataSourceMode = uiState.dataBackend;
const { isFullscreen, toggleFullscreen } = useFullscreen(mapContainerRef);
const { isCollapsed: isHeaderCollapsed, toggleCollapsed: toggleHeaderCollapsed } = useCollapsibleHeader(sidebarRef, { threshold: 80 });
// Determine effective theme (resolve 'system' to actual theme)
const isDarkMode = useMemo(() => {
if (uiState.theme === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
return uiState.theme === 'dark';
}, [uiState.theme]);
// Werkgebied hook - must be called after mapInstanceRef is available
const werkgebied = useWerkgebiedMapLibre(mapReady ? mapInstanceRef.current : null);
// Mobile state
const [isMobile, setIsMobile] = useState<boolean>(false);
const [sidebarOpen, setSidebarOpen] = useState<boolean>(true);
const touchStartX = useRef<number>(0);
const touchStartY = useRef<number>(0);
const touchStartedInEdge = useRef<boolean>(false);
const overlayRef = useRef<HTMLDivElement>(null);
// Mobile detection
useEffect(() => {
const checkMobile = () => {
const mobile = window.innerWidth <= 900;
const wasMobile = isMobile;
setIsMobile(mobile);
// Only auto-adjust sidebar when transitioning between mobile and desktop
if (mobile && !wasMobile) {
// Switching to mobile: collapse sidebar
setSidebarOpen(false);
} else if (!mobile && wasMobile) {
// Switching to desktop: expand sidebar
setSidebarOpen(true);
}
};
// Initial check - set defaults based on screen size
const mobile = window.innerWidth <= 900;
setIsMobile(mobile);
setSidebarOpen(!mobile);
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
// Swipe gesture handling
useEffect(() => {
if (!isMobile) return;
const handleTouchStart = (e: TouchEvent) => {
const touch = e.touches[0];
touchStartX.current = touch.clientX;
touchStartY.current = touch.clientY;
touchStartedInEdge.current = touch.clientX <= EDGE_ZONE || sidebarOpen;
};
const handleTouchMove = (e: TouchEvent) => {
if (!touchStartedInEdge.current) return;
const touch = e.touches[0];
const deltaX = touch.clientX - touchStartX.current;
const deltaY = touch.clientY - touchStartY.current;
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 10) {
e.preventDefault();
}
};
const handleTouchEnd = (e: TouchEvent) => {
if (!touchStartedInEdge.current) return;
const touch = e.changedTouches[0];
const deltaX = touch.clientX - touchStartX.current;
const deltaY = touch.clientY - touchStartY.current;
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > SWIPE_THRESHOLD) {
if (deltaX > 0 && !sidebarOpen) {
setSidebarOpen(true);
} else if (deltaX < 0 && sidebarOpen) {
setSidebarOpen(false);
}
}
touchStartedInEdge.current = false;
};
document.addEventListener('touchstart', handleTouchStart, { passive: true });
document.addEventListener('touchmove', handleTouchMove, { passive: false });
document.addEventListener('touchend', handleTouchEnd, { passive: true });
return () => {
document.removeEventListener('touchstart', handleTouchStart);
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleTouchEnd);
};
}, [isMobile, sidebarOpen]);
const getTypeName = useCallback((typeCode: string): string => {
const names = TYPE_NAMES[typeCode];
return names ? (language === 'nl' ? names.nl : names.en) : typeCode;
}, [language]);
// Search handler
const handleSearch = useCallback((query: string) => {
setSearchQuery(query);
setVisibleResultsCount(MAX_INITIAL_RESULTS); // Reset to initial count on new search
if (!query.trim()) {
setSearchResults([]);
setShowSearchResults(false);
return;
}
const searchLower = query.toLowerCase().trim();
const words = searchLower.split(/\s+/).filter(w => w.length > 1);
const results = institutions.filter(inst => {
const nameLower = inst.name.toLowerCase();
const cityLower = (inst.city || '').toLowerCase();
const provinceLower = (inst.province || '').toLowerCase();
const typeName = getTypeName(inst.type).toLowerCase();
return words.every(word =>
nameLower.includes(word) ||
cityLower.includes(word) ||
provinceLower.includes(word) ||
typeName.includes(word)
);
}).slice(0, 50); // Increased limit to allow "show more" functionality
setSearchResults(results);
setShowSearchResults(true);
}, [institutions, getTypeName]);
const handleSelectSearchResult = useCallback((inst: Institution) => {
setSearchQuery('');
setShowSearchResults(false);
setSearchParams({ highlight: inst.name });
}, [setSearchParams]);
const toggleSection = useCallback((section: string) => {
setCollapsedSections(prev => ({
...prev,
[section]: !prev[section]
}));
}, []);
// Extended stats
const extendedStats = useMemo(() => {
if (institutions.length === 0) {
return {
dataSources: {},
provinces: {},
cities: {},
ratingBuckets: {},
};
}
const dataSources: Record<string, number> = {};
const provinces: Record<string, number> = {};
const cities: Record<string, number> = {};
const ratingBuckets: Record<string, number> = {
'4.5+': 0,
'4.0+': 0,
'3.5+': 0,
'3.0+': 0,
'no_rating': 0,
};
institutions.forEach((inst) => {
if (inst.museum_register) dataSources['museum_register'] = (dataSources['museum_register'] || 0) + 1;
if (inst.youtube) dataSources['youtube'] = (dataSources['youtube'] || 0) + 1;
if (inst.isil) {
dataSources['isil'] = (dataSources['isil'] || 0) + 1;
const source = inst.isil.source;
if (source === 'Nationaal Archief ISIL Registry') {
dataSources['isil_na'] = (dataSources['isil_na'] || 0) + 1;
} else if (source === 'KB Netherlands Library Network') {
dataSources['isil_kb'] = (dataSources['isil_kb'] || 0) + 1;
}
}
if (inst.wikidata_id) dataSources['wikidata'] = (dataSources['wikidata'] || 0) + 1;
if (inst.google_place_id || inst.rating) dataSources['google_maps'] = (dataSources['google_maps'] || 0) + 1;
if (inst.website) dataSources['website'] = (dataSources['website'] || 0) + 1;
if (inst.identifiers?.some(id => id.scheme === 'ZCBS')) dataSources['zcbs'] = (dataSources['zcbs'] || 0) + 1;
if (inst.genealogiewerkbalk) dataSources['genealogiewerkbalk'] = (dataSources['genealogiewerkbalk'] || 0) + 1;
dataSources['nde'] = (dataSources['nde'] || 0) + 1;
if (inst.province) provinces[inst.province] = (provinces[inst.province] || 0) + 1;
if (inst.city) cities[inst.city] = (cities[inst.city] || 0) + 1;
if (inst.rating !== undefined && inst.rating !== null) {
if (inst.rating >= 4.5) ratingBuckets['4.5+']++;
if (inst.rating >= 4.0) ratingBuckets['4.0+']++;
if (inst.rating >= 3.5) ratingBuckets['3.5+']++;
if (inst.rating >= 3.0) ratingBuckets['3.0+']++;
} else {
ratingBuckets['no_rating']++;
}
});
return { dataSources, provinces, cities, ratingBuckets };
}, [institutions]);
// Prepare temporal data for timeline component
// Now uses founding_year and dissolution_year directly from Institution object
// (extracted from DuckLake temporal columns in useDuckLakeInstitutions)
const institutionsWithTemporal = useMemo(() => {
return institutions.map(inst => {
// Use pre-extracted years from DuckLake (already parsed in useDuckLakeInstitutions)
const foundingYear = inst.founding_year;
const foundingDecade = inst.founding_decade;
const dissolutionYear = inst.dissolution_year;
// Fallback to temporal_extent for is_operational/is_defunct flags
const temporalExtent = inst.temporal_extent;
const temporal: TemporalData = {
founding_year: foundingYear,
founding_decade: foundingDecade,
dissolution_year: dissolutionYear,
is_operational: temporalExtent?.is_operational,
is_defunct: temporalExtent?.is_defunct,
};
return {
name: inst.name,
temporal,
// Keep reference to original for filtering
_original: inst,
};
});
}, [institutions]);
// DEBUG: Log temporal data being passed to timeline
useEffect(() => {
const withTemporal = institutionsWithTemporal.filter(i => i.temporal?.founding_year || i.temporal?.dissolution_year);
console.log(`[Timeline] institutionsWithTemporal: ${institutionsWithTemporal.length} total, ${withTemporal.length} with temporal data`);
if (withTemporal.length > 0 && withTemporal.length <= 10) {
withTemporal.forEach(i => console.log(` - ${i.name}: founding=${i.temporal?.founding_year}, dissolution=${i.temporal?.dissolution_year}`));
}
}, [institutionsWithTemporal]);
// Close search results on outside click
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (searchInputRef.current && !searchInputRef.current.contains(e.target as Node)) {
const resultsContainer = document.querySelector('.map-search-results');
if (resultsContainer && !resultsContainer.contains(e.target as Node)) {
setShowSearchResults(false);
}
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleClosePanel = useCallback(() => {
setSelectedInstitution(null);
}, []);
const handleTogglePin = useCallback((currentPanelPosition?: { x: number; y: number }) => {
if (!selectedInstitution) return;
const key = selectedInstitution.ghcid?.uuid || selectedInstitution.name;
setPinnedInstitutions(prev => {
const newPinned = new Map(prev);
if (newPinned.has(key)) {
newPinned.delete(key);
} else {
// Use current panel position if provided (for pinned panels to stay in place),
// otherwise fall back to marker position
const positionToStore = currentPanelPosition || markerScreenPosition;
newPinned.set(key, {
institution: selectedInstitution,
position: positionToStore
});
}
return newPinned;
});
}, [selectedInstitution, markerScreenPosition]);
const handleClosePinnedPanel = useCallback((key: string) => {
setPinnedInstitutions(prev => {
const newPinned = new Map(prev);
newPinned.delete(key);
return newPinned;
});
}, []);
const handleTogglePinnedPin = useCallback((key: string) => {
setPinnedInstitutions(prev => {
const newPinned = new Map(prev);
newPinned.delete(key);
return newPinned;
});
}, []);
// Werkgebied handlers - show service area polygons for archives
const handleShowWerkgebied = useCallback((institutionName: string, isil?: string, genealogiewerkbalk?: GenealogiewerkbalkData) => {
setWerkgebiedInstitutionKey(isil || institutionName);
werkgebied.showWerkgebiedForInstitution(institutionName, isil, genealogiewerkbalk);
}, [werkgebied]);
const handleHideWerkgebied = useCallback(() => {
setWerkgebiedInstitutionKey(null);
werkgebied.hideWerkgebied();
}, [werkgebied]);
// Handler for showing service_area from institution YAML (non-Dutch institutions)
const handleShowServiceArea = useCallback(async (serviceArea: ServiceArea | undefined): Promise<boolean> => {
if (!serviceArea) return false;
const result = await werkgebied.showServiceArea(serviceArea);
if (result) {
// Use admin2_name or admin1_name as the key
setWerkgebiedInstitutionKey(serviceArea.admin2_name || serviceArea.admin1_name || 'service-area');
}
return result;
}, [werkgebied]);
// URL filter params
const provinceFilter = searchParams.get('province');
const typeFilter = searchParams.get('type');
const cityFilter = searchParams.get('city');
const highlightName = searchParams.get('highlight');
const ghcidFilter = searchParams.get('ghcid');
const enrichedFilter = searchParams.get('enriched');
const sourceFilter = searchParams.get('source');
const hasFilter = searchParams.get('has');
const minRatingFilter = searchParams.get('min_rating');
const wikidataTypeFilter = searchParams.get('wikidata_type');
const foundingDecadeFilter = searchParams.get('founding_decade');
// URL params for direct positioning (from Browse page "View on Map" button)
const urlLat = searchParams.get('lat');
const urlLon = searchParams.get('lon');
const urlZoom = searchParams.get('zoom');
// Find matching institution
const highlightMatch = useMemo(() => {
if (!highlightName || institutions.length === 0) return null;
const searchLower = highlightName.toLowerCase();
let match = institutions.find(inst => inst.name === highlightName);
if (match) return { institution: match, matchType: 'exact' as const };
match = institutions.find(inst => inst.name.toLowerCase() === searchLower);
if (match) return { institution: match, matchType: 'case-insensitive' as const };
match = institutions.find(inst => inst.name.toLowerCase().includes(searchLower));
if (match) return { institution: match, matchType: 'partial' as const };
match = institutions.find(inst => searchLower.includes(inst.name.toLowerCase()));
if (match) return { institution: match, matchType: 'reverse-partial' as const };
const searchWords = searchLower.split(/\s+/).filter(w => w.length > 2);
if (searchWords.length > 0) {
match = institutions.find(inst => {
const nameLower = inst.name.toLowerCase();
return searchWords.some(word => nameLower.includes(word));
});
if (match) return { institution: match, matchType: 'word' as const };
}
return null;
}, [highlightName, institutions]);
// GHCID matching - supports both UUID and human-readable GHCID strings
const ghcidMatch = useMemo(() => {
if (!ghcidFilter || institutions.length === 0) return null;
const ghcidSet = new Set(ghcidFilter.split(',').map(id => id.trim()));
const matches = institutions.filter(inst =>
(inst.ghcid?.uuid && ghcidSet.has(inst.ghcid.uuid)) ||
(inst.ghcid?.current && ghcidSet.has(inst.ghcid.current))
);
if (matches.length === 0) return null;
return matches.length === 1
? { institution: matches[0], matchType: 'ghcid' as const }
: { institutions: matches, matchType: 'ghcid-multi' as const };
}, [ghcidFilter, institutions]);
const clearUrlFilters = () => setSearchParams({});
const clearAllFilters = () => {
setSearchParams({});
setSelectedTypes(new Set(Object.keys(TYPE_COLORS)));
setSelectedSources(new Set());
setSelectedProvinces(new Set());
setSelectedCities(new Set());
setSelectedMinRating(null);
setIsTimelineActive(false);
setTimelineRange([MIN_YEAR, MAX_YEAR]);
};
const hasUrlFilters = provinceFilter || typeFilter || cityFilter || highlightName || ghcidFilter ||
enrichedFilter || sourceFilter || hasFilter || minRatingFilter || wikidataTypeFilter || foundingDecadeFilter;
const hasSidebarFilters = selectedProvinces.size > 0 || selectedCities.size > 0 ||
selectedSources.size > 0 || selectedMinRating !== null || isTimelineActive;
const hasAnyFilters = hasUrlFilters || hasSidebarFilters;
// Filtered institutions for the map
const filteredInstitutions = useMemo(() => {
const result = institutions.filter((inst) => {
if (!selectedTypes.has(inst.type)) return false;
// Timeline filter - use founding_year and dissolution_year directly from Institution
// (already extracted from DuckLake temporal columns in useDuckLakeInstitutions)
if (isTimelineActive) {
const foundingYear = inst.founding_year || inst.founding_decade;
const dissolutionYear = inst.dissolution_year;
// If no temporal data at all, HIDE the institution when timeline filter is active
if (!foundingYear && !dissolutionYear) {
return false;
}
// If ONLY dissolution_year exists (no founding_year), we cannot determine
// when the institution was founded, so we HIDE it (conservative approach)
// These institutions need temporal data enrichment
if (!foundingYear && dissolutionYear) {
return false;
}
// Institution is visible if:
// 1. Founded before or during the selected end year
if (foundingYear && foundingYear > timelineRange[1]) {
return false;
}
// 2. AND (still operational OR dissolved after the selected start year)
if (dissolutionYear && dissolutionYear < timelineRange[0]) {
return false;
}
}
// Province filter
if (provinceFilter && inst.province !== provinceFilter) return false;
if (selectedProvinces.size > 0 && inst.province && !selectedProvinces.has(inst.province)) return false;
// Type filter
if (typeFilter && inst.type !== typeFilter) return false;
// City filter
if (cityFilter && inst.city !== cityFilter) return false;
if (selectedCities.size > 0 && inst.city && !selectedCities.has(inst.city)) return false;
// Source filters
if (selectedSources.size > 0) {
const hasSource = [...selectedSources].some(source => {
switch (source) {
case 'museum_register': return Boolean(inst.museum_register);
case 'youtube': return Boolean(inst.youtube);
case 'isil': return Boolean(inst.isil);
case 'isil_na': return Boolean(inst.isil && inst.isil.source === 'Nationaal Archief ISIL Registry');
case 'isil_kb': return Boolean(inst.isil && inst.isil.source === 'KB Netherlands Library Network');
case 'wikidata': return Boolean(inst.wikidata_id);
case 'google_maps': return Boolean(inst.google_place_id || inst.rating);
case 'website': return Boolean(inst.website);
case 'zcbs': return Boolean(inst.identifiers?.some(id => id.scheme === 'ZCBS'));
case 'genealogiewerkbalk': return Boolean(inst.genealogiewerkbalk);
case 'nde': return true;
default: return true;
}
});
if (!hasSource) return false;
}
// Rating filter
if (selectedMinRating !== null) {
if (selectedMinRating === 0) {
if (inst.rating !== undefined && inst.rating !== null) return false;
} else {
if (!inst.rating || inst.rating < selectedMinRating) return false;
}
}
// URL-based filters
if (enrichedFilter) {
const isEnriched = Boolean(inst.rating || inst.google_place_id || (inst.reviews && inst.reviews.length > 0));
if (enrichedFilter === 'true' && !isEnriched) return false;
if (enrichedFilter === 'false' && isEnriched) return false;
}
if (minRatingFilter) {
if (!inst.rating || inst.rating < parseFloat(minRatingFilter)) return false;
}
if (wikidataTypeFilter && !inst.wikidata_types?.includes(wikidataTypeFilter)) return false;
if (foundingDecadeFilter) {
const filterDecade = parseInt(foundingDecadeFilter, 10);
if (inst.founding_decade !== filterDecade) return false;
}
return true;
});
return result;
}, [institutions, selectedTypes, provinceFilter, typeFilter, cityFilter, enrichedFilter,
sourceFilter, hasFilter, minRatingFilter, wikidataTypeFilter, foundingDecadeFilter,
selectedProvinces, selectedCities, selectedSources, selectedMinRating,
isTimelineActive, timelineRange]);
const visibleCount = filteredInstitutions.length;
// DuckLake hook for live data (client-side WASM database)
const duckLakeData = useDuckLakeInstitutions();
// Geo API hook for server-side PostGIS data (full 126MB)
const geoApiData = useGeoApiInstitutions();
// Geo API Lite hook for fast map markers (~5MB)
const liteData = useLiteInstitutions();
// Progressive loading hook - combines cache + lite + background full load
const progressiveData = useProgressiveInstitutions();
// Geo API Detail hook for on-demand full data fetch (when clicking markers in lite mode)
const detailData = useInstitutionDetail(selectedGhcid);
// Effect to update selectedInstitution when full details arrive (geoapi-lite mode)
useEffect(() => {
if (detailData.institution && !detailData.isLoading) {
setSelectedInstitution(detailData.institution);
setIsDetailLoading(false);
}
if (detailData.error) {
console.error('[NDEMapPage] Detail fetch error:', detailData.error);
setIsDetailLoading(false);
}
}, [detailData.institution, detailData.isLoading, detailData.error, selectedGhcid]);
// Geo API hook for country boundaries (used at low zoom levels)
// Pass type filter based on selected types (if all selected, pass undefined; otherwise pass array)
const selectedTypesArray = [...selectedTypes];
const allTypesCount = Object.keys(TYPE_COLORS).length;
const typeFilterForCountries = selectedTypesArray.length === allTypesCount
? undefined
: selectedTypesArray.length === 1
? selectedTypesArray[0]
: selectedTypesArray;
const countriesData = useGeoApiCountries(typeFilterForCountries);
// Load institutions data based on selected data source mode
useEffect(() => {
console.log('[NDEMapPage] loadInstitutions effect triggered', {
dataSourceMode,
progressiveReady: progressiveData.isReady,
progressivePhase: progressiveData.state.phase,
progressiveDataLevel: progressiveData.state.dataLevel,
progressiveCount: progressiveData.institutions.length,
progressiveError: progressiveData.error?.message,
});
async function loadInstitutions() {
console.log('[NDEMapPage] loadInstitutions() called');
// Helper to update stats
const updateStats = (data: Institution[] | LiteInstitution[]) => {
const byType: Record<string, number> = {};
data.forEach((inst) => {
byType[inst.type] = (byType[inst.type] || 0) + 1;
});
setStats({ total: data.length, byType });
};
// Helper to convert LiteInstitution to Institution (for map compatibility)
const liteToInstitution = (lite: LiteInstitution): Institution => ({
lat: lite.lat,
lon: lite.lon,
name: lite.name,
city: lite.city,
province: '', // Not available in lite data
type: lite.type,
type_name: lite.type_name,
color: lite.color,
website: '',
wikidata_id: '',
description: '',
rating: lite.rating,
ghcid: { current: lite.ghcid, uuid: '', numeric: undefined },
org_name: lite.name,
});
// Mode: Progressive - cache-first with lite fallback, background full load
if (dataSourceMode === 'progressive') {
console.log('[NDEMapPage] Progressive mode block entered', {
isReady: progressiveData.isReady,
phase: progressiveData.state.phase,
dataLevel: progressiveData.state.dataLevel,
count: progressiveData.institutions.length,
});
// Progressive loading state machine:
// 1. If not ready yet, show loading
// 2. Once ready (lite or full), display institutions
// 3. Background loading upgrades lite → full seamlessly
if (!progressiveData.isReady) {
console.log('[NDEMapPage] Progressive NOT ready - returning early');
return;
}
console.log('[NDEMapPage] Progressive IS ready - setting institutions:', progressiveData.institutions.length);
setInstitutions(progressiveData.institutions);
setUsingFallback(false);
setActiveDataSource('progressive');
updateStats(progressiveData.institutions);
setLoading(false);
console.log('[NDEMapPage] Progressive setup complete, setLoading(false) called');
if (progressiveData.error) {
console.warn('[NDEMapPage] Progressive loading warning:', progressiveData.error.message);
}
return;
}
// Mode: GeoAPI-Lite - use lightweight endpoint for fast map loading (~5MB vs 126MB)
if (dataSourceMode === 'geoapi-lite') {
if (liteData.isLoading) {
return;
}
if (liteData.institutions.length > 0) {
// Convert lite institutions to full Institution type for map compatibility
const converted = liteData.institutions.map(liteToInstitution);
setInstitutions(converted);
setUsingFallback(false);
setActiveDataSource('geoapi-lite');
updateStats(liteData.institutions);
setLoading(false);
return;
}
if (liteData.error) {
setError(liteData.error.message);
setLoading(false);
return;
}
}
// Mode: GeoAPI - use server-side PostGIS exclusively
if (dataSourceMode === 'geoapi') {
if (geoApiData.isLoading) {
return;
}
if (geoApiData.isConnected && geoApiData.institutions.length > 0) {
setInstitutions(geoApiData.institutions);
setUsingFallback(false);
setActiveDataSource('geoapi');
updateStats(geoApiData.institutions);
setLoading(false);
return;
}
// Geo API failed - show error
setError(geoApiData.error?.message || 'Geo API not available');
setLoading(false);
return;
}
// Mode: DuckLake - use client-side WASM database exclusively
if (dataSourceMode === 'ducklake') {
if (duckLakeData.isLoading) {
return;
}
if (duckLakeData.isConnected && duckLakeData.institutions.length > 0) {
setInstitutions(duckLakeData.institutions);
setUsingFallback(false);
setActiveDataSource('ducklake');
updateStats(duckLakeData.institutions);
setLoading(false);
return;
}
// DuckLake failed - show error
setError('DuckLake not available');
setLoading(false);
return;
}
// Mode: Auto - try DuckLake first, then Geo API, then static JSON fallback
// Wait for DuckLake to finish loading before making any decisions
if (duckLakeData.isLoading) {
return;
}
// If DuckLake is connected and has data, use it
if (duckLakeData.isConnected && duckLakeData.institutions.length > 0) {
setInstitutions(duckLakeData.institutions);
setUsingFallback(false);
setActiveDataSource('ducklake');
updateStats(duckLakeData.institutions);
setLoading(false);
return;
}
// DuckLake not available - try Geo API
if (!geoApiData.isLoading && geoApiData.isConnected && geoApiData.institutions.length > 0) {
setInstitutions(geoApiData.institutions);
setUsingFallback(false);
setActiveDataSource('geoapi');
updateStats(geoApiData.institutions);
setLoading(false);
return;
}
// Both DuckLake and Geo API not available - use static JSON as fallback
setUsingFallback(true);
setActiveDataSource('fallback');
try {
const [instResponse, metaResponse] = await Promise.all([
fetch(INSTITUTIONS_URL),
fetch(METADATA_URL)
]);
if (!instResponse.ok) {
throw new Error('Failed to load institutions data');
}
const data = await instResponse.json();
setInstitutions(data);
if (metaResponse.ok) {
const metaData = await metaResponse.json();
setMetadata(metaData);
}
updateStats(data);
setLoading(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
setLoading(false);
}
}
loadInstitutions();
}, [dataSourceMode, duckLakeData.isConnected, duckLakeData.isLoading, duckLakeData.institutions, duckLakeData.progress, geoApiData.isConnected, geoApiData.isLoading, geoApiData.institutions, liteData.isLoading, liteData.institutions, liteData.error, progressiveData.isReady, progressiveData.institutions, progressiveData.state.dataLevel, progressiveData.totalCount, progressiveData.error]);
// Initialize MapLibre map
useEffect(() => {
console.log('[NDEMapPage] Map init effect', {
hasMapRef: !!mapRef.current,
loading,
institutionsCount: institutions.length,
hasMapInstance: !!mapInstanceRef.current,
});
if (!mapRef.current || loading || institutions.length === 0) return;
if (mapInstanceRef.current) return;
console.log('[NDEMapPage] Initializing MapLibre map');
// Use URL params for initial position if provided (from Browse page "View on Map")
// Otherwise default to Netherlands center
const initialLon = urlLon ? parseFloat(urlLon) : 5.3;
const initialLat = urlLat ? parseFloat(urlLat) : 52.1;
const initialZoom = urlZoom ? parseFloat(urlZoom) : 7;
const map = new maplibregl.Map({
container: mapRef.current,
style: getMapStyle(isDarkMode),
center: [initialLon, initialLat],
zoom: initialZoom,
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
map.addControl(new maplibregl.NavigationControl() as any, 'top-right');
map.on('load', () => {
mapInstanceRef.current = map;
setMapReady(true);
// Track zoom level changes for country polygon / institution point switching
map.on('zoom', () => {
const zoom = map.getZoom();
setCurrentZoom(zoom);
});
// Track map bounds changes for bbox filtering
const updateBounds = () => {
const bounds = map.getBounds();
if (bounds) {
setMapBounds([
bounds.getWest(),
bounds.getSouth(),
bounds.getEast(),
bounds.getNorth()
]);
}
};
map.on('moveend', updateBounds);
// Initial bounds
updateBounds();
});
return () => {
if (mapInstanceRef.current) {
mapInstanceRef.current.remove();
mapInstanceRef.current = null;
setMapReady(false);
}
};
}, [loading, institutions]);
// Track if style has been changed (to trigger marker re-add)
const [styleVersion, setStyleVersion] = useState(0);
// Track the last applied theme to avoid unnecessary style changes
const lastAppliedThemeRef = useRef<boolean | null>(null);
// Update map style when theme changes (without recreating the map)
useEffect(() => {
if (!mapInstanceRef.current || !mapReady) return;
// Skip if this is the same theme we already applied
// (handles initial render and React StrictMode double-renders)
if (lastAppliedThemeRef.current === isDarkMode) {
console.log('[Theme] Skipping - already applied isDarkMode:', isDarkMode);
return;
}
// Skip initial render - map was initialized with correct style
if (lastAppliedThemeRef.current === null) {
console.log('[Theme] Initial render - recording theme:', isDarkMode);
lastAppliedThemeRef.current = isDarkMode;
return;
}
const map = mapInstanceRef.current;
const currentCenter = map.getCenter();
const currentZoom = map.getZoom();
console.log('[Theme] Changing style from', lastAppliedThemeRef.current, 'to', isDarkMode);
lastAppliedThemeRef.current = isDarkMode;
// Set the new style - this removes all sources and layers
map.setStyle(getMapStyle(isDarkMode));
// Track if effect has been cleaned up (component unmounted)
let isCancelled = false;
// Re-apply center and zoom after style change, and trigger marker re-add
const handleStyleLoad = () => {
// Check if component was unmounted or map was destroyed
if (isCancelled || !mapInstanceRef.current) {
console.log('[Theme] style.load cancelled - component unmounted');
return;
}
console.log('[Theme] style.load event fired for isDarkMode:', isDarkMode);
map.setCenter(currentCenter);
map.setZoom(currentZoom);
// Increment style version to trigger markers effect to re-add source/layer
console.log('[Theme] About to increment styleVersion');
setStyleVersion(v => {
console.log('[Theme] styleVersion changing from', v, 'to', v + 1);
return v + 1;
});
};
map.once('style.load', handleStyleLoad);
// Cleanup: mark as cancelled and remove event listener
return () => {
isCancelled = true;
map.off('style.load', handleStyleLoad);
};
}, [isDarkMode, mapReady]);
// Ref to hold current filtered institutions (avoids stale closure in click handler)
const filteredInstitutionsRef = useRef<Institution[]>([]);
filteredInstitutionsRef.current = filteredInstitutions;
// Ref to hold current active data source (avoids stale closure in click handler)
const activeDataSourceRef = useRef<'ducklake' | 'geoapi' | 'geoapi-lite' | 'progressive' | 'fallback'>('progressive');
activeDataSourceRef.current = activeDataSource;
// Update map markers when filters change
useEffect(() => {
console.log('[Markers] Effect triggered, mapReady:', mapReady, 'styleVersion:', styleVersion);
if (!mapInstanceRef.current || !mapReady) {
console.log('[Markers] Early return - no map or not ready');
return;
}
const map = mapInstanceRef.current;
const geojson = institutionsToGeoJSON(filteredInstitutions);
console.log('[Markers] GeoJSON features count:', geojson.features.length);
// Track if effect has been cleaned up (component unmounted)
let isCancelled = false;
// Helper function to add source and layer
const addSourceAndLayer = () => {
// Check if component was unmounted or map was destroyed
if (isCancelled || !mapInstanceRef.current) {
console.log('[Markers] addSourceAndLayer cancelled - component unmounted');
return;
}
console.log('[Markers] addSourceAndLayer called');
// Extra safety check - ensure map style is accessible
try {
if (!map.getStyle()) {
console.log('[Markers] No style available, skipping');
return;
}
console.log('[Markers] isStyleLoaded:', map.isStyleLoaded());
} catch {
console.log('[Markers] Map style not accessible, skipping');
return;
}
// Check if source already exists (with safety check)
let existingSource: GeoJSONSource | undefined;
try {
existingSource = map.getSource('institutions') as GeoJSONSource | undefined;
} catch {
console.log('[Markers] Error getting source, map may be destroyed');
return;
}
console.log('[Markers] existingSource:', existingSource ? 'exists' : 'null');
if (existingSource) {
// Just update the data - much more efficient than removing/re-adding
console.log('[Markers] Updating existing source data');
existingSource.setData(geojson);
} else {
// First time or after style change: add source and layer
console.log('[Markers] Adding NEW source and layer');
try {
map.addSource('institutions', {
type: 'geojson',
data: geojson,
});
console.log('[Markers] Source added successfully');
// Determine initial visibility based on current zoom
const initialZoom = map.getZoom();
const shouldShowInstitutions = initialZoom >= COUNTRY_ZOOM_THRESHOLD;
console.log(`[Markers] Adding layer with initial visibility: ${shouldShowInstitutions ? 'visible' : 'none'} (zoom: ${initialZoom.toFixed(1)})`);
// Add circle layer for institutions
map.addLayer({
id: 'institutions-circles',
type: 'circle',
source: 'institutions',
layout: {
'visibility': shouldShowInstitutions ? 'visible' : 'none',
},
paint: {
'circle-radius': 8,
'circle-color': ['get', 'color'],
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff',
'circle-opacity': 0.8,
},
});
console.log('[Markers] Layer added successfully');
// Hover cursor - add once when layer is created
map.on('mouseenter', 'institutions-circles', () => {
map.getCanvas().style.cursor = 'pointer';
});
map.on('mouseleave', 'institutions-circles', () => {
map.getCanvas().style.cursor = '';
});
} catch (err) {
console.error('[Markers] Error adding source/layer:', err);
}
}
// Verify source exists after adding (with safety check)
try {
if (mapInstanceRef.current) {
const verifySource = map.getSource('institutions');
console.log('[Markers] Verification - source exists:', verifySource ? 'yes' : 'no');
const verifyLayer = map.getLayer('institutions-circles');
console.log('[Markers] Verification - layer exists:', verifyLayer ? 'yes' : 'no');
}
} catch {
console.log('[Markers] Verification skipped - map may be destroyed');
}
};
// Handler for idle event
const handleIdle = () => {
if (isCancelled || !mapInstanceRef.current) {
console.log('[Markers] idle handler cancelled - component unmounted');
return;
}
console.log('[Markers] idle event received, isStyleLoaded:', map.isStyleLoaded());
if (map.isStyleLoaded()) {
addSourceAndLayer();
} else {
// Still not loaded, wait for next idle
map.once('idle', handleIdle);
}
};
// Check if style is loaded - if not, wait for it
if (map.isStyleLoaded()) {
console.log('[Markers] Style is loaded, calling addSourceAndLayer immediately');
addSourceAndLayer();
} else {
// Style is still loading (e.g., after theme change), wait for it
console.log('[Markers] Style NOT loaded, waiting for idle event');
map.once('idle', handleIdle);
}
// Cleanup: mark as cancelled and remove event listener
return () => {
isCancelled = true;
if (mapInstanceRef.current) {
try {
map.off('idle', handleIdle);
} catch {
// Map may already be destroyed
}
}
};
}, [filteredInstitutions, mapReady, styleVersion]);
// Click handler - separate effect with ref to avoid stale closure
useEffect(() => {
if (!mapInstanceRef.current || !mapReady) return;
const map = mapInstanceRef.current;
// Click handler using ref to always get current filtered data
const handleClick = (e: MapLayerMouseEvent) => {
if (!e.features || e.features.length === 0) return;
const feature = e.features[0] as GeoJSON.Feature;
const index = feature.properties?.index as number | undefined;
if (index === undefined || typeof index !== 'number') return;
// Use ref to get current filtered institutions, not stale closure
const inst = filteredInstitutionsRef.current[index];
if (!inst) return;
// Get screen position
const point = map.project(e.lngLat);
const sidebar = document.querySelector('.map-sidebar');
const sidebarWidth = sidebar ? (sidebar as HTMLElement).offsetWidth : 0;
// For geoapi-lite mode, show lite data immediately and trigger full detail fetch
// The useEffect watching detailData will update selectedInstitution when full data arrives
if (activeDataSourceRef.current === 'geoapi-lite' && inst.ghcid?.current) {
setSelectedGhcid(inst.ghcid.current);
setIsDetailLoading(true);
}
setSelectedInstitution(inst);
setMarkerScreenPosition({
x: point.x + sidebarWidth,
y: point.y,
});
};
map.on('click', 'institutions-circles', handleClick);
// Cleanup: remove click handler when effect re-runs or unmounts
return () => {
try {
// Check if map still exists and has the layer before removing handler
if (map && mapInstanceRef.current && map.getLayer('institutions-circles')) {
map.off('click', 'institutions-circles', handleClick);
}
} catch {
// Map may already be destroyed during navigation
}
};
}, [mapReady]);
// Country polygon layers - add/update when countries data changes
useEffect(() => {
if (!mapInstanceRef.current || !mapReady) return;
if (!countriesData.geojson || countriesData.isLoading) return;
const map = mapInstanceRef.current;
// Track if effect has been cleaned up
let isCancelled = false;
const addCountryLayers = () => {
if (isCancelled || !mapInstanceRef.current) return;
try {
if (!map.getStyle()) return;
} catch {
return;
}
// Convert API response to proper GeoJSON format for MapLibre
// Filter out features with invalid geometry
const validFeatures = countriesData.geojson!.features.filter(f =>
f.geometry &&
typeof f.geometry === 'object' &&
'type' in f.geometry &&
'coordinates' in f.geometry
);
const countryGeoJSON: GeoJSON.FeatureCollection = {
type: 'FeatureCollection',
features: validFeatures.map(f => ({
type: 'Feature' as const,
id: f.id,
geometry: f.geometry,
properties: {
...f.properties,
// Ensure count is a number for data-driven styling
count: f.properties.institution_count || 0,
label: f.properties.institution_count > 0
? `${f.properties.name}\n${f.properties.institution_count.toLocaleString()}`
: f.properties.name,
},
})),
};
// Create centroid GeoJSON for labels
const centroidGeoJSON: GeoJSON.FeatureCollection = {
type: 'FeatureCollection',
features: validFeatures
.filter(f => {
// Safely check centroid is array with valid coordinates
const centroid = f.properties.centroid;
return (
Array.isArray(centroid) &&
centroid[0] !== null &&
centroid[0] !== undefined &&
centroid[1] !== null &&
centroid[1] !== undefined &&
f.properties.institution_count > 0
);
})
.map(f => ({
type: 'Feature' as const,
id: `label-${f.id}`,
geometry: {
type: 'Point' as const,
coordinates: [f.properties.centroid[0] as number, f.properties.centroid[1] as number],
},
properties: {
name: f.properties.name,
count: f.properties.institution_count,
iso_a2: f.properties.iso_a2,
},
})),
};
console.log(`[Countries] Adding country layers with ${countryGeoJSON.features.length} countries, ${centroidGeoJSON.features.length} labels`);
// Check if source already exists
const existingSource = map.getSource('countries') as GeoJSONSource | undefined;
const existingCentroidSource = map.getSource('country-centroids') as GeoJSONSource | undefined;
// Determine initial visibility based on current zoom (declared here for both branches)
const initialZoom = map.getZoom();
const shouldShowCountryLayers = initialZoom < COUNTRY_ZOOM_THRESHOLD;
if (existingSource) {
existingSource.setData(countryGeoJSON);
} else {
map.addSource('countries', {
type: 'geojson',
data: countryGeoJSON,
});
console.log(`[Countries] Adding layers with initial visibility: ${shouldShowCountryLayers ? 'visible' : 'none'} (zoom: ${initialZoom.toFixed(1)})`);
// Country fill layer - semi-transparent
map.addLayer({
id: 'countries-fill',
type: 'fill',
source: 'countries',
layout: {
'visibility': shouldShowCountryLayers ? 'visible' : 'none',
},
paint: {
'fill-color': [
'case',
['>', ['get', 'count'], 0],
'#3498db', // Blue for countries with data
'transparent', // No fill for empty countries
],
'fill-opacity': [
'case',
['>', ['get', 'count'], 0],
0.3,
0,
],
},
}, 'institutions-circles'); // Insert below institution markers
// Country stroke layer
map.addLayer({
id: 'countries-stroke',
type: 'line',
source: 'countries',
layout: {
'visibility': shouldShowCountryLayers ? 'visible' : 'none',
},
paint: {
'line-color': [
'case',
['>', ['get', 'count'], 0],
'#2980b9',
'#999999',
],
'line-width': [
'case',
['>', ['get', 'count'], 0],
2,
0.5,
],
'line-opacity': [
'case',
['>', ['get', 'count'], 0],
0.8,
0.3,
],
},
}, 'institutions-circles');
// Hover handlers for countries - show tooltip with name and count
map.on('mouseenter', 'countries-fill', (e) => {
map.getCanvas().style.cursor = 'pointer';
if (e.features && e.features.length > 0) {
const feature = e.features[0];
const name = (feature.properties?.name as string) || '';
const count = (feature.properties?.count as number) || 0;
if (count > 0) {
setHoveredCountry({
name,
count,
position: { x: e.point.x, y: e.point.y }
});
}
}
});
map.on('mousemove', 'countries-fill', (e) => {
if (e.features && e.features.length > 0) {
const feature = e.features[0];
const name = (feature.properties?.name as string) || '';
const count = (feature.properties?.count as number) || 0;
if (count > 0) {
setHoveredCountry({
name,
count,
position: { x: e.point.x, y: e.point.y }
});
} else {
setHoveredCountry(null);
}
}
});
map.on('mouseleave', 'countries-fill', () => {
map.getCanvas().style.cursor = '';
setHoveredCountry(null);
});
// Click handler for countries - zoom in
map.on('click', 'countries-fill', (e) => {
if (!e.features || e.features.length === 0) return;
const feature = e.features[0];
const count = feature.properties?.count || 0;
if (count === 0) return;
// Zoom to country
const bounds = new maplibregl.LngLatBounds();
const geometry = feature.geometry;
if (geometry.type === 'Polygon') {
geometry.coordinates[0].forEach((coord: number[]) => {
bounds.extend([coord[0], coord[1]]);
});
} else if (geometry.type === 'MultiPolygon') {
geometry.coordinates.forEach((polygon: number[][][]) => {
polygon[0].forEach((coord: number[]) => {
bounds.extend([coord[0], coord[1]]);
});
});
}
map.fitBounds(bounds, { padding: 50, maxZoom: 8 });
});
}
if (existingCentroidSource) {
existingCentroidSource.setData(centroidGeoJSON);
} else {
map.addSource('country-centroids', {
type: 'geojson',
data: centroidGeoJSON,
});
// Country count labels
map.addLayer({
id: 'countries-labels',
type: 'symbol',
source: 'country-centroids',
layout: {
'visibility': shouldShowCountryLayers ? 'visible' : 'none',
'text-field': ['get', 'count'],
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
'text-size': 14,
'text-anchor': 'center',
'text-allow-overlap': false,
},
paint: {
'text-color': '#ffffff',
'text-halo-color': '#2980b9',
'text-halo-width': 2,
},
});
}
};
// Wait for style to load
if (map.isStyleLoaded()) {
addCountryLayers();
} else {
map.once('idle', () => {
if (!isCancelled) addCountryLayers();
});
}
return () => {
isCancelled = true;
};
}, [countriesData.geojson, countriesData.isLoading, mapReady, styleVersion]);
// Toggle layer visibility based on zoom level
// Track previous state to detect threshold crossing
const prevShowCountryPolygonsRef = useRef(showCountryPolygons);
const transitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!mapInstanceRef.current || !mapReady) return;
const map = mapInstanceRef.current;
// Detect if we're crossing the threshold
const wasShowingPolygons = prevShowCountryPolygonsRef.current;
const crossingThreshold = wasShowingPolygons !== showCountryPolygons;
prevShowCountryPolygonsRef.current = showCountryPolygons;
// Show transition loading briefly when crossing threshold (non-blocking)
if (crossingThreshold) {
// Clear any existing timer first
if (transitionTimerRef.current) {
clearTimeout(transitionTimerRef.current);
transitionTimerRef.current = null;
}
setIsTransitioningView(true);
// Clear after a short delay
transitionTimerRef.current = setTimeout(() => {
setIsTransitioningView(false);
transitionTimerRef.current = null;
}, 300);
}
// Always update layer visibility (don't return early)
try {
// Check which layers exist
const hasFillLayer = map.getLayer('countries-fill');
const hasStrokeLayer = map.getLayer('countries-stroke');
const hasLabelsLayer = map.getLayer('countries-labels');
const hasInstitutionsLayer = map.getLayer('institutions-circles');
// Show/hide country layers based on zoom
if (hasFillLayer) {
map.setLayoutProperty('countries-fill', 'visibility', showCountryPolygons ? 'visible' : 'none');
}
if (hasStrokeLayer) {
map.setLayoutProperty('countries-stroke', 'visibility', showCountryPolygons ? 'visible' : 'none');
}
if (hasLabelsLayer) {
map.setLayoutProperty('countries-labels', 'visibility', showCountryPolygons ? 'visible' : 'none');
}
// Show/hide institution markers based on zoom (opposite of country polygons)
if (hasInstitutionsLayer) {
map.setLayoutProperty('institutions-circles', 'visibility', showCountryPolygons ? 'none' : 'visible');
}
// Clear hovered country when switching to institution view
if (!showCountryPolygons) {
setHoveredCountry(null);
}
console.log(`[Zoom] Level ${currentZoom.toFixed(1)}, showCountryPolygons: ${showCountryPolygons}, layers: fill=${!!hasFillLayer}, stroke=${!!hasStrokeLayer}, labels=${!!hasLabelsLayer}, institutions=${!!hasInstitutionsLayer}`);
} catch (err) {
console.warn('[Zoom] Error updating layer visibility:', err);
}
// Cleanup: clear timer AND reset transitioning state to prevent stuck spinner
return () => {
if (transitionTimerRef.current) {
clearTimeout(transitionTimerRef.current);
transitionTimerRef.current = null;
}
// Reset transitioning state on cleanup to prevent stuck spinner
setIsTransitioningView(false);
};
}, [showCountryPolygons, mapReady, currentZoom]);
// Highlight/zoom behavior - separate effect
useEffect(() => {
if (!mapInstanceRef.current || !mapReady || filteredInstitutions.length === 0) return;
const map = mapInstanceRef.current;
// Zoom to highlighted institution
const highlightedInst = ghcidMatch?.matchType === 'ghcid'
? (ghcidMatch as { institution: Institution; matchType: 'ghcid' }).institution
: highlightMatch?.institution || null;
if (highlightedInst) {
map.flyTo({
center: [highlightedInst.lon, highlightedInst.lat],
zoom: 14,
duration: 1000,
});
// Open panel after fly
setTimeout(() => {
const point = map.project([highlightedInst.lon, highlightedInst.lat]);
const sidebar = document.querySelector('.map-sidebar');
const sidebarWidth = sidebar ? (sidebar as HTMLElement).offsetWidth : 0;
setSelectedInstitution(highlightedInst);
setMarkerScreenPosition({
x: point.x + sidebarWidth,
y: point.y,
});
}, 1200);
} else if (ghcidMatch?.matchType === 'ghcid-multi') {
const multiMatches = (ghcidMatch as { institutions: Institution[]; matchType: 'ghcid-multi' }).institutions;
if (multiMatches.length > 0) {
const bounds = new maplibregl.LngLatBounds();
multiMatches.forEach(inst => bounds.extend([inst.lon, inst.lat]));
map.fitBounds(bounds, { padding: 50, maxZoom: 14 });
}
} else if (cityFilter) {
const cityInstitutions = filteredInstitutions.filter(inst => inst.city === cityFilter);
if (cityInstitutions.length > 0) {
const bounds = new maplibregl.LngLatBounds();
cityInstitutions.forEach(inst => bounds.extend([inst.lon, inst.lat]));
map.fitBounds(bounds, { padding: 50, maxZoom: 14 });
}
} else if (provinceFilter) {
const provinceInstitutions = filteredInstitutions.filter(inst => inst.province === provinceFilter);
if (provinceInstitutions.length > 0) {
const bounds = new maplibregl.LngLatBounds();
provinceInstitutions.forEach(inst => bounds.extend([inst.lon, inst.lat]));
map.fitBounds(bounds, { padding: 50, maxZoom: 12 });
}
}
}, [highlightMatch, ghcidMatch, cityFilter, provinceFilter, mapReady, filteredInstitutions]);
// Handle map resize
useEffect(() => {
if (mapInstanceRef.current) {
setTimeout(() => {
mapInstanceRef.current?.resize();
}, 350);
}
}, [isFullscreen, isHeaderCollapsed]);
const toggleType = (type: string) => {
setSelectedTypes((prev) => {
const next = new Set(prev);
if (next.has(type)) {
next.delete(type);
} else {
next.add(type);
}
return next;
});
};
const selectAll = () => setSelectedTypes(new Set(Object.keys(TYPE_COLORS)));
const selectNone = () => setSelectedTypes(new Set());
if (loading) {
// Use progress from the active data source based on dataSourceMode
let progressPercent: number | undefined;
let progressMessage: string;
if (dataSourceMode === 'geoapi') {
progressPercent = geoApiData.progress?.percent;
progressMessage = geoApiData.progress?.message ||
t('Instellingsgegevens ophalen...', 'Fetching institution data...');
} else if (dataSourceMode === 'ducklake') {
progressPercent = duckLakeData.progress?.percent;
progressMessage = duckLakeData.progress?.message ||
t('DuckLake database laden...', 'Loading DuckLake database...');
} else {
// Auto mode - show progress from whichever is active
if (duckLakeData.isLoading && duckLakeData.progress) {
progressPercent = duckLakeData.progress.percent;
progressMessage = duckLakeData.progress.message ||
t('DuckLake database laden...', 'Loading DuckLake database...');
} else if (geoApiData.isLoading && geoApiData.progress) {
progressPercent = geoApiData.progress.percent;
progressMessage = geoApiData.progress.message ||
t('Instellingsgegevens ophalen...', 'Fetching institution data...');
} else {
progressMessage = t('Instellingsgegevens laden...', 'Loading institutions data...');
}
}
return (
<div className="nde-map-page">
<LoadingScreen
message={progressMessage}
progress={progressPercent}
size="large"
fullscreen={false}
/>
</div>
);
}
if (error) {
return (
<div className="nde-map-page">
<div className="error-container">
<h2>{t('Fout bij laden gegevens', 'Error Loading Data')}</h2>
<p>{error}</p>
<p className="error-hint">
{t(
'Zorg ervoor dat de instellingsgegevens zijn geëxporteerd door het volgende uit te voeren:',
'Make sure the institutions data has been exported by running:'
)}
<code>python scripts/export_nde_map_json.py</code>
</p>
</div>
</div>
);
}
return (
<div className={`nde-map-page ${isFullscreen ? 'fullscreen-active' : ''} ${isMobile ? 'is-mobile' : ''}`} ref={mapContainerRef}>
{/* Mobile overlay */}
{isMobile && sidebarOpen && (
<div
className="sidebar-overlay"
ref={overlayRef}
onClick={() => setSidebarOpen(false)}
aria-hidden="true"
/>
)}
{/* Sidebar Toggle */}
{isMobile && !sidebarOpen && (
<button
className="sidebar-toggle sidebar-toggle--mobile"
onClick={() => setSidebarOpen(true)}
aria-label={t('Zijbalk openen', 'Open sidebar')}
>
<Menu size={20} />
</button>
)}
<div className={`map-header collapsible-header ${isHeaderCollapsed ? 'collapsible-header--collapsed' : ''}`}>
<div className="header-content">
<h1>{t('NDE Erfgoedinstellingen Kaart', 'NDE Heritage Institutions Map')}</h1>
</div>
</div>
{isHeaderCollapsed && !isFullscreen && (
<div className="header-toggle-bar" onClick={toggleHeaderCollapsed}>
<span className="toggle-hint">{language === 'nl' ? SEARCH_TEXT.showHeader.nl : SEARCH_TEXT.showHeader.en}</span>
<span className="toggle-icon"></span>
</div>
)}
{/* DuckLake Fallback Warning Banner */}
{usingFallback && !loading && (
<div className="ducklake-fallback-warning">
<AlertTriangle size={16} />
<span>
{language === 'nl'
? 'Beperkte dataset: alleen Nederlandse instellingen worden getoond (DuckLake niet beschikbaar)'
: 'Limited dataset: showing Dutch institutions only (DuckLake unavailable)'
}
</span>
</div>
)}
{/* View Controls Bar */}
<div className="map-view-controls">
{/* Search Bar */}
<div className="map-search-container">
<input
ref={searchInputRef}
type="text"
className="map-search-input"
placeholder={language === 'nl' ? SEARCH_TEXT.placeholder.nl : SEARCH_TEXT.placeholder.en}
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
onFocus={() => searchQuery && setShowSearchResults(true)}
/>
{searchQuery && (
<button
className="map-search-clear"
onClick={() => { setSearchQuery(''); setShowSearchResults(false); }}
title="Clear search"
>
×
</button>
)}
{showSearchResults && (
<div className="map-search-results">
{searchResults.length > 0 ? (
<>
<div className="search-results-count">
{searchResults.length > visibleResultsCount ? (
<>
{language === 'nl' ? SEARCH_TEXT.showingFirst.nl : SEARCH_TEXT.showingFirst.en}{' '}
{Math.min(visibleResultsCount, searchResults.length)}{' '}
{language === 'nl' ? SEARCH_TEXT.of.nl : SEARCH_TEXT.of.en}{' '}
{searchResults.length} {language === 'nl' ? SEARCH_TEXT.resultsCount.nl : SEARCH_TEXT.resultsCount.en}
</>
) : (
<>
{searchResults.length} {language === 'nl' ? SEARCH_TEXT.resultsCount.nl : SEARCH_TEXT.resultsCount.en}
</>
)}
</div>
{searchResults.slice(0, visibleResultsCount).map((inst, index) => (
<div
key={inst.name + index}
className="search-result-item"
onClick={() => handleSelectSearchResult(inst)}
>
<span
className="search-result-type"
style={{ backgroundColor: TYPE_COLORS[inst.type] || '#6b7280' }}
>
{inst.type}
</span>
<div className="search-result-info">
<span className="search-result-name">{inst.name}</span>
<span className="search-result-location">
{[inst.city, inst.province].filter(Boolean).join(', ')}
</span>
</div>
</div>
))}
{searchResults.length > visibleResultsCount && (
<button
className="search-show-more"
onClick={(e) => {
e.stopPropagation();
setVisibleResultsCount(prev => Math.min(prev + 10, searchResults.length));
}}
>
{language === 'nl' ? SEARCH_TEXT.showMore.nl : SEARCH_TEXT.showMore.en} ({searchResults.length - visibleResultsCount} {language === 'nl' ? 'meer' : 'more'})
</button>
)}
</>
) : (
<div className="search-no-results">
{language === 'nl' ? SEARCH_TEXT.noResults.nl : SEARCH_TEXT.noResults.en}
</div>
)}
</div>
)}
</div>
{!isHeaderCollapsed && !isFullscreen && (
<button
onClick={toggleHeaderCollapsed}
className="view-control-btn header-toggle-btn"
title={language === 'nl' ? SEARCH_TEXT.hideHeader.nl : SEARCH_TEXT.hideHeader.en}
>
{language === 'nl' ? SEARCH_TEXT.hideHeader.nl : SEARCH_TEXT.hideHeader.en}
</button>
)}
<button
onClick={toggleFullscreen}
className={`view-control-btn fullscreen-btn ${isFullscreen ? 'fullscreen-btn--active' : ''}`}
title={isFullscreen ? t('Volledig scherm afsluiten', 'Exit fullscreen') : t('Volledig scherm', 'Fullscreen')}
>
{isFullscreen ? '⛶' : '⛶'} {isFullscreen ? t('Afsluiten', 'Exit') : t('Volledig scherm', 'Fullscreen')}
</button>
</div>
<div className="map-container">
<aside
className={`map-sidebar ${isMobile ? 'map-sidebar--mobile' : ''} ${!sidebarOpen ? 'collapsed' : ''}`}
ref={sidebarRef}
>
{/* Desktop collapse toggle */}
{!isMobile && (
<div className="sidebar-header-desktop">
<button
className="sidebar-toggle-btn"
onClick={() => setSidebarOpen(!sidebarOpen)}
aria-label={sidebarOpen ? t('Zijbalk inklappen', 'Collapse sidebar') : t('Zijbalk uitklappen', 'Expand sidebar')}
title={sidebarOpen ? t('Zijbalk inklappen', 'Collapse sidebar') : t('Zijbalk uitklappen', 'Expand sidebar')}
>
{sidebarOpen ? '«' : '»'}
</button>
</div>
)}
{isMobile && (
<div className="sidebar-header-mobile">
<h2>{t('Filters', 'Filters')}</h2>
<button
className="sidebar-close-btn"
onClick={() => setSidebarOpen(false)}
aria-label={t('Zijbalk sluiten', 'Close sidebar')}
>
<X size={18} />
</button>
</div>
)}
{sidebarOpen && hasAnyFilters && (
<div className="clear-all-filters-section">
<button
onClick={clearAllFilters}
className="clear-all-filters-btn"
title={t('Alle filters wissen', 'Clear all filters')}
>
<X size={14} />
{t('Alle filters wissen', 'Clear all filters')}
</button>
</div>
)}
{sidebarOpen && hasUrlFilters && (
<div className="active-filters-section">
<h3>{t('Actieve filters', 'Active Filters')}</h3>
<div className="filter-tags">
{provinceFilter && (
<span className="filter-tag province-tag">
{t('Provincie', 'Province')}: {provinceFilter}
</span>
)}
{typeFilter && (
<span className="filter-tag type-tag" style={{ backgroundColor: TYPE_COLORS[typeFilter] || '#6b7280' }}>
{t('Type', 'Type')}: {getTypeName(typeFilter)}
</span>
)}
{cityFilter && (
<span className="filter-tag city-tag">
🏙 {t('Plaats', 'City')}: {cityFilter}
</span>
)}
{highlightName && highlightMatch && (
<span className="filter-tag highlight-tag">
📍 {highlightMatch.institution.name}
</span>
)}
</div>
<button onClick={clearUrlFilters} className="clear-filters-btn">
{t('Filters wissen', 'Clear filters')}
</button>
</div>
)}
{/* Active Data Source Indicator (selection moved to Settings) */}
<div className="filter-section data-backend-section">
<div className="filter-header">
<Database size={16} />
<h3>{t('Databron', 'Data Source')}</h3>
</div>
<div className="filter-content">
{/* Active source indicator */}
<div className={`active-source-indicator ${activeDataSource}`}>
{activeDataSource === 'progressive' && (
<span>
<Zap size={12} />
{progressiveData.hasFullData
? t('Actief: Volledig', 'Active: Full')
: t('Actief: Laden...', 'Active: Loading...')
} ({institutions.length.toLocaleString()})
{progressiveData.state.backgroundLoading && <span className="loading-spinner" title={progressiveData.state.message}> </span>}
</span>
)}
{activeDataSource === 'ducklake' && (
<span><Database size={12} /> {t('Actief: DuckLake', 'Active: DuckLake')} ({institutions.length.toLocaleString()})</span>
)}
{activeDataSource === 'geoapi' && (
<span><Globe size={12} /> {t('Actief: Geo API', 'Active: Geo API')} ({institutions.length.toLocaleString()})</span>
)}
{activeDataSource === 'geoapi-lite' && (
<span><Zap size={12} /> {t('Actief: Geo API Lite', 'Active: Geo API Lite')} ({institutions.length.toLocaleString()})</span>
)}
{activeDataSource === 'fallback' && (
<span><AlertTriangle size={12} /> {t('Actief: Fallback JSON', 'Active: Fallback JSON')} ({institutions.length.toLocaleString()})</span>
)}
</div>
{/* Cache status indicator (only shown when using GeoAPI or Progressive) */}
{(dataSourceMode === 'geoapi' || dataSourceMode === 'progressive' || (dataSourceMode === 'auto' && activeDataSource === 'geoapi')) && (
<CacheStatusIndicator
language={language === 'nl' ? 'nl' : 'en'}
onCacheCleared={() => {
// Trigger refresh when cache is cleared
if (dataSourceMode === 'progressive') {
progressiveData.refresh();
} else {
geoApiData.refresh();
}
}}
/>
)}
{/* Link to settings for backend selection */}
<p className="settings-hint">
{t('Wijzig databron in', 'Change data source in')} <a href="/settings" onClick={(e) => { e.preventDefault(); navigate('/settings'); }}>{t('Instellingen', 'Settings')}</a>
</p>
</div>
</div>
{/* Type Filter */}
<div className="filter-section collapsible-filter">
<div
className="filter-header"
onClick={() => toggleSection('custodianType')}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && toggleSection('custodianType')}
>
{collapsedSections.custodianType ? <ChevronRight size={16} /> : <ChevronDown size={16} />}
<h3>{t('Filteren op type', 'Filter by Type')}</h3>
<span className={`filter-count ${[...selectedTypes].filter(t => stats.byType[t]).length < Object.keys(stats.byType).length ? 'filter-active' : ''}`}>
{[...selectedTypes].filter(t => stats.byType[t]).length}/{Object.keys(stats.byType).length}
</span>
</div>
{!collapsedSections.custodianType && (
<div className="filter-content">
<div className="filter-actions">
<button onClick={selectAll} className="filter-btn">{t('Alles', 'All')}</button>
<button onClick={selectNone} className="filter-btn">{t('Geen', 'None')}</button>
</div>
<div className="type-filters">
{Object.entries(TYPE_NAMES).map(([type, names]) => {
const count = stats.byType[type] || 0;
if (count === 0) return null;
return (
<label key={type} className="type-filter">
<input
type="checkbox"
checked={selectedTypes.has(type)}
onChange={() => toggleType(type)}
/>
<span
className="type-color"
style={{ backgroundColor: TYPE_COLORS[type] }}
></span>
<span className="type-label">
{language === 'nl' ? names.nl : names.en} <span className="type-count">({count})</span>
</span>
</label>
);
})}
</div>
</div>
)}
</div>
{/* Data Source Filter */}
<div className="filter-section collapsible-filter">
<div
className="filter-header"
onClick={() => toggleSection('dataSource')}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && toggleSection('dataSource')}
>
{collapsedSections.dataSource ? <ChevronRight size={16} /> : <ChevronDown size={16} />}
<h3>{t('Gegevensbron', 'Data Source')}</h3>
<span className={`filter-count ${selectedSources.size > 0 ? 'filter-active' : ''}`}>
{selectedSources.size > 0 ? `${selectedSources.size} ${t('geselecteerd', 'selected')}` : t('Alle', 'All')}
</span>
</div>
{!collapsedSections.dataSource && (
<div className="filter-content">
<div className="source-filters">
{[
{ key: 'museum_register', nl: 'Museumregister', en: 'Museum Register' },
{ key: 'youtube', nl: 'YouTube Video', en: 'YouTube Video' },
{ key: 'isil_na', nl: 'ISIL NL-NA', en: 'ISIL NL-NA' },
{ key: 'isil_kb', nl: 'ISIL NL-KB', en: 'ISIL NL-KB' },
{ key: 'zcbs', nl: 'ZCBS', en: 'ZCBS' },
{ key: 'wikidata', nl: 'Wikidata', en: 'Wikidata' },
{ key: 'nde', nl: 'NDE Register', en: 'NDE Register' },
{ key: 'google_maps', nl: 'Google Maps', en: 'Google Maps' },
{ key: 'website', nl: 'Website', en: 'Website' },
{ key: 'genealogiewerkbalk', nl: 'Genealogiewerkbalk', en: 'Genealogiewerkbalk' },
].map(({ key, nl, en }) => {
const count = extendedStats.dataSources[key] || 0;
if (count === 0) return null;
return (
<label key={key} className="source-filter">
<input
type="checkbox"
checked={selectedSources.has(key)}
onChange={() => {
const newSources = new Set(selectedSources);
if (newSources.has(key)) {
newSources.delete(key);
} else {
newSources.add(key);
}
setSelectedSources(newSources);
}}
/>
<span className="source-label">
{language === 'nl' ? nl : en} <span className="source-count">({count})</span>
</span>
</label>
);
})}
</div>
</div>
)}
</div>
{/* Location Filter */}
<div className="filter-section collapsible-filter">
<div
className="filter-header"
onClick={() => toggleSection('location')}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && toggleSection('location')}
>
{collapsedSections.location ? <ChevronRight size={16} /> : <ChevronDown size={16} />}
<h3>{t('Locatie', 'Location')}</h3>
<span className={`filter-count ${selectedProvinces.size + selectedCities.size > 0 ? 'filter-active' : ''}`}>
{selectedProvinces.size + selectedCities.size > 0 ? `${selectedProvinces.size + selectedCities.size} ${t('geselecteerd', 'selected')}` : t('Alle', 'All')}
</span>
</div>
{!collapsedSections.location && (
<div className="filter-content">
<div className="location-subsection">
<h4>{t('Provincie', 'Province')}</h4>
<div className="location-filters">
{Object.entries(extendedStats.provinces)
.sort((a, b) => b[1] - a[1])
.slice(0, 15)
.map(([province, count]) => (
<label key={province} className="location-filter">
<input
type="checkbox"
checked={selectedProvinces.has(province)}
onChange={() => {
const newProvinces = new Set(selectedProvinces);
if (newProvinces.has(province)) {
newProvinces.delete(province);
} else {
newProvinces.add(province);
}
setSelectedProvinces(newProvinces);
}}
/>
<span className="location-label">
{province} <span className="location-count">({count})</span>
</span>
</label>
))}
</div>
</div>
<div className="location-subsection">
<h4>{t('Plaats', 'Settlement')}</h4>
<div className="location-filters scrollable">
{Object.entries(extendedStats.cities)
.sort((a, b) => b[1] - a[1])
.slice(0, 30)
.map(([city, count]) => (
<label key={city} className="location-filter">
<input
type="checkbox"
checked={selectedCities.has(city)}
onChange={() => {
const newCities = new Set(selectedCities);
if (newCities.has(city)) {
newCities.delete(city);
} else {
newCities.add(city);
}
setSelectedCities(newCities);
}}
/>
<span className="location-label">
{city} <span className="location-count">({count})</span>
</span>
</label>
))}
</div>
</div>
</div>
)}
</div>
{/* Rating Filter */}
<div className="filter-section collapsible-filter">
<div
className="filter-header"
onClick={() => toggleSection('reviewScore')}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && toggleSection('reviewScore')}
>
{collapsedSections.reviewScore ? <ChevronRight size={16} /> : <ChevronDown size={16} />}
<h3>{t('Beoordeling', 'Review Score')}</h3>
<span className={`filter-count ${selectedMinRating ? 'filter-active' : ''}`}>
{selectedMinRating ? `${selectedMinRating}` : t('Alle', 'All')}
</span>
</div>
{!collapsedSections.reviewScore && (
<div className="filter-content">
<div className="rating-filters">
{[
{ value: 4.5, label: '⭐ 4.5+', count: extendedStats.ratingBuckets['4.5+'] },
{ value: 4.0, label: '⭐ 4.0+', count: extendedStats.ratingBuckets['4.0+'] },
{ value: 3.5, label: '⭐ 3.5+', count: extendedStats.ratingBuckets['3.5+'] },
{ value: 3.0, label: '⭐ 3.0+', count: extendedStats.ratingBuckets['3.0+'] },
{ value: 0, label: t('Geen beoordeling', 'No rating'), count: extendedStats.ratingBuckets['no_rating'] },
].map(({ value, label, count }) => (
<label key={value} className="rating-filter">
<input
type="radio"
name="rating"
checked={selectedMinRating === value}
onChange={() => setSelectedMinRating(value === 0 ? 0 : value)}
/>
<span className="rating-label">
{label} <span className="rating-count">({count})</span>
</span>
</label>
))}
{selectedMinRating !== null && (
<button
className="filter-btn clear-rating-btn"
onClick={() => setSelectedMinRating(null)}
>
{t('Wis filter', 'Clear filter')}
</button>
)}
</div>
</div>
)}
</div>
{/* Stats */}
<div className="stats-section">
<h3>{t('Statistieken', 'Statistics')}</h3>
<dl className="stats-list">
<dt>{t('Totaal instellingen', 'Total Institutions')}</dt>
<dd>{stats.total.toLocaleString()}</dd>
<dt>{t('Momenteel zichtbaar', 'Currently Visible')}</dt>
<dd>{visibleCount.toLocaleString()}</dd>
<dt>{t('Types getoond', 'Types Shown')}</dt>
<dd>{[...selectedTypes].filter(t => stats.byType[t]).length} {t('van', 'of')} {Object.keys(stats.byType).length}</dd>
</dl>
</div>
</aside>
<div className="map-wrapper">
<div ref={mapRef} className="maplibre-map" style={{ width: '100%', height: '100%' }}></div>
{/* View transition loading indicator */}
{isTransitioningView && (
<div className="map-transition-overlay">
<div className="map-transition-spinner"></div>
</div>
)}
{/* Country hover tooltip */}
{hoveredCountry && showCountryPolygons && (
<div
className="country-hover-tooltip"
style={{
left: hoveredCountry.position.x + 15,
top: hoveredCountry.position.y - 10,
}}
>
<div className="tooltip-country-name">{hoveredCountry.name}</div>
<div className="tooltip-institution-count">
{hoveredCountry.count.toLocaleString()} {t('instellingen', 'institutions')}
</div>
</div>
)}
{/* Timeline Slider */}
<TimelineSlider
institutions={institutionsWithTemporal}
selectedRange={timelineRange}
onRangeChange={setTimelineRange}
isActive={isTimelineActive}
onToggleActive={() => {
console.log(`[Timeline Toggle] Clicked! Current isTimelineActive=${isTimelineActive}, setting to ${!isTimelineActive}`);
setIsTimelineActive(!isTimelineActive);
}}
t={t}
language={language}
/>
</div>
</div>
{/* Institution Info Panel */}
{selectedInstitution && (
<InstitutionInfoPanel
institution={selectedInstitution}
markerScreenPosition={markerScreenPosition}
onClose={handleClosePanel}
language={language}
t={t}
getTypeName={getTypeName}
isPinned={pinnedInstitutions.has(selectedInstitution.ghcid?.uuid || selectedInstitution.name)}
onTogglePin={handleTogglePin}
onShowWerkgebied={handleShowWerkgebied}
onHideWerkgebied={handleHideWerkgebied}
onShowServiceArea={handleShowServiceArea}
isWerkgebiedShown={werkgebiedInstitutionKey === (selectedInstitution.isil?.code || selectedInstitution.name)}
werkgebiedArchive={werkgebied.currentArchive}
isHistoricalBoundary={werkgebied.isHistoricalBoundary}
historicalDescription={werkgebied.historicalDescription}
isServiceAreaShown={werkgebied.isServiceArea}
currentServiceArea={werkgebied.currentServiceArea}
/>
)}
{/* Pinned Panels */}
{Array.from(pinnedInstitutions.entries()).map(([key, { institution, position }]) => {
const selectedKey = selectedInstitution?.ghcid?.uuid || selectedInstitution?.name;
if (key === selectedKey) return null;
return (
<InstitutionInfoPanel
key={key}
institution={institution}
markerScreenPosition={position}
fixedPosition={position}
onClose={() => handleClosePinnedPanel(key)}
language={language}
t={t}
getTypeName={getTypeName}
isPinned={true}
onTogglePin={() => handleTogglePinnedPin(key)}
onShowWerkgebied={handleShowWerkgebied}
onHideWerkgebied={handleHideWerkgebied}
onShowServiceArea={handleShowServiceArea}
isWerkgebiedShown={werkgebiedInstitutionKey === (institution.isil?.code || institution.name)}
werkgebiedArchive={werkgebied.currentArchive}
isHistoricalBoundary={werkgebied.isHistoricalBoundary}
historicalDescription={werkgebied.historicalDescription}
isServiceAreaShown={werkgebied.isServiceArea}
currentServiceArea={werkgebied.currentServiceArea}
/>
);
})}
</div>
);
}