Database Panels: - Add D3.js force-directed graph visualization to Oxigraph and TypeDB panels - Add 'Explore' tab with class/entity browser, graph/table toggle, and search - Add data explorer to PostgreSQL panel with table browser, pagination, search, export - Fix SPARQL variable naming bug in Oxigraph getGraphData() function - Add node details panel showing selected entity attributes - Add zoom/pan controls and node coloring by entity type Map Features: - Add TimelineSlider component for temporal filtering of institutions - Support dual-handle range slider with decade histogram - Add quick presets (Ancient, Medieval, Modern, Contemporary) - Show institution density visualization by founding decade Hooks: - Extend useOxigraph with getGraphData() for graph visualization - Extend useTypeDB with getGraphData() for graph visualization - Extend usePostgreSQL with getTableData() and exportTableData() - Improve useDuckLakeInstitutions with temporal filtering support Styles: - Add HeritageDashboard.css with shared panel styling - Add TimelineSlider.css for timeline component styling
385 lines
14 KiB
TypeScript
385 lines
14 KiB
TypeScript
/**
|
||
* 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<TimelineSliderProps> = ({
|
||
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<HTMLDivElement>(null);
|
||
|
||
// Calculate decade histogram - count institutions founded per decade
|
||
const histogram = useMemo(() => {
|
||
const decades: Record<number, { founded: number; active: number; dissolved: number }> = {};
|
||
|
||
// 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 (
|
||
<div className="timeline-slider timeline-slider--collapsed">
|
||
<button
|
||
className="timeline-slider__expand-btn"
|
||
onClick={() => setIsCollapsed(false)}
|
||
title={t('Tijdlijn uitvouwen', 'Expand timeline')}
|
||
>
|
||
<span className="timeline-slider__expand-icon">📅</span>
|
||
<span className="timeline-slider__expand-text">
|
||
{isActive
|
||
? `${selectedRange[0]} - ${selectedRange[1]}`
|
||
: t('Tijdlijn', 'Timeline')}
|
||
</span>
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className={`timeline-slider ${isActive ? 'timeline-slider--active' : ''}`}>
|
||
{/* Header */}
|
||
<div className="timeline-slider__header">
|
||
<div className="timeline-slider__title">
|
||
<span className="timeline-slider__icon">📅</span>
|
||
<span>{t('Tijdlijn', 'Timeline')}</span>
|
||
</div>
|
||
|
||
<div className="timeline-slider__controls">
|
||
{/* Toggle switch */}
|
||
<label className="timeline-slider__toggle">
|
||
<input
|
||
type="checkbox"
|
||
checked={isActive}
|
||
onChange={(e) => {
|
||
console.log(`[TimelineSlider] Checkbox onChange fired! checked=${e.target.checked}, calling onToggleActive`);
|
||
onToggleActive();
|
||
}}
|
||
/>
|
||
<span className="timeline-slider__toggle-slider"></span>
|
||
<span className="timeline-slider__toggle-label">
|
||
{isActive ? t('Aan', 'On') : t('Uit', 'Off')}
|
||
</span>
|
||
</label>
|
||
|
||
{/* Collapse button */}
|
||
<button
|
||
className="timeline-slider__collapse-btn"
|
||
onClick={() => setIsCollapsed(true)}
|
||
title={t('Tijdlijn inklappen', 'Collapse timeline')}
|
||
>
|
||
▼
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Stats */}
|
||
<div className="timeline-slider__stats">
|
||
<span className="timeline-slider__stat">
|
||
{visibleCount.toLocaleString()} {t('zichtbaar', 'visible')}
|
||
</span>
|
||
{isActive && defunctCount > 0 && (
|
||
<span className="timeline-slider__stat timeline-slider__stat--defunct">
|
||
⚠️ {defunctCount} {t('opgeheven', 'defunct')}
|
||
</span>
|
||
)}
|
||
<span className="timeline-slider__range-display">
|
||
{selectedRange[0]} — {selectedRange[1]}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Presets */}
|
||
<div className="timeline-slider__presets">
|
||
{PRESETS.map(preset => (
|
||
<button
|
||
key={preset.id}
|
||
className={`timeline-slider__preset ${
|
||
selectedRange[0] === preset.range[0] && selectedRange[1] === preset.range[1]
|
||
? 'timeline-slider__preset--active'
|
||
: ''
|
||
}`}
|
||
onClick={() => onRangeChange(preset.range)}
|
||
disabled={!isActive}
|
||
>
|
||
{language === 'nl' ? preset.nl : preset.en}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Histogram and Slider */}
|
||
<div className="timeline-slider__track-container">
|
||
{/* Histogram bars */}
|
||
<div className="timeline-slider__histogram">
|
||
{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 (
|
||
<div
|
||
key={decade}
|
||
className={`timeline-slider__histogram-bar ${isInRange ? 'timeline-slider__histogram-bar--in-range' : ''} ${hasDissolved ? 'timeline-slider__histogram-bar--dissolved' : ''}`}
|
||
style={{
|
||
left: `${left}%`,
|
||
height: `${Math.max(height, 2)}%`,
|
||
}}
|
||
title={`${decade}s: ${data.founded} ${t('opgericht', 'founded')}${hasDissolved ? `, ${data.dissolved} ${t('opgeheven', 'dissolved')}` : ''}`}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Slider track */}
|
||
<div
|
||
className="timeline-slider__track"
|
||
ref={sliderRef}
|
||
>
|
||
{/* Selected range highlight */}
|
||
<div
|
||
className="timeline-slider__range-highlight"
|
||
style={{
|
||
left: `${startPercent}%`,
|
||
width: `${endPercent - startPercent}%`,
|
||
}}
|
||
/>
|
||
|
||
{/* Start handle */}
|
||
<div
|
||
className={`timeline-slider__handle timeline-slider__handle--start ${isDragging === 'start' ? 'timeline-slider__handle--dragging' : ''}`}
|
||
style={{ left: `${startPercent}%` }}
|
||
onMouseDown={handleMouseDown('start')}
|
||
onTouchStart={handleMouseDown('start')}
|
||
role="slider"
|
||
aria-label={t('Startjaar', 'Start year')}
|
||
aria-valuenow={selectedRange[0]}
|
||
aria-valuemin={MIN_YEAR}
|
||
aria-valuemax={MAX_YEAR}
|
||
tabIndex={0}
|
||
>
|
||
<span className="timeline-slider__handle-label">{selectedRange[0]}</span>
|
||
</div>
|
||
|
||
{/* End handle */}
|
||
<div
|
||
className={`timeline-slider__handle timeline-slider__handle--end ${isDragging === 'end' ? 'timeline-slider__handle--dragging' : ''}`}
|
||
style={{ left: `${endPercent}%` }}
|
||
onMouseDown={handleMouseDown('end')}
|
||
onTouchStart={handleMouseDown('end')}
|
||
role="slider"
|
||
aria-label={t('Eindjaar', 'End year')}
|
||
aria-valuenow={selectedRange[1]}
|
||
aria-valuemin={MIN_YEAR}
|
||
aria-valuemax={MAX_YEAR}
|
||
tabIndex={0}
|
||
>
|
||
<span className="timeline-slider__handle-label">{selectedRange[1]}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Axis labels */}
|
||
<div className="timeline-slider__axis">
|
||
<span>{MIN_YEAR}</span>
|
||
<span>1600</span>
|
||
<span>1800</span>
|
||
<span>1900</span>
|
||
<span>2000</span>
|
||
<span>{MAX_YEAR}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default TimelineSlider;
|