/** * TimelineSlider.tsx * * A horizontal timeline slider for filtering heritage institutions by temporal extent. * Displays a time range selector that allows users to see institutions that existed * during a specific period. * * Features: * - Dual-handle range slider (start year to end year) * - Visual histogram showing institution density by decade * - Quick presets (Ancient, Medieval, Modern, Contemporary) * - Shows count of institutions visible in selected range * - Collapsible to minimize map obstruction * - Highlights destroyed/defunct institutions * * Uses CIDOC-CRM E52_Time-Span pattern conceptually: * - Institution visible if: founding_year <= selectedEnd AND (dissolution_year >= selectedStart OR is_operational) */ import React, { useState, useMemo, useCallback, useRef, useEffect } from 'react'; import './TimelineSlider.css'; export interface TemporalData { founding_year?: number; founding_decade?: number; dissolution_year?: number; // Extracted from temporal_extent.dissolution_date is_operational?: boolean; is_defunct?: boolean; } export interface TimelineSliderProps { /** All institutions with temporal data */ institutions: Array<{ temporal?: TemporalData; name: string }>; /** Current selected year range [start, end] */ selectedRange: [number, number]; /** Called when range changes */ onRangeChange: (range: [number, number]) => void; /** Whether the timeline filter is active */ isActive: boolean; /** Toggle timeline filter on/off */ onToggleActive: () => void; /** Translation function */ t: (nl: string, en: string) => string; /** Current language */ language: 'nl' | 'en'; } // Timeline bounds - covers most heritage institutions const MIN_YEAR = 1400; const MAX_YEAR = new Date().getFullYear(); const DECADE_WIDTH = 10; // Quick preset ranges const PRESETS = [ { id: 'all', nl: 'Alles', en: 'All', range: [MIN_YEAR, MAX_YEAR] as [number, number] }, { id: 'medieval', nl: 'Middeleeuwen', en: 'Medieval', range: [1400, 1500] as [number, number] }, { id: 'early-modern', nl: 'Vroegmodern', en: 'Early Modern', range: [1500, 1800] as [number, number] }, { id: 'modern', nl: '19e eeuw', en: '19th Century', range: [1800, 1900] as [number, number] }, { id: 'contemporary', nl: '20e eeuw+', en: '20th Century+', range: [1900, MAX_YEAR] as [number, number] }, ]; export const TimelineSlider: React.FC = ({ institutions, selectedRange, onRangeChange, isActive, onToggleActive, t, language, }) => { // DEBUG: Log props on every render console.log(`[TimelineSlider] Render - isActive=${isActive}, range=[${selectedRange[0]}, ${selectedRange[1]}], institutions=${institutions.length}`); const [isCollapsed, setIsCollapsed] = useState(false); const [isDragging, setIsDragging] = useState<'start' | 'end' | null>(null); const sliderRef = useRef(null); // Calculate decade histogram - count institutions founded per decade const histogram = useMemo(() => { const decades: Record = {}; // Initialize all decades for (let year = MIN_YEAR; year <= MAX_YEAR; year += DECADE_WIDTH) { decades[year] = { founded: 0, active: 0, dissolved: 0 }; } institutions.forEach(inst => { const temporal = inst.temporal; if (!temporal?.founding_year && !temporal?.founding_decade) return; const foundingYear = temporal.founding_year || (temporal.founding_decade ? temporal.founding_decade : null); if (!foundingYear) return; const foundingDecade = Math.floor(foundingYear / 10) * 10; if (foundingDecade >= MIN_YEAR && foundingDecade <= MAX_YEAR) { decades[foundingDecade].founded++; } // Count as dissolved if has dissolution year if (temporal.dissolution_year || temporal.is_defunct) { const dissolutionYear = temporal.dissolution_year; if (dissolutionYear) { const dissolutionDecade = Math.floor(dissolutionYear / 10) * 10; if (dissolutionDecade >= MIN_YEAR && dissolutionDecade <= MAX_YEAR) { decades[dissolutionDecade].dissolved++; } } } }); // Calculate max for normalization const maxFounded = Math.max(...Object.values(decades).map(d => d.founded), 1); return { decades, maxFounded }; }, [institutions]); // Calculate visible institutions count in selected range const visibleCount = useMemo(() => { if (!isActive) return institutions.length; return institutions.filter(inst => { const temporal = inst.temporal; const foundingYear = temporal?.founding_year || temporal?.founding_decade; const dissolutionYear = temporal?.dissolution_year; // If no temporal data at all, HIDE the institution when timeline filter is active if (!foundingYear && !dissolutionYear) return false; // Institution is visible if: // 1. Founded before or during the selected end year // 2. AND (still operational OR dissolved after the selected start year) if (foundingYear && foundingYear > selectedRange[1]) return false; if (dissolutionYear && dissolutionYear < selectedRange[0]) return false; return true; }).length; }, [institutions, selectedRange, isActive]); // Count of defunct/destroyed institutions in range const defunctCount = useMemo(() => { if (!isActive) return 0; return institutions.filter(inst => { const temporal = inst.temporal; if (!temporal?.is_defunct && !temporal?.dissolution_year) return false; const foundingYear = temporal.founding_year || temporal.founding_decade; const dissolutionYear = temporal.dissolution_year; if (foundingYear && foundingYear > selectedRange[1]) return false; if (dissolutionYear && dissolutionYear < selectedRange[0]) return false; return true; }).length; }, [institutions, selectedRange, isActive]); // Convert pixel position to year const positionToYear = useCallback((clientX: number) => { if (!sliderRef.current) return selectedRange[0]; const rect = sliderRef.current.getBoundingClientRect(); const percentage = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); return Math.round(MIN_YEAR + percentage * (MAX_YEAR - MIN_YEAR)); }, [selectedRange]); // Handle mouse/touch events for dragging const handleMouseDown = useCallback((handle: 'start' | 'end') => (e: React.MouseEvent | React.TouchEvent) => { e.preventDefault(); setIsDragging(handle); }, []); const handleMouseMove = useCallback((e: MouseEvent | TouchEvent) => { if (!isDragging) return; const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX; const year = positionToYear(clientX); if (isDragging === 'start') { onRangeChange([Math.min(year, selectedRange[1] - 10), selectedRange[1]]); } else { onRangeChange([selectedRange[0], Math.max(year, selectedRange[0] + 10)]); } }, [isDragging, positionToYear, selectedRange, onRangeChange]); const handleMouseUp = useCallback(() => { setIsDragging(null); }, []); // Global event listeners for dragging useEffect(() => { if (isDragging) { window.addEventListener('mousemove', handleMouseMove); window.addEventListener('mouseup', handleMouseUp); window.addEventListener('touchmove', handleMouseMove); window.addEventListener('touchend', handleMouseUp); } return () => { window.removeEventListener('mousemove', handleMouseMove); window.removeEventListener('mouseup', handleMouseUp); window.removeEventListener('touchmove', handleMouseMove); window.removeEventListener('touchend', handleMouseUp); }; }, [isDragging, handleMouseMove, handleMouseUp]); // Calculate handle positions as percentages const startPercent = ((selectedRange[0] - MIN_YEAR) / (MAX_YEAR - MIN_YEAR)) * 100; const endPercent = ((selectedRange[1] - MIN_YEAR) / (MAX_YEAR - MIN_YEAR)) * 100; if (isCollapsed) { return (
); } return (
{/* Header */}
📅 {t('Tijdlijn', 'Timeline')}
{/* Toggle switch */} {/* Collapse button */}
{/* Stats */}
{visibleCount.toLocaleString()} {t('zichtbaar', 'visible')} {isActive && defunctCount > 0 && ( ⚠️ {defunctCount} {t('opgeheven', 'defunct')} )} {selectedRange[0]} — {selectedRange[1]}
{/* Presets */}
{PRESETS.map(preset => ( ))}
{/* Histogram and Slider */}
{/* Histogram bars */}
{Object.entries(histogram.decades).map(([decadeStr, data]) => { const decade = parseInt(decadeStr); const height = (data.founded / histogram.maxFounded) * 100; const left = ((decade - MIN_YEAR) / (MAX_YEAR - MIN_YEAR)) * 100; const isInRange = decade >= selectedRange[0] && decade <= selectedRange[1]; const hasDissolved = data.dissolved > 0; return (
); })}
{/* Slider track */}
{/* Selected range highlight */}
{/* Start handle */}
{selectedRange[0]}
{/* End handle */}
{selectedRange[1]}
{/* Axis labels */}
{MIN_YEAR} 1600 1800 1900 2000 {MAX_YEAR}
); }; export default TimelineSlider;