2482 lines
97 KiB
TypeScript
2482 lines
97 KiB
TypeScript
/**
|
||
* 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: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <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: '© <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>
|
||
);
|
||
}
|