glam/frontend/src/components/map/TimelineSlider.tsx
kempersc 13f67bed19 feat(frontend): add graph visualization and data explorer features
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
2025-12-08 14:56:17 +01:00

385 lines
14 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

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

/**
* 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;