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
This commit is contained in:
kempersc 2025-12-08 14:56:17 +01:00
parent 7e3559f7e5
commit 13f67bed19
16 changed files with 3496 additions and 218 deletions

View file

@ -1,5 +1,5 @@
{
"generated": "2025-12-07T21:46:29.967Z",
"generated": "2025-12-08T13:51:53.356Z",
"version": "1.0.0",
"categories": [
{

View file

@ -114,6 +114,46 @@ classes:
See: .opencode/ABBREVIATION_SPECIAL_CHAR_RULE.md for complete documentation
===========================================================================
MANDATORY RULE: Diacritics MUST Be Normalized to ASCII in Abbreviations
===========================================================================
When generating abbreviations for GHCID, diacritics (accented characters)
MUST be normalized to their ASCII base letter equivalents. Only ASCII
uppercase letters (A-Z) are permitted in the abbreviation component.
RATIONALE:
1. URI/URL safety - Non-ASCII requires percent-encoding
2. Cross-system compatibility - ASCII is universally supported
3. Parsing consistency - No special character handling needed
4. Human readability - Easier to type and communicate
DIACRITICS TO NORMALIZE (examples by language):
- Czech: Č→C, Ř→R, Š→S, Ž→Z, Ě→E, Ů→U
- Polish: Ł→L, Ń→N, Ó→O, Ś→S, Ź→Z, Ż→Z, Ą→A, Ę→E
- German: Ä→A, Ö→O, Ü→U, ß→SS
- French: É→E, È→E, Ê→E, Ç→C, Ô→O
- Spanish: Ñ→N, Á→A, É→E, Í→I, Ó→O, Ú→U
- Nordic: Å→A, Ä→A, Ö→O, Ø→O, Æ→AE
EXAMPLES:
- "Vlastivědné muzeum" (Czech) → "VM" (not "VM" with háček)
- "Österreichische Nationalbibliothek" (German) → "ON"
- "Bibliothèque nationale" (French) → "BN"
REAL-WORLD EXAMPLE:
- ❌ WRONG: CZ-VY-TEL-L-VHSPAOČRZS (contains Č)
- ✅ CORRECT: CZ-VY-TEL-L-VHSPAOCRZS (ASCII only)
IMPLEMENTATION:
```python
import unicodedata
normalized = unicodedata.normalize('NFD', text)
ascii_text = ''.join(c for c in normalized if unicodedata.category(c) != 'Mn')
```
See: .opencode/ABBREVIATION_SPECIAL_CHAR_RULE.md for complete documentation
Can be generated by:
1. ReconstructionActivity (formal entity resolution) - was_generated_by link
2. Direct extraction (simple standardization) - no was_generated_by link

View file

@ -467,6 +467,411 @@
color: var(--text-secondary, #b0b0b0);
}
/* ==========================================================================
TypeDB Explore Tab Styles
========================================================================== */
/* Entity Type Grid */
.explore-section {
padding: 1rem 0;
}
.explore-header {
margin-bottom: 1.5rem;
}
.entity-type-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 0.75rem;
}
.entity-type-card {
background: white;
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 0.5rem;
padding: 0.875rem;
text-align: center;
cursor: pointer;
transition: all 0.15s ease;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.entity-type-card:hover {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
border-color: var(--primary-color, #2563eb);
}
.entity-type-card.selected {
border-color: var(--primary-color, #2563eb);
background: rgba(37, 99, 235, 0.05);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.entity-type-name {
font-weight: 600;
color: var(--text-primary, #111827);
font-size: 0.875rem;
}
.entity-type-count {
font-size: 0.75rem;
color: var(--text-secondary, #6b7280);
}
/* Data Viewer */
.data-viewer {
background: white;
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 0.5rem;
overflow: hidden;
}
.data-viewer-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: var(--table-header-bg, #f9fafb);
border-bottom: 1px solid var(--border-color, #e5e7eb);
flex-wrap: wrap;
gap: 0.75rem;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 1rem;
}
.toolbar-left h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--text-primary, #111827);
}
.instance-count {
font-size: 0.875rem;
color: var(--text-secondary, #6b7280);
}
.toolbar-right {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.view-toggle {
display: flex;
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 0.375rem;
overflow: hidden;
}
.toggle-btn {
padding: 0.375rem 0.75rem;
border: none;
background: white;
cursor: pointer;
font-size: 0.75rem;
color: var(--text-secondary, #6b7280);
transition: all 0.15s ease;
}
.toggle-btn:hover {
background: var(--hover-bg, #f9fafb);
}
.toggle-btn.active {
background: var(--primary-color, #2563eb);
color: white;
}
.export-btn {
padding: 0.375rem 0.75rem;
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 0.375rem;
background: white;
cursor: pointer;
font-size: 0.75rem;
color: var(--text-primary, #374151);
transition: all 0.15s ease;
}
.export-btn:hover:not(:disabled) {
background: var(--hover-bg, #f9fafb);
border-color: var(--primary-color, #2563eb);
}
.export-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Loading Container */
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
color: var(--text-secondary, #6b7280);
}
.loading-container .loading-spinner {
width: 24px;
height: 24px;
border: 3px solid var(--border-color, #e5e7eb);
border-top-color: var(--primary-color, #2563eb);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-bottom: 0.75rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Table Container */
.table-container {
max-height: 500px;
overflow-y: auto;
}
.data-table tbody tr.selected {
background: rgba(37, 99, 235, 0.08);
}
.data-table tbody tr {
cursor: pointer;
}
.attr-preview {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.attr-badge {
background: var(--code-bg, #f3f4f6);
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.75rem;
color: var(--text-secondary, #6b7280);
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attr-more {
font-size: 0.75rem;
color: var(--text-tertiary, #9ca3af);
font-style: italic;
}
/* Graph Container */
.graph-container {
display: flex;
justify-content: center;
padding: 1rem;
min-height: 520px;
}
/* Node Detail Panel */
.node-detail-panel {
border-top: 1px solid var(--border-color, #e5e7eb);
background: var(--table-header-bg, #f9fafb);
}
.node-detail-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color, #e5e7eb);
}
.node-detail-header h4 {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary, #111827);
}
.close-btn {
background: none;
border: none;
font-size: 1.25rem;
color: var(--text-secondary, #6b7280);
cursor: pointer;
padding: 0;
line-height: 1;
}
.close-btn:hover {
color: var(--text-primary, #111827);
}
.node-detail-content {
padding: 1rem;
font-size: 0.875rem;
}
.node-detail-content p {
margin: 0 0 0.5rem;
}
.node-detail-content code {
background: var(--code-bg, #f3f4f6);
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.75rem;
}
.attributes-list {
margin-top: 0.75rem;
}
.attributes-list ul {
margin: 0.5rem 0 0;
padding-left: 1.25rem;
list-style: disc;
}
.attributes-list li {
margin-bottom: 0.25rem;
}
.attr-key {
font-weight: 500;
color: var(--text-primary, #374151);
margin-right: 0.25rem;
}
.attr-value {
color: var(--text-secondary, #6b7280);
}
.no-attrs {
color: var(--text-tertiary, #9ca3af);
font-style: italic;
margin: 0.5rem 0 0;
}
.help-text {
text-align: center;
color: var(--text-secondary, #6b7280);
padding: 2rem;
}
/* Dark mode for TypeDB Explore */
[data-theme="dark"] .entity-type-card {
background: var(--surface-color, #2a2a2a);
border-color: var(--border-color, #404040);
}
[data-theme="dark"] .entity-type-card:hover {
border-color: var(--primary-color, #64b5f6);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
}
[data-theme="dark"] .entity-type-card.selected {
border-color: var(--primary-color, #64b5f6);
background: rgba(100, 181, 246, 0.1);
}
[data-theme="dark"] .entity-type-name {
color: var(--text-primary, #e0e0e0);
}
[data-theme="dark"] .entity-type-count {
color: var(--text-secondary, #b0b0b0);
}
[data-theme="dark"] .data-viewer {
background: var(--surface-color, #2a2a2a);
border-color: var(--border-color, #404040);
}
[data-theme="dark"] .data-viewer-toolbar {
background: var(--surface-secondary, #333333);
border-bottom-color: var(--border-color, #404040);
}
[data-theme="dark"] .toolbar-left h3 {
color: var(--text-primary, #e0e0e0);
}
[data-theme="dark"] .instance-count {
color: var(--text-secondary, #b0b0b0);
}
[data-theme="dark"] .view-toggle {
border-color: var(--border-color, #404040);
}
[data-theme="dark"] .toggle-btn {
background: var(--surface-color, #2a2a2a);
color: var(--text-secondary, #b0b0b0);
}
[data-theme="dark"] .toggle-btn:hover {
background: var(--surface-secondary, #333333);
}
[data-theme="dark"] .toggle-btn.active {
background: var(--primary-color, #64b5f6);
color: #111827;
}
[data-theme="dark"] .export-btn {
background: var(--surface-color, #2a2a2a);
border-color: var(--border-color, #404040);
color: var(--text-primary, #e0e0e0);
}
[data-theme="dark"] .export-btn:hover:not(:disabled) {
background: var(--surface-secondary, #333333);
}
[data-theme="dark"] .attr-badge {
background: var(--surface-secondary, #333333);
color: var(--text-secondary, #b0b0b0);
}
[data-theme="dark"] .node-detail-panel {
background: var(--surface-secondary, #333333);
border-top-color: var(--border-color, #404040);
}
[data-theme="dark"] .node-detail-header {
border-bottom-color: var(--border-color, #404040);
}
[data-theme="dark"] .node-detail-header h4 {
color: var(--text-primary, #e0e0e0);
}
[data-theme="dark"] .node-detail-content code {
background: var(--surface-color, #2a2a2a);
color: var(--text-primary, #e0e0e0);
}
[data-theme="dark"] .attr-key {
color: var(--text-primary, #e0e0e0);
}
[data-theme="dark"] .graph-container svg {
background: var(--surface-color, #2a2a2a) !important;
border-color: var(--border-color, #404040) !important;
}
/* Responsive */
@media (max-width: 768px) {
.heritage-dashboard {

View file

@ -3,8 +3,10 @@
* SPARQL triplestore for Linked Data
*/
import { useState, useRef } from 'react';
import { useState, useRef, useEffect, useCallback } from 'react';
import * as d3 from 'd3';
import { useOxigraph } from '@/hooks/useOxigraph';
import type { OxigraphGraphNode, OxigraphGraphData } from '@/hooks/useOxigraph';
import { useLanguage } from '@/contexts/LanguageContext';
interface OxigraphPanelProps {
@ -51,6 +53,16 @@ const TEXT = {
class: { nl: 'Klasse', en: 'Class' },
predicate: { nl: 'Predicaat', en: 'Predicate' },
count: { nl: 'Aantal', en: 'Count' },
explore: { nl: 'Verkennen', en: 'Explore' },
graphView: { nl: 'Graaf', en: 'Graph' },
tableView: { nl: 'Tabel', en: 'Table' },
loadGraph: { nl: 'Graaf laden', en: 'Load Graph' },
allClasses: { nl: 'Alle klassen', en: 'All Classes' },
selectClass: { nl: 'Selecteer klasse...', en: 'Select class...' },
nodes: { nl: 'Knooppunten', en: 'Nodes' },
edges: { nl: 'Verbindingen', en: 'Edges' },
search: { nl: 'Zoeken...', en: 'Search...' },
attributes: { nl: 'Attributen', en: 'Attributes' },
};
/**
@ -89,6 +101,212 @@ function shortenUri(uri: string): string {
return uri;
}
// D3 node interface extending simulation datum
interface D3Node extends d3.SimulationNodeDatum {
id: string;
label: string;
type: string;
entityType: string;
attributes: Record<string, unknown>;
}
// D3 link interface
interface D3Link extends d3.SimulationLinkDatum<D3Node> {
id: string;
predicate: string;
predicateLabel: string;
}
// ============================================================================
// Graph Visualization Component for Oxigraph
// ============================================================================
interface OxigraphGraphVisualizationProps {
data: OxigraphGraphData;
width?: number;
height?: number;
onNodeClick?: (node: OxigraphGraphNode) => void;
}
function OxigraphGraphVisualization({ data, width = 800, height = 500, onNodeClick }: OxigraphGraphVisualizationProps) {
const svgRef = useRef<SVGSVGElement>(null);
const getNodeColor = useCallback((entityType: string): string => {
const colors: Record<string, string> = {
'schema:Museum': '#e74c3c',
'schema:Library': '#3498db',
'schema:ArchiveOrganization': '#2ecc71',
'hc:HeritageCustodian': '#9b59b6',
'cpov:PublicOrganisation': '#1abc9c',
'schema:Place': '#16a085',
'schema:Organization': '#f39c12',
'crm:E74_Group': '#e67e22',
'rico:CorporateBody': '#d35400',
};
return colors[entityType] || '#95a5a6';
}, []);
useEffect(() => {
if (!svgRef.current || data.nodes.length === 0) return;
const svg = d3.select(svgRef.current);
svg.selectAll('*').remove();
// Create container with zoom
const container = svg.append('g').attr('class', 'graph-container');
// Arrow marker
const defs = svg.append('defs');
defs.append('marker')
.attr('id', 'oxigraph-arrow')
.attr('viewBox', '0 -5 10 10')
.attr('refX', 25)
.attr('refY', 0)
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('fill', '#999');
// Create D3 nodes and links
const d3Nodes: D3Node[] = data.nodes.map(n => ({
...n,
x: width / 2 + (Math.random() - 0.5) * 200,
y: height / 2 + (Math.random() - 0.5) * 200,
}));
const nodeMap = new Map(d3Nodes.map(n => [n.id, n]));
const d3Links: D3Link[] = data.edges
.filter(e => nodeMap.has(e.source) && nodeMap.has(e.target))
.map(e => ({
id: e.id,
source: nodeMap.get(e.source)!,
target: nodeMap.get(e.target)!,
predicate: e.predicate,
predicateLabel: e.predicateLabel,
}));
// Force simulation
const simulation = d3.forceSimulation<D3Node>(d3Nodes)
.force('link', d3.forceLink<D3Node, D3Link>(d3Links).id(d => d.id).distance(120))
.force('charge', d3.forceManyBody().strength(-400))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide().radius(40));
// Links
const link = container.append('g')
.attr('class', 'links')
.selectAll('line')
.data(d3Links)
.join('line')
.attr('stroke', '#999')
.attr('stroke-width', 2)
.attr('stroke-opacity', 0.6)
.attr('marker-end', 'url(#oxigraph-arrow)');
// Link labels
const linkLabel = container.append('g')
.attr('class', 'link-labels')
.selectAll('text')
.data(d3Links)
.join('text')
.attr('font-size', 9)
.attr('fill', '#666')
.attr('text-anchor', 'middle')
.text(d => d.predicateLabel);
// Nodes
const node = container.append('g')
.attr('class', 'nodes')
.selectAll('circle')
.data(d3Nodes)
.join('circle')
.attr('r', 15)
.attr('fill', d => getNodeColor(d.entityType))
.attr('stroke', '#fff')
.attr('stroke-width', 2)
.style('cursor', 'pointer')
.on('click', (_event, d) => {
onNodeClick?.({
id: d.id,
label: d.label,
type: d.type,
entityType: d.entityType,
attributes: d.attributes,
});
})
.call(d3.drag<SVGCircleElement, D3Node>()
.on('start', (event, d) => {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
})
.on('drag', (event, d) => {
d.fx = event.x;
d.fy = event.y;
})
.on('end', (event, d) => {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}) as any);
// Node labels
const nodeLabel = container.append('g')
.attr('class', 'node-labels')
.selectAll('text')
.data(d3Nodes)
.join('text')
.attr('font-size', 11)
.attr('dx', 18)
.attr('dy', 4)
.attr('fill', '#333')
.text(d => d.label.length > 20 ? d.label.substring(0, 17) + '...' : d.label);
// Simulation tick
simulation.on('tick', () => {
link
.attr('x1', d => (d.source as D3Node).x!)
.attr('y1', d => (d.source as D3Node).y!)
.attr('x2', d => (d.target as D3Node).x!)
.attr('y2', d => (d.target as D3Node).y!);
linkLabel
.attr('x', d => ((d.source as D3Node).x! + (d.target as D3Node).x!) / 2)
.attr('y', d => ((d.source as D3Node).y! + (d.target as D3Node).y!) / 2 - 5);
node
.attr('cx', d => d.x!)
.attr('cy', d => d.y!);
nodeLabel
.attr('x', d => d.x!)
.attr('y', d => d.y!);
});
// Zoom behavior
const zoom = d3.zoom<SVGSVGElement, unknown>()
.scaleExtent([0.1, 4])
.on('zoom', (event) => {
container.attr('transform', event.transform);
});
svg.call(zoom as any);
}, [data, width, height, getNodeColor, onNodeClick]);
return (
<svg
ref={svgRef}
width={width}
height={height}
style={{ background: '#fafafa', borderRadius: '8px', border: '1px solid #e0e0e0' }}
/>
);
}
export function OxigraphPanel({ compact = false }: OxigraphPanelProps) {
const { language } = useLanguage();
const t = (key: keyof typeof TEXT) => TEXT[key][language];
@ -103,9 +321,10 @@ export function OxigraphPanel({ compact = false }: OxigraphPanelProps) {
loadRdfData,
clearGraph,
exportGraph,
getGraphData,
} = useOxigraph();
const [activeTab, setActiveTab] = useState<'graphs' | 'classes' | 'predicates' | 'namespaces' | 'query' | 'upload'>('graphs');
const [activeTab, setActiveTab] = useState<'explore' | 'graphs' | 'classes' | 'predicates' | 'namespaces' | 'query' | 'upload'>('explore');
const [query, setQuery] = useState('SELECT * WHERE { ?s ?p ?o } LIMIT 10');
const [queryResult, setQueryResult] = useState<string | null>(null);
const [isQueryRunning, setIsQueryRunning] = useState(false);
@ -113,6 +332,14 @@ export function OxigraphPanel({ compact = false }: OxigraphPanelProps) {
const [uploadGraph, setUploadGraph] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
// Explore tab state
const [selectedClass, setSelectedClass] = useState<string | null>(null);
const [exploreViewMode, setExploreViewMode] = useState<'graph' | 'table'>('graph');
const [graphData, setGraphData] = useState<OxigraphGraphData | null>(null);
const [isLoadingData, setIsLoadingData] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [selectedNode, setSelectedNode] = useState<OxigraphGraphNode | null>(null);
const handleRunQuery = async () => {
setIsQueryRunning(true);
setQueryResult(null);
@ -168,6 +395,33 @@ export function OxigraphPanel({ compact = false }: OxigraphPanelProps) {
}
};
// Handler for loading graph visualization data
const handleLoadGraphData = async (rdfClass?: string) => {
setSelectedClass(rdfClass || null);
setIsLoadingData(true);
setGraphData(null);
setSelectedNode(null);
try {
const data = await getGraphData(rdfClass, 100);
setGraphData(data);
} catch (err) {
console.error('Failed to load graph data:', err);
} finally {
setIsLoadingData(false);
}
};
// Filter nodes by search term
const filteredNodes = graphData?.nodes.filter(n => {
if (!searchTerm) return true;
const term = searchTerm.toLowerCase();
return (
n.label.toLowerCase().includes(term) ||
n.id.toLowerCase().includes(term) ||
n.entityType.toLowerCase().includes(term)
);
}) ?? [];
// Compact view for comparison grid
if (compact) {
return (
@ -254,6 +508,12 @@ export function OxigraphPanel({ compact = false }: OxigraphPanelProps) {
{/* Tabs */}
<div className="panel-tabs">
<button
className={`tab-btn ${activeTab === 'explore' ? 'active' : ''}`}
onClick={() => setActiveTab('explore')}
>
{t('explore')}
</button>
<button
className={`tab-btn ${activeTab === 'graphs' ? 'active' : ''}`}
onClick={() => setActiveTab('graphs')}
@ -294,6 +554,140 @@ export function OxigraphPanel({ compact = false }: OxigraphPanelProps) {
{/* Tab Content */}
<div className="panel-content">
{/* EXPLORE TAB - Graph Visualization */}
{activeTab === 'explore' && (
<div className="explore-section">
{/* Class Selector and Controls */}
<div className="explore-header">
<div className="explore-controls">
<select
className="class-select"
value={selectedClass || ''}
onChange={(e) => handleLoadGraphData(e.target.value || undefined)}
>
<option value="">{t('allClasses')}</option>
{stats?.classes.map((cls, index) => (
<option key={index} value={cls.class}>
{shortenUri(cls.class)} ({cls.count})
</option>
))}
</select>
<button
className="primary-button"
onClick={() => handleLoadGraphData(selectedClass || undefined)}
disabled={isLoadingData}
>
{isLoadingData ? t('loading') : t('loadGraph')}
</button>
</div>
{graphData && (
<div className="graph-stats">
<span className="stat-badge">{graphData.nodes.length} {t('nodes')}</span>
<span className="stat-badge">{graphData.edges.length} {t('edges')}</span>
</div>
)}
</div>
{/* Data Viewer */}
{graphData && (
<div className="data-viewer">
<div className="data-viewer-toolbar">
<div className="toolbar-left">
<h3>{selectedClass ? shortenUri(selectedClass) : t('allClasses')}</h3>
<span className="instance-count">
{filteredNodes.length} {t('nodes')}
</span>
</div>
<div className="toolbar-right">
<input
type="text"
className="search-input"
placeholder={t('search')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<div className="view-toggle">
<button
className={`toggle-btn ${exploreViewMode === 'graph' ? 'active' : ''}`}
onClick={() => setExploreViewMode('graph')}
>
{t('graphView')}
</button>
<button
className={`toggle-btn ${exploreViewMode === 'table' ? 'active' : ''}`}
onClick={() => setExploreViewMode('table')}
>
{t('tableView')}
</button>
</div>
</div>
</div>
{isLoadingData ? (
<div className="loading-container">
<div className="loading-spinner" />
<p>{t('loading')}</p>
</div>
) : !graphData?.nodes.length ? (
<p className="empty-message">{t('noData')}</p>
) : exploreViewMode === 'graph' ? (
<div className="graph-container">
<OxigraphGraphVisualization
data={{ nodes: filteredNodes, edges: graphData.edges }}
width={800}
height={500}
onNodeClick={setSelectedNode}
/>
</div>
) : (
<div className="table-container">
<table className="data-table">
<thead>
<tr>
<th>URI</th>
<th>Label</th>
<th>Type</th>
</tr>
</thead>
<tbody>
{filteredNodes.map(node => (
<tr
key={node.id}
onClick={() => setSelectedNode(node)}
className={selectedNode?.id === node.id ? 'selected' : ''}
>
<td><code title={node.id}>{shortenUri(node.id)}</code></td>
<td>{node.label}</td>
<td>{node.entityType}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Node Detail Panel */}
{selectedNode && (
<div className="node-detail-panel">
<div className="node-detail-header">
<h4>{selectedNode.label}</h4>
<button className="close-btn" onClick={() => setSelectedNode(null)}>×</button>
</div>
<div className="node-detail-content">
<p><strong>URI:</strong> <code>{selectedNode.id}</code></p>
<p><strong>Type:</strong> {selectedNode.entityType}</p>
</div>
</div>
)}
</div>
)}
{!graphData && !isLoadingData && (
<p className="help-text">Select a class and click "Load Graph" to visualize RDF data</p>
)}
</div>
)}
{activeTab === 'graphs' && (
<div className="graphs-list">
{!stats?.graphs.length ? (

View file

@ -1,9 +1,10 @@
/**
* PostgreSQL Panel Component
* Relational database for structured heritage data
* With Data Explorer for viewing table contents (similar to DuckLake)
*/
import { useState } from 'react';
import { useState, useCallback } from 'react';
import { usePostgreSQL } from '@/hooks/usePostgreSQL';
import { useLanguage } from '@/contexts/LanguageContext';
@ -41,6 +42,52 @@ const TEXT = {
nl: 'PostgreSQL API niet beschikbaar. Configureer VITE_POSTGRES_API_URL.',
en: 'PostgreSQL API not available. Configure VITE_POSTGRES_API_URL.',
},
// Data Explorer translations
explore: { nl: 'Verkennen', en: 'Explore' },
dataExplorer: { nl: 'Data Verkenner', en: 'Data Explorer' },
selectTable: { nl: 'Selecteer een tabel om te verkennen', en: 'Select a table to explore' },
viewData: { nl: 'Bekijk data', en: 'View data' },
backToTables: { nl: '← Terug naar tabellen', en: '← Back to tables' },
data: { nl: 'Data', en: 'Data' },
search: { nl: 'Zoeken', en: 'Search' },
searchPlaceholder: { nl: 'Zoek in alle kolommen...', en: 'Search across all columns...' },
resultsFiltered: { nl: 'resultaten gefilterd', en: 'results filtered' },
clearSearch: { nl: 'Wissen', en: 'Clear' },
showingRows: { nl: 'Toon rijen', en: 'Showing rows' },
of: { nl: 'van', en: 'of' },
previous: { nl: 'Vorige', en: 'Previous' },
next: { nl: 'Volgende', en: 'Next' },
export: { nl: 'Exporteren', en: 'Export' },
noData: { nl: 'Geen data gevonden.', en: 'No data found.' },
};
// Get table icon based on name
const getTableIcon = (tableName: string): string => {
const name = tableName.toLowerCase();
if (name.includes('institution') || name.includes('custodian')) return '🏛️';
if (name.includes('collection')) return '📚';
if (name.includes('location') || name.includes('geo') || name.includes('boundary')) return '📍';
if (name.includes('person') || name.includes('staff')) return '👤';
if (name.includes('event')) return '📅';
if (name.includes('identifier') || name.includes('id')) return '🔖';
if (name.includes('linkml') || name.includes('schema')) return '📋';
if (name.includes('spatial') || name.includes('topology')) return '🗺️';
if (name.startsWith('v_')) return '👁️'; // Views
return '📊';
};
// Format cell value for display
const formatCellValue = (value: unknown): string => {
if (value === null || value === undefined) return '—';
if (typeof value === 'object') {
const json = JSON.stringify(value);
if (json.length > 60) return json.slice(0, 57) + '...';
return json;
}
if (typeof value === 'string') {
if (value.length > 80) return value.slice(0, 77) + '...';
}
return String(value);
};
export function PostgreSQLPanel({ compact = false }: PostgreSQLPanelProps) {
@ -54,13 +101,23 @@ export function PostgreSQLPanel({ compact = false }: PostgreSQLPanelProps) {
error,
refresh,
executeQuery,
getTableData,
exportTableData,
} = usePostgreSQL();
const [activeTab, setActiveTab] = useState<'overview' | 'query'>('overview');
const [activeTab, setActiveTab] = useState<'explore' | 'query'>('explore');
const [query, setQuery] = useState('SELECT tablename FROM pg_tables WHERE schemaname = \'public\'');
const [queryResult, setQueryResult] = useState<string | null>(null);
const [isQueryRunning, setIsQueryRunning] = useState(false);
const [expandedTable, setExpandedTable] = useState<string | null>(null);
// Data Explorer state
const [selectedTable, setSelectedTable] = useState<{ schema: string; name: string } | null>(null);
const [tableData, setTableData] = useState<{ columns: string[]; rows: unknown[][]; totalRows: number } | null>(null);
const [isLoadingData, setIsLoadingData] = useState(false);
const [dataPage, setDataPage] = useState(0);
const [searchQuery, setSearchQuery] = useState('');
const [explorerView, setExplorerView] = useState<'list' | 'data'>('list');
const PAGE_SIZE = 50;
const handleRunQuery = async () => {
setIsQueryRunning(true);
@ -75,6 +132,86 @@ export function PostgreSQLPanel({ compact = false }: PostgreSQLPanelProps) {
}
};
// Load table data for explorer
const loadTableData = useCallback(async (schema: string, tableName: string, page: number = 0) => {
setIsLoadingData(true);
setSelectedTable({ schema, name: tableName });
setDataPage(page);
setSearchQuery('');
try {
const result = await getTableData(schema, tableName, PAGE_SIZE, page * PAGE_SIZE);
setTableData({
columns: result.columns,
rows: result.rows,
totalRows: result.totalRows,
});
setExplorerView('data');
} catch (err) {
console.error('Failed to load table data:', err);
setTableData(null);
} finally {
setIsLoadingData(false);
}
}, [getTableData]);
// Load page for pagination
const loadPage = useCallback(async (page: number) => {
if (!selectedTable) return;
setIsLoadingData(true);
setDataPage(page);
try {
const result = await getTableData(selectedTable.schema, selectedTable.name, PAGE_SIZE, page * PAGE_SIZE);
setTableData({
columns: result.columns,
rows: result.rows,
totalRows: result.totalRows,
});
} catch (err) {
console.error('Failed to load page:', err);
} finally {
setIsLoadingData(false);
}
}, [selectedTable, getTableData]);
// Export table data
const handleExport = useCallback(async (format: 'json' | 'csv') => {
if (!selectedTable) return;
try {
const blob = await exportTableData(selectedTable.schema, selectedTable.name, format);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${selectedTable.schema}_${selectedTable.name}.${format}`;
a.click();
URL.revokeObjectURL(url);
} catch (err) {
alert(`Export failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
}, [selectedTable, exportTableData]);
// Filter rows by search query
const filterRowsBySearch = useCallback((rows: unknown[][], query: string): unknown[][] => {
if (!query.trim()) return rows;
const lowerQuery = query.toLowerCase();
return rows.filter(row => {
return row.some(cell => {
if (cell === null || cell === undefined) return false;
const cellStr = typeof cell === 'object'
? JSON.stringify(cell).toLowerCase()
: String(cell).toLowerCase();
return cellStr.includes(lowerQuery);
});
});
}, []);
// Back to table list
const backToList = () => {
setExplorerView('list');
setSelectedTable(null);
setTableData(null);
setSearchQuery('');
};
// Compact view for comparison grid
if (compact) {
return (
@ -107,6 +244,10 @@ export function PostgreSQLPanel({ compact = false }: PostgreSQLPanelProps) {
);
}
// Get filtered rows for display
const displayRows = tableData ? filterRowsBySearch(tableData.rows, searchQuery) : [];
const totalPages = tableData ? Math.ceil(tableData.totalRows / PAGE_SIZE) : 0;
// Full view
return (
<div className="db-panel full postgres-panel">
@ -165,10 +306,10 @@ export function PostgreSQLPanel({ compact = false }: PostgreSQLPanelProps) {
{/* Tabs */}
<div className="panel-tabs">
<button
className={`tab-btn ${activeTab === 'overview' ? 'active' : ''}`}
onClick={() => setActiveTab('overview')}
className={`tab-btn ${activeTab === 'explore' ? 'active' : ''}`}
onClick={() => setActiveTab('explore')}
>
{t('tables')}
📊 {t('explore')}
</button>
<button
className={`tab-btn ${activeTab === 'query' ? 'active' : ''}`}
@ -180,70 +321,149 @@ export function PostgreSQLPanel({ compact = false }: PostgreSQLPanelProps) {
{/* Tab Content */}
<div className="panel-content">
{activeTab === 'overview' && (
<div className="tables-list">
{!stats?.tables.length ? (
<p className="empty-message">{t('noTables')}</p>
) : (
<table className="data-table">
<thead>
<tr>
<th>{t('schema')}</th>
<th>{t('tableName')}</th>
<th>{t('rows')}</th>
<th>{t('columns')}</th>
</tr>
</thead>
<tbody>
{stats.tables.map((table) => (
<>
<tr
{activeTab === 'explore' && (
<div className="data-explorer">
{explorerView === 'list' ? (
/* Table Selection Grid */
<div className="table-selection">
<p className="selection-hint">{t('selectTable')}</p>
{!stats?.tables.length ? (
<p className="empty-message">{t('noTables')}</p>
) : (
<div className="table-grid">
{stats.tables.map((table) => (
<div
key={`${table.schema}.${table.name}`}
className="clickable"
onClick={() =>
setExpandedTable(
expandedTable === `${table.schema}.${table.name}`
? null
: `${table.schema}.${table.name}`
)
}
className="table-card clickable"
onClick={() => loadTableData(table.schema, table.name)}
>
<td><code>{table.schema}</code></td>
<td><code>{table.name}</code></td>
<td>{table.rowCount.toLocaleString()}</td>
<td>{table.columns.length}</td>
</tr>
{expandedTable === `${table.schema}.${table.name}` && (
<tr className="expanded-row">
<td colSpan={4}>
<div className="column-list">
<h4>{t('columns')}</h4>
<table className="nested-table">
<thead>
<tr>
<th>Column</th>
<th>{t('type')}</th>
<th>{t('nullable')}</th>
</tr>
</thead>
<tbody>
{table.columns.map((col) => (
<tr key={col.name}>
<td><code>{col.name}</code></td>
<td>{col.type}</td>
<td>{col.nullable ? 'Yes' : 'No'}</td>
</tr>
))}
</tbody>
</table>
</div>
</td>
</tr>
)}
<div className="table-card-icon">{getTableIcon(table.name)}</div>
<div className="table-card-info">
<div className="table-card-name">
<code>{table.name}</code>
</div>
<div className="table-card-meta">
<span>{table.schema}</span>
<span>{table.rowCount.toLocaleString()} {t('rows')}</span>
<span>{table.columns.length} {t('columns')}</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
) : (
/* Data Viewer */
<div className="data-viewer">
{/* Header with back button and table info */}
<div className="data-viewer-header">
<button className="back-btn" onClick={backToList}>
{t('backToTables')}
</button>
<div className="current-table">
<span className="table-icon">{selectedTable ? getTableIcon(selectedTable.name) : '📊'}</span>
<span className="table-name">
<code>{selectedTable?.schema}.{selectedTable?.name}</code>
</span>
<span className="row-count">
({tableData?.totalRows.toLocaleString() ?? 0} {t('rows')})
</span>
</div>
<div className="export-buttons">
<button
className="export-btn"
onClick={() => handleExport('csv')}
title="Export as CSV"
>
📥 CSV
</button>
<button
className="export-btn"
onClick={() => handleExport('json')}
title="Export as JSON"
>
📥 JSON
</button>
</div>
</div>
{/* Search bar */}
<div className="search-bar">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('searchPlaceholder')}
className="search-input"
/>
{searchQuery && (
<>
<span className="search-results">
{displayRows.length} {t('resultsFiltered')}
</span>
<button className="clear-btn" onClick={() => setSearchQuery('')}>
{t('clearSearch')}
</button>
</>
))}
</tbody>
</table>
)}
</div>
{/* Data table */}
{isLoadingData ? (
<div className="loading-indicator">{t('loading')}</div>
) : !tableData || displayRows.length === 0 ? (
<p className="empty-message">{t('noData')}</p>
) : (
<div className="data-table-wrapper">
<table className="data-table resizable">
<thead>
<tr>
{tableData.columns.map((col) => (
<th key={col}>
<span>{col}</span>
</th>
))}
</tr>
</thead>
<tbody>
{displayRows.map((row, rowIdx) => (
<tr key={rowIdx}>
{row.map((cell, cellIdx) => (
<td key={cellIdx} title={typeof cell === 'string' ? cell : undefined}>
{formatCellValue(cell)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Pagination */}
{tableData && tableData.totalRows > PAGE_SIZE && !searchQuery && (
<div className="pagination">
<button
className="page-btn"
onClick={() => loadPage(dataPage - 1)}
disabled={dataPage === 0 || isLoadingData}
>
{t('previous')}
</button>
<span className="page-info">
{t('showingRows')} {dataPage * PAGE_SIZE + 1}-{Math.min((dataPage + 1) * PAGE_SIZE, tableData.totalRows)} {t('of')} {tableData.totalRows.toLocaleString()}
</span>
<button
className="page-btn"
onClick={() => loadPage(dataPage + 1)}
disabled={dataPage >= totalPages - 1 || isLoadingData}
>
{t('next')}
</button>
</div>
)}
</div>
)}
</div>
)}

View file

@ -1,10 +1,18 @@
/**
* TypeDB Panel Component
* Knowledge graph for complex heritage relationships
*
* Features:
* - Explore tab with data viewer and D3.js graph visualization
* - Entity/Relation/Attribute type browser
* - TypeQL query interface
* - Schema viewer
*/
import { useState } from 'react';
import { useState, useRef, useEffect, useCallback } from 'react';
import * as d3 from 'd3';
import { useTypeDB } from '@/hooks/useTypeDB';
import type { TypeDBGraphNode, TypeDBGraphData } from '@/hooks/useTypeDB';
import { useLanguage } from '@/contexts/LanguageContext';
interface TypeDBPanelProps {
@ -44,8 +52,234 @@ const TEXT = {
nl: 'TypeDB API niet beschikbaar. Configureer VITE_TYPEDB_API_URL.',
en: 'TypeDB API not available. Configure VITE_TYPEDB_API_URL.',
},
explore: { nl: 'Verkennen', en: 'Explore' },
selectEntityType: { nl: 'Selecteer entity type...', en: 'Select entity type...' },
loadData: { nl: 'Data laden', en: 'Load Data' },
graphView: { nl: 'Graaf', en: 'Graph' },
tableView: { nl: 'Tabel', en: 'Table' },
instances: { nl: 'instances', en: 'instances' },
search: { nl: 'Zoeken...', en: 'Search...' },
exportCsv: { nl: 'CSV', en: 'CSV' },
exportJson: { nl: 'JSON', en: 'JSON' },
attributes: { nl: 'Attributen', en: 'Attributes' },
noInstances: { nl: 'Geen instances gevonden voor dit type.', en: 'No instances found for this type.' },
clickToExplore: { nl: 'Klik op een entity type om te verkennen', en: 'Click on an entity type to explore' },
};
// D3 node interface extending simulation datum
interface D3Node extends d3.SimulationNodeDatum {
id: string;
label: string;
type: string;
entityType: string;
attributes: Record<string, unknown>;
}
// D3 link interface
interface D3Link extends d3.SimulationLinkDatum<D3Node> {
id: string;
relationType: string;
role: string;
}
// ============================================================================
// Graph Visualization Component
// ============================================================================
interface GraphVisualizationProps {
data: TypeDBGraphData;
width?: number;
height?: number;
onNodeClick?: (node: TypeDBGraphNode) => void;
}
function GraphVisualization({ data, width = 800, height = 500, onNodeClick }: GraphVisualizationProps) {
const svgRef = useRef<SVGSVGElement>(null);
const getNodeColor = useCallback((entityType: string): string => {
const colors: Record<string, string> = {
'custodian': '#e74c3c',
'museum': '#e74c3c',
'library': '#3498db',
'archive': '#2ecc71',
'gallery': '#f39c12',
'collection': '#9b59b6',
'organization': '#1abc9c',
'person': '#34495e',
'place': '#16a085',
'location': '#16a085',
'event': '#d35400',
'identifier': '#7f8c8d',
};
const key = entityType.toLowerCase();
return colors[key] || '#95a5a6';
}, []);
useEffect(() => {
if (!svgRef.current || data.nodes.length === 0) return;
const svg = d3.select(svgRef.current);
svg.selectAll('*').remove();
// Create container with zoom
const container = svg.append('g').attr('class', 'graph-container');
// Arrow marker
const defs = svg.append('defs');
defs.append('marker')
.attr('id', 'typedb-arrow')
.attr('viewBox', '0 -5 10 10')
.attr('refX', 25)
.attr('refY', 0)
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('fill', '#999');
// Create D3 nodes and links
const d3Nodes: D3Node[] = data.nodes.map(n => ({
...n,
x: width / 2 + (Math.random() - 0.5) * 200,
y: height / 2 + (Math.random() - 0.5) * 200,
}));
const nodeMap = new Map(d3Nodes.map(n => [n.id, n]));
const d3Links: D3Link[] = data.edges
.filter(e => nodeMap.has(e.source) && nodeMap.has(e.target))
.map(e => ({
id: e.id,
source: nodeMap.get(e.source)!,
target: nodeMap.get(e.target)!,
relationType: e.relationType,
role: e.role,
}));
// Force simulation
const simulation = d3.forceSimulation<D3Node>(d3Nodes)
.force('link', d3.forceLink<D3Node, D3Link>(d3Links).id(d => d.id).distance(120))
.force('charge', d3.forceManyBody().strength(-400))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide().radius(40));
// Links
const link = container.append('g')
.attr('class', 'links')
.selectAll('line')
.data(d3Links)
.join('line')
.attr('stroke', '#999')
.attr('stroke-width', 2)
.attr('stroke-opacity', 0.6)
.attr('marker-end', 'url(#typedb-arrow)');
// Link labels
const linkLabel = container.append('g')
.attr('class', 'link-labels')
.selectAll('text')
.data(d3Links)
.join('text')
.attr('font-size', 9)
.attr('fill', '#666')
.attr('text-anchor', 'middle')
.text(d => d.relationType);
// Nodes
const node = container.append('g')
.attr('class', 'nodes')
.selectAll('circle')
.data(d3Nodes)
.join('circle')
.attr('r', 15)
.attr('fill', d => getNodeColor(d.entityType))
.attr('stroke', '#fff')
.attr('stroke-width', 2)
.style('cursor', 'pointer')
.on('click', (_event, d) => {
onNodeClick?.({
id: d.id,
label: d.label,
type: d.type,
entityType: d.entityType,
attributes: d.attributes,
});
})
.call(d3.drag<SVGCircleElement, D3Node>()
.on('start', (event, d) => {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
})
.on('drag', (event, d) => {
d.fx = event.x;
d.fy = event.y;
})
.on('end', (event, d) => {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}) as any);
// Node labels
const nodeLabel = container.append('g')
.attr('class', 'node-labels')
.selectAll('text')
.data(d3Nodes)
.join('text')
.attr('font-size', 11)
.attr('dx', 18)
.attr('dy', 4)
.attr('fill', '#333')
.text(d => d.label.length > 20 ? d.label.substring(0, 17) + '...' : d.label);
// Simulation tick
simulation.on('tick', () => {
link
.attr('x1', d => (d.source as D3Node).x!)
.attr('y1', d => (d.source as D3Node).y!)
.attr('x2', d => (d.target as D3Node).x!)
.attr('y2', d => (d.target as D3Node).y!);
linkLabel
.attr('x', d => ((d.source as D3Node).x! + (d.target as D3Node).x!) / 2)
.attr('y', d => ((d.source as D3Node).y! + (d.target as D3Node).y!) / 2 - 5);
node
.attr('cx', d => d.x!)
.attr('cy', d => d.y!);
nodeLabel
.attr('x', d => d.x!)
.attr('y', d => d.y!);
});
// Zoom behavior
const zoom = d3.zoom<SVGSVGElement, unknown>()
.scaleExtent([0.1, 4])
.on('zoom', (event) => {
container.attr('transform', event.transform);
});
svg.call(zoom as any);
}, [data, width, height, getNodeColor, onNodeClick]);
return (
<svg
ref={svgRef}
width={width}
height={height}
style={{ background: '#fafafa', borderRadius: '8px', border: '1px solid #e0e0e0' }}
/>
);
}
// ============================================================================
// Main Panel Component
// ============================================================================
export function TypeDBPanel({ compact = false }: TypeDBPanelProps) {
const { language } = useLanguage();
const t = (key: keyof typeof TEXT) => TEXT[key][language];
@ -58,14 +292,23 @@ export function TypeDBPanel({ compact = false }: TypeDBPanelProps) {
refresh,
executeQuery,
getSchema,
getGraphData,
} = useTypeDB();
const [activeTab, setActiveTab] = useState<'entities' | 'relations' | 'attributes' | 'query' | 'schema'>('entities');
const [activeTab, setActiveTab] = useState<'explore' | 'entities' | 'relations' | 'attributes' | 'query' | 'schema'>('explore');
const [query, setQuery] = useState('match $x isa entity; get $x; limit 10;');
const [queryResult, setQueryResult] = useState<string | null>(null);
const [schemaContent, setSchemaContent] = useState<string | null>(null);
const [isQueryRunning, setIsQueryRunning] = useState(false);
// Explore tab state
const [selectedEntityType, setSelectedEntityType] = useState<string | null>(null);
const [exploreViewMode, setExploreViewMode] = useState<'graph' | 'table'>('table');
const [graphData, setGraphData] = useState<TypeDBGraphData | null>(null);
const [isLoadingData, setIsLoadingData] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [selectedNode, setSelectedNode] = useState<TypeDBGraphNode | null>(null);
const handleRunQuery = async () => {
setIsQueryRunning(true);
setQueryResult(null);
@ -89,12 +332,73 @@ export function TypeDBPanel({ compact = false }: TypeDBPanelProps) {
}
};
const handleLoadEntityData = async (entityType: string) => {
setSelectedEntityType(entityType);
setIsLoadingData(true);
setGraphData(null);
setSelectedNode(null);
try {
const data = await getGraphData(entityType, 1, 100);
setGraphData(data);
} catch (err) {
console.error('Failed to load graph data:', err);
} finally {
setIsLoadingData(false);
}
};
const handleExportCsv = () => {
if (!graphData?.nodes.length) return;
const headers = ['id', 'label', 'entityType', 'attributes'];
const rows = graphData.nodes.map(n => [
n.id,
n.label,
n.entityType,
JSON.stringify(n.attributes),
]);
const csv = [headers.join(','), ...rows.map(r => r.map(v => `"${v}"`).join(','))].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `typedb_${selectedEntityType}_export.csv`;
a.click();
URL.revokeObjectURL(url);
};
const handleExportJson = () => {
if (!graphData?.nodes.length) return;
const json = JSON.stringify(graphData, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `typedb_${selectedEntityType}_export.json`;
a.click();
URL.revokeObjectURL(url);
};
// Filter nodes by search term
const filteredNodes = graphData?.nodes.filter(n => {
if (!searchTerm) return true;
const term = searchTerm.toLowerCase();
return (
n.label.toLowerCase().includes(term) ||
n.id.toLowerCase().includes(term) ||
n.entityType.toLowerCase().includes(term) ||
Object.values(n.attributes).some(v => String(v).toLowerCase().includes(term))
);
}) ?? [];
// Compact view for comparison grid
if (compact) {
return (
<div className="db-panel compact typedb-panel">
<div className="panel-header">
<span className="panel-icon">🧠</span>
<span className="panel-icon">🔷</span>
<h3>TypeDB</h3>
<span className={`status-badge ${status.isConnected ? 'connected' : 'disconnected'}`}>
{isLoading ? t('loading') : status.isConnected ? t('connected') : t('disconnected')}
@ -125,7 +429,7 @@ export function TypeDBPanel({ compact = false }: TypeDBPanelProps) {
{/* Header */}
<div className="panel-header-full">
<div className="header-info">
<span className="panel-icon-large">🧠</span>
<span className="panel-icon-large">🔷</span>
<div>
<h2>{t('title')}</h2>
<p>{t('description')}</p>
@ -180,6 +484,12 @@ export function TypeDBPanel({ compact = false }: TypeDBPanelProps) {
{/* Tabs */}
<div className="panel-tabs">
<button
className={`tab-btn ${activeTab === 'explore' ? 'active' : ''}`}
onClick={() => setActiveTab('explore')}
>
{t('explore')}
</button>
<button
className={`tab-btn ${activeTab === 'entities' ? 'active' : ''}`}
onClick={() => setActiveTab('entities')}
@ -214,6 +524,161 @@ export function TypeDBPanel({ compact = false }: TypeDBPanelProps) {
{/* Tab Content */}
<div className="panel-content">
{/* EXPLORE TAB */}
{activeTab === 'explore' && (
<div className="explore-section">
{/* Entity Type Selector */}
<div className="explore-header">
<div className="entity-type-grid">
{stats?.entityTypes
.filter(et => !et.abstract && et.count > 0)
.map(et => (
<button
key={et.label}
className={`entity-type-card ${selectedEntityType === et.label ? 'selected' : ''}`}
onClick={() => handleLoadEntityData(et.label)}
>
<span className="entity-type-name">{et.label}</span>
<span className="entity-type-count">{et.count.toLocaleString()} {t('instances')}</span>
</button>
))}
</div>
</div>
{/* Data Viewer */}
{selectedEntityType && (
<div className="data-viewer">
<div className="data-viewer-toolbar">
<div className="toolbar-left">
<h3>{selectedEntityType}</h3>
<span className="instance-count">
{isLoadingData ? t('loading') : `${filteredNodes.length} ${t('instances')}`}
</span>
</div>
<div className="toolbar-right">
<input
type="text"
className="search-input"
placeholder={t('search')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<div className="view-toggle">
<button
className={`toggle-btn ${exploreViewMode === 'table' ? 'active' : ''}`}
onClick={() => setExploreViewMode('table')}
>
{t('tableView')}
</button>
<button
className={`toggle-btn ${exploreViewMode === 'graph' ? 'active' : ''}`}
onClick={() => setExploreViewMode('graph')}
>
{t('graphView')}
</button>
</div>
<button className="export-btn" onClick={handleExportCsv} disabled={!graphData?.nodes.length}>
{t('exportCsv')}
</button>
<button className="export-btn" onClick={handleExportJson} disabled={!graphData?.nodes.length}>
{t('exportJson')}
</button>
</div>
</div>
{isLoadingData ? (
<div className="loading-container">
<div className="loading-spinner" />
<p>{t('loading')}</p>
</div>
) : !graphData?.nodes.length ? (
<p className="empty-message">{t('noInstances')}</p>
) : exploreViewMode === 'table' ? (
<div className="table-container">
<table className="data-table">
<thead>
<tr>
<th>ID</th>
<th>Label</th>
<th>Type</th>
<th>{t('attributes')}</th>
</tr>
</thead>
<tbody>
{filteredNodes.map(node => (
<tr
key={node.id}
onClick={() => setSelectedNode(node)}
className={selectedNode?.id === node.id ? 'selected' : ''}
>
<td><code>{node.id.substring(0, 12)}...</code></td>
<td>{node.label}</td>
<td>{node.entityType}</td>
<td>
{Object.entries(node.attributes).length > 0 ? (
<span className="attr-preview">
{Object.entries(node.attributes).slice(0, 3).map(([k, v]) => (
<span key={k} className="attr-badge">{k}: {String(v).substring(0, 20)}</span>
))}
{Object.keys(node.attributes).length > 3 && (
<span className="attr-more">+{Object.keys(node.attributes).length - 3} more</span>
)}
</span>
) : '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="graph-container">
<GraphVisualization
data={{ nodes: filteredNodes, edges: graphData.edges }}
width={800}
height={500}
onNodeClick={setSelectedNode}
/>
</div>
)}
{/* Node Detail Panel */}
{selectedNode && (
<div className="node-detail-panel">
<div className="node-detail-header">
<h4>{selectedNode.label}</h4>
<button className="close-btn" onClick={() => setSelectedNode(null)}>×</button>
</div>
<div className="node-detail-content">
<p><strong>ID:</strong> <code>{selectedNode.id}</code></p>
<p><strong>Type:</strong> {selectedNode.entityType}</p>
<div className="attributes-list">
<strong>{t('attributes')}:</strong>
{Object.entries(selectedNode.attributes).length > 0 ? (
<ul>
{Object.entries(selectedNode.attributes).map(([key, value]) => (
<li key={key}>
<span className="attr-key">{key}:</span>
<span className="attr-value">{String(value)}</span>
</li>
))}
</ul>
) : (
<p className="no-attrs">No attributes</p>
)}
</div>
</div>
</div>
)}
</div>
)}
{!selectedEntityType && stats?.entityTypes.length && (
<p className="help-text">{t('clickToExplore')}</p>
)}
</div>
)}
{activeTab === 'entities' && (
<div className="types-list">
{!stats?.entityTypes.length ? (

View file

@ -149,6 +149,7 @@ export interface Institution {
youtube?: YouTubeData;
founding_year?: number;
founding_decade?: number;
dissolution_year?: number; // Year institution closed/dissolved/destroyed
temporal_extent?: TemporalExtent;
successor_organization?: SuccessorOrganization;
genealogiewerkbalk?: {

View file

@ -0,0 +1,467 @@
/**
* TimelineSlider.css
*
* Styles for the heritage institution timeline filter component.
* Positioned at the bottom of the map, above the map controls.
* Supports light and dark themes.
*/
/* Base container */
.timeline-slider {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
width: calc(100% - 380px); /* Account for sidebar width */
max-width: 900px;
min-width: 400px;
background: var(--card-background, #ffffff);
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
padding: 16px 20px;
z-index: 1000;
transition: all 0.3s ease;
}
/* Dark mode support */
[data-theme="dark"] .timeline-slider,
.dark .timeline-slider {
background: var(--card-background, #1e293b);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
}
/* Active state - slight highlight */
.timeline-slider--active {
border: 2px solid var(--primary-color, #3b82f6);
}
/* Collapsed state */
.timeline-slider--collapsed {
width: auto;
min-width: auto;
padding: 8px 16px;
}
/* Header section */
.timeline-slider__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.timeline-slider__title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
font-size: 14px;
color: var(--text-primary, #1f2937);
}
[data-theme="dark"] .timeline-slider__title,
.dark .timeline-slider__title {
color: var(--text-primary, #f1f5f9);
}
.timeline-slider__icon {
font-size: 18px;
}
.timeline-slider__controls {
display: flex;
align-items: center;
gap: 12px;
}
/* Toggle switch */
.timeline-slider__toggle {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.timeline-slider__toggle input {
display: none;
}
.timeline-slider__toggle-slider {
width: 36px;
height: 20px;
background: var(--border-color, #d1d5db);
border-radius: 10px;
position: relative;
transition: background 0.2s ease;
}
.timeline-slider__toggle-slider::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
background: white;
border-radius: 50%;
transition: transform 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.timeline-slider__toggle input:checked + .timeline-slider__toggle-slider {
background: var(--primary-color, #3b82f6);
}
.timeline-slider__toggle input:checked + .timeline-slider__toggle-slider::after {
transform: translateX(16px);
}
.timeline-slider__toggle-label {
font-size: 12px;
color: var(--text-secondary, #6b7280);
min-width: 24px;
}
/* Collapse/Expand buttons */
.timeline-slider__collapse-btn,
.timeline-slider__expand-btn {
background: transparent;
border: none;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
color: var(--text-secondary, #6b7280);
transition: all 0.2s ease;
}
.timeline-slider__collapse-btn:hover,
.timeline-slider__expand-btn:hover {
background: var(--hover-background, #f3f4f6);
color: var(--text-primary, #1f2937);
}
[data-theme="dark"] .timeline-slider__collapse-btn:hover,
[data-theme="dark"] .timeline-slider__expand-btn:hover,
.dark .timeline-slider__collapse-btn:hover,
.dark .timeline-slider__expand-btn:hover {
background: var(--hover-background, #374151);
color: var(--text-primary, #f1f5f9);
}
.timeline-slider__expand-btn {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.timeline-slider__expand-icon {
font-size: 16px;
}
.timeline-slider__expand-text {
font-weight: 500;
}
/* Stats row */
.timeline-slider__stats {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 12px;
font-size: 12px;
color: var(--text-secondary, #6b7280);
}
.timeline-slider__stat {
display: flex;
align-items: center;
gap: 4px;
}
.timeline-slider__stat--defunct {
color: var(--warning-color, #f59e0b);
}
.timeline-slider__range-display {
margin-left: auto;
font-weight: 600;
font-size: 13px;
color: var(--text-primary, #1f2937);
background: var(--hover-background, #f3f4f6);
padding: 4px 10px;
border-radius: 6px;
}
[data-theme="dark"] .timeline-slider__range-display,
.dark .timeline-slider__range-display {
color: var(--text-primary, #f1f5f9);
background: var(--hover-background, #374151);
}
/* Presets row */
.timeline-slider__presets {
display: flex;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.timeline-slider__preset {
padding: 6px 12px;
font-size: 12px;
border: 1px solid var(--border-color, #d1d5db);
background: transparent;
border-radius: 16px;
cursor: pointer;
transition: all 0.2s ease;
color: var(--text-secondary, #6b7280);
}
.timeline-slider__preset:hover:not(:disabled) {
border-color: var(--primary-color, #3b82f6);
color: var(--primary-color, #3b82f6);
}
.timeline-slider__preset:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.timeline-slider__preset--active {
background: var(--primary-color, #3b82f6);
border-color: var(--primary-color, #3b82f6);
color: white;
}
.timeline-slider__preset--active:hover {
background: var(--primary-hover, #2563eb);
border-color: var(--primary-hover, #2563eb);
color: white;
}
/* Track container - holds histogram and slider */
.timeline-slider__track-container {
position: relative;
padding-top: 60px; /* Space for histogram */
}
/* Histogram bars */
.timeline-slider__histogram {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 50px;
display: flex;
pointer-events: none;
}
.timeline-slider__histogram-bar {
position: absolute;
bottom: 0;
width: 1.2%;
background: var(--border-color, #d1d5db);
border-radius: 2px 2px 0 0;
transition: background 0.2s ease, opacity 0.2s ease;
opacity: 0.4;
}
.timeline-slider__histogram-bar--in-range {
background: var(--primary-color, #3b82f6);
opacity: 0.7;
}
.timeline-slider__histogram-bar--dissolved {
background: linear-gradient(to top, var(--warning-color, #f59e0b) 30%, var(--primary-color, #3b82f6) 30%);
}
.timeline-slider__histogram-bar--in-range.timeline-slider__histogram-bar--dissolved {
opacity: 0.9;
}
/* Slider track */
.timeline-slider__track {
position: relative;
height: 8px;
background: var(--border-color, #e5e7eb);
border-radius: 4px;
cursor: pointer;
}
[data-theme="dark"] .timeline-slider__track,
.dark .timeline-slider__track {
background: var(--border-color, #374151);
}
/* Selected range highlight */
.timeline-slider__range-highlight {
position: absolute;
top: 0;
height: 100%;
background: var(--primary-color, #3b82f6);
border-radius: 4px;
opacity: 0.6;
}
/* Slider handles */
.timeline-slider__handle {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 20px;
height: 20px;
background: var(--primary-color, #3b82f6);
border: 3px solid white;
border-radius: 50%;
cursor: grab;
z-index: 10;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
transition: transform 0.1s ease, box-shadow 0.1s ease;
}
.timeline-slider__handle:hover {
transform: translate(-50%, -50%) scale(1.1);
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.25);
}
.timeline-slider__handle--dragging {
cursor: grabbing;
transform: translate(-50%, -50%) scale(1.15);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
}
.timeline-slider__handle:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3);
}
/* Handle labels */
.timeline-slider__handle-label {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: var(--text-primary, #1f2937);
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
pointer-events: none;
}
.timeline-slider__handle-label::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 4px solid transparent;
border-top-color: var(--text-primary, #1f2937);
}
/* Axis labels */
.timeline-slider__axis {
display: flex;
justify-content: space-between;
margin-top: 8px;
font-size: 11px;
color: var(--text-secondary, #9ca3af);
}
/* Responsive adjustments */
@media (max-width: 1200px) {
.timeline-slider {
width: calc(100% - 340px);
min-width: 350px;
}
}
@media (max-width: 900px) {
.timeline-slider {
width: calc(100% - 40px);
min-width: 300px;
left: 20px;
transform: none;
bottom: 60px; /* Above mobile controls */
}
.timeline-slider__presets {
gap: 4px;
}
.timeline-slider__preset {
padding: 4px 8px;
font-size: 11px;
}
.timeline-slider__stats {
flex-wrap: wrap;
gap: 8px;
}
.timeline-slider__range-display {
margin-left: 0;
}
}
@media (max-width: 600px) {
.timeline-slider {
padding: 12px 16px;
bottom: 80px;
}
.timeline-slider__header {
flex-wrap: wrap;
gap: 8px;
}
.timeline-slider__presets {
overflow-x: auto;
flex-wrap: nowrap;
padding-bottom: 4px;
}
.timeline-slider__histogram {
height: 35px;
}
.timeline-slider__track-container {
padding-top: 45px;
}
.timeline-slider__axis span:nth-child(2),
.timeline-slider__axis span:nth-child(4) {
display: none; /* Hide some axis labels on mobile */
}
}
/* Fullscreen mode adjustments */
.fullscreen-active .timeline-slider {
width: calc(100% - 60px);
left: 30px;
transform: none;
}
/* When sidebar is collapsed on mobile */
.is-mobile .timeline-slider {
width: calc(100% - 40px);
left: 20px;
transform: none;
}
/* Animation for collapse/expand */
.timeline-slider--collapsed .timeline-slider__expand-btn {
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}

View file

@ -0,0 +1,385 @@
/**
* 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;

View file

@ -9,6 +9,13 @@ import { useState, useEffect, useCallback } from 'react';
import { useDuckLake } from './useDuckLake';
import type { Institution } from '../components/map/InstitutionInfoPanel';
// TypeScript declaration for debug counter on window
declare global {
interface Window {
_temporalDebugCount?: number;
}
}
// Re-export Institution type for convenience
export type { Institution };
@ -37,6 +44,7 @@ const TYPE_COLORS: Record<string, string> = {
// Map full type names to single-letter codes
// DuckLake stores full names like "MUSEUM", but frontend expects "M"
// Also handles CH-Annotator entity codes (GRP.HER.*)
const TYPE_NAME_TO_CODE: Record<string, string> = {
'GALLERY': 'G',
'LIBRARY': 'L',
@ -65,6 +73,13 @@ const TYPE_NAME_TO_CODE: Record<string, string> = {
'DIGITAL_PLATFORM': 'D',
'NGO': 'N',
'TASTE_SMELL': 'T',
// CH-Annotator entity codes (GRP.HER.*)
'GRP.HER': 'U', // Generic heritage group -> Unknown (needs classification)
'GRP.HER.GAL': 'G', // Gallery
'GRP.HER.LIB': 'L', // Library
'GRP.HER.ARC': 'A', // Archive
'GRP.HER.MUS': 'M', // Museum
'GRP.HER.MIX': 'X', // Mixed
};
// Convert org_type from DuckLake to single-letter code
@ -145,7 +160,20 @@ const INSTITUTIONS_QUERY = `
file_name,
wikidata_enrichment_json,
original_entry_json,
service_area_json
service_area_json,
-- Temporal data columns
timespan_begin,
timespan_end,
timespan_json,
time_of_destruction_json,
conflict_status_json,
destruction_date,
founding_date,
dissolution_date,
temporal_extent_json,
wikidata_inception,
-- YouTube enrichment data
youtube_enrichment_json
FROM heritage.custodians_raw
WHERE latitude IS NOT NULL
AND longitude IS NOT NULL
@ -400,6 +428,75 @@ interface ServiceAreaData {
notes?: string;
}
// Parse youtube_enrichment JSON (from YAML youtube_enrichment field)
// Supports TWO structures:
// 1. Dutch (nested): { channel: {...}, videos: [...] }
// 2. Non-Dutch (flat): { channel_id: "...", title: "...", ... } (fields at root level)
interface YouTubeEnrichmentData {
// Nested structure (Dutch institutions)
channel?: {
channel_id?: string;
channel_url?: string;
title?: string;
description?: string;
subscriber_count?: number | null;
video_count?: number | null;
view_count?: number | null;
thumbnail_url?: string;
published_at?: string;
};
videos?: Array<{
video_id: string;
video_url?: string;
title?: string;
description?: string;
published_at?: string;
duration?: string;
view_count?: number | null;
like_count?: number | null;
comment_count?: number | null;
thumbnail_url?: string;
comments?: Array<{
author_display_name?: string;
author?: string;
text?: string;
text_display?: string;
like_count?: number;
}>;
has_transcript?: boolean;
transcript_snippet?: string | null;
}>;
enrichment_date?: string;
// Flat structure (non-Dutch institutions: SA, ZW, VN, VE, PS, etc.)
// These fields appear at the root level instead of nested in 'channel'
channel_id?: string;
channel_url?: string;
title?: string;
description?: string;
custom_url?: string;
published_at?: string;
country?: string;
fetch_timestamp?: string;
subscriber_count?: number | null;
video_count?: number | null;
view_count?: number | null;
thumbnail_url?: string;
llm_verification?: {
is_match?: boolean;
confidence?: number;
reasoning?: string;
agent?: string;
verified?: boolean;
};
}
// Reserved for future temporal data parsing:
// - TimespanData: CIDOC-CRM E52_Time-Span (begin_of_the_begin, end_of_the_begin, begin_of_the_end, end_of_the_end)
// - TimeOfDestructionData: conflict-related destruction info
// - ConflictStatusData: operational status (destroyed, damaged, operational)
// - TemporalExtentData: founding/dissolution dates and precision
// ============================================================================
// Main Hook
// ============================================================================
@ -445,7 +542,11 @@ export function useDuckLakeInstitutions(): UseDuckLakeInstitutionsReturn {
// 5: org_type, 6: wikidata_id, 7: google_rating, 8: google_total_ratings,
// 9: formatted_address, 10: record_id, 11: google_maps_enrichment_json,
// 12: identifiers_json, 13: genealogiewerkbalk_json, 14: file_name,
// 15: wikidata_enrichment_json, 16: original_entry_json, 17: service_area_json
// 15: wikidata_enrichment_json, 16: original_entry_json, 17: service_area_json,
// 18: timespan_begin, 19: timespan_end, 20: timespan_json,
// 21: time_of_destruction_json, 22: conflict_status_json, 23: destruction_date,
// 24: founding_date, 25: dissolution_date, 26: temporal_extent_json, 27: wikidata_inception,
// 28: youtube_enrichment_json
const lat = Number(row[0]);
const lon = Number(row[1]);
@ -466,11 +567,29 @@ export function useDuckLakeInstitutions(): UseDuckLakeInstitutionsReturn {
const wikidataEnrichJson = row[15] ? String(row[15]) : null;
const originalEntryJson = row[16] ? String(row[16]) : null;
const serviceAreaJson = row[17] ? String(row[17]) : null;
// Temporal data columns (new) - prefixed with _ to indicate future use
const _timespanBegin = row[18] ? String(row[18]) : null;
const timespanEnd = row[19] ? String(row[19]) : null;
const _timespanJson = row[20] ? String(row[20]) : null;
const _timeOfDestructionJson = row[21] ? String(row[21]) : null;
const _conflictStatusJson = row[22] ? String(row[22]) : null;
const destructionDate = row[23] ? String(row[23]) : null;
const foundingDateStr = row[24] ? String(row[24]) : null;
const dissolutionDateStr = row[25] ? String(row[25]) : null;
const _temporalExtentJson = row[26] ? String(row[26]) : null;
const wikidataInception = row[27] ? String(row[27]) : null;
const youtubeEnrichJson = row[28] ? String(row[28]) : null;
const province = parseProvinceFromGhcid(ghcidCurrent || null);
const color = TYPE_COLORS[typeCode] || '#9e9e9e';
const typeName = TYPE_NAMES[typeCode] || 'Unknown';
// Suppress unused variable warnings for future temporal data parsing
// TODO: Parse these JSON columns when temporal UI is implemented
void _timespanBegin; void _timespanJson; void _timeOfDestructionJson;
void _conflictStatusJson; void _temporalExtentJson;
// Parse JSON columns
const gmapsData = safeParseJSON<GoogleMapsEnrichment>(gmapsJson, {});
const identifiersData = safeParseJSON<IdentifiersData>(identifiersJson, {});
@ -478,32 +597,143 @@ export function useDuckLakeInstitutions(): UseDuckLakeInstitutionsReturn {
const wikidataEnrichData = safeParseJSON<WikidataEnrichmentData>(wikidataEnrichJson, {});
const originalEntryData = safeParseJSON<OriginalEntryData>(originalEntryJson, {});
const serviceAreaData = safeParseJSON<ServiceAreaData>(serviceAreaJson, {});
const youtubeEnrichData = safeParseJSON<YouTubeEnrichmentData>(youtubeEnrichJson, {});
// =========================================================================
// Extract YouTube channel from Wikidata enrichment
// Path: wikidata_claims.P2397_youtube_channel_id.value
// Extract YouTube channel from youtube_enrichment (PRIMARY)
// Supports TWO structures:
// 1. Dutch (nested): { channel: {...}, videos: [...] }
// 2. Non-Dutch (flat): { channel_id: "...", title: "...", ... }
// Falls back to Wikidata enrichment if youtube_enrichment not available
// All values are sanitized to strings/numbers to prevent React Error #300
// =========================================================================
let youtubeChannelId: string | undefined;
let youtubeHandle: string | undefined;
if (wikidataEnrichData.wikidata_claims?.P2397_youtube_channel_id?.value) {
youtubeChannelId = wikidataEnrichData.wikidata_claims.P2397_youtube_channel_id.value;
// First try youtube_enrichment - check both nested and flat structures
if (youtubeEnrichData.channel?.channel_id) {
// Nested structure (Dutch)
youtubeChannelId = String(youtubeEnrichData.channel.channel_id);
} else if (youtubeEnrichData.channel_id) {
// Flat structure (non-Dutch: SA, ZW, VN, VE, PS, etc.)
youtubeChannelId = String(youtubeEnrichData.channel_id);
}
// Get custom_url as handle from flat structure
if (youtubeEnrichData.custom_url) {
youtubeHandle = String(youtubeEnrichData.custom_url);
}
// Fallback to Wikidata P2397 (YouTube channel ID property)
if (!youtubeChannelId && wikidataEnrichData.wikidata_claims?.P2397_youtube_channel_id?.value) {
youtubeChannelId = String(wikidataEnrichData.wikidata_claims.P2397_youtube_channel_id.value);
}
if (wikidataEnrichData.wikidata_claims?.P11245_youtube_handle?.value) {
youtubeHandle = wikidataEnrichData.wikidata_claims.P11245_youtube_handle.value;
youtubeHandle = String(wikidataEnrichData.wikidata_claims.P11245_youtube_handle.value);
}
// Build YouTube channel object if we have channel ID
const youtubeChannel = youtubeChannelId ? {
channel_id: youtubeChannelId,
channel_url: `https://www.youtube.com/channel/${youtubeChannelId}`,
channel_title: youtubeHandle || '',
channel_description: '',
subscriber_count: null,
video_count: null,
view_count: null,
thumbnail_url: '',
} : identifiersData.youtube_channel;
// Build YouTube channel object with full video data from youtube_enrichment
// IMPORTANT: Sanitize all values to primitives to prevent React Error #300
let youtubeChannel: IdentifiersData['youtube_channel'] | undefined;
if (youtubeEnrichData.channel) {
// Build from youtube_enrichment NESTED structure (Dutch institutions)
// Has full video data with comments
const channel = youtubeEnrichData.channel;
youtubeChannel = {
channel_id: String(channel.channel_id || ''),
channel_url: String(channel.channel_url || `https://www.youtube.com/channel/${channel.channel_id}`),
channel_title: String(channel.title || youtubeHandle || ''),
channel_description: String(channel.description || ''),
subscriber_count: typeof channel.subscriber_count === 'number' ? channel.subscriber_count : null,
video_count: typeof channel.video_count === 'number' ? channel.video_count : null,
view_count: typeof channel.view_count === 'number' ? channel.view_count : null,
thumbnail_url: String(channel.thumbnail_url || ''),
// Map videos with sanitized comments
videos: youtubeEnrichData.videos?.map(v => ({
video_id: String(v.video_id || ''),
video_url: String(v.video_url || `https://www.youtube.com/watch?v=${v.video_id}`),
title: String(v.title || ''),
description: String(v.description || ''),
published_at: String(v.published_at || ''),
duration: String(v.duration || ''),
view_count: typeof v.view_count === 'number' ? v.view_count : null,
like_count: typeof v.like_count === 'number' ? v.like_count : null,
comment_count: typeof v.comment_count === 'number' ? v.comment_count : null,
thumbnail_url: String(v.thumbnail_url || ''),
// CRITICAL: Sanitize comments to prevent Error #300
// Comments may have nested objects - ensure all values are primitives
comments: (v.comments || []).map(c => ({
author: String(c.author_display_name || c.author || 'Anonymous'),
text: String(c.text_display || c.text || ''),
like_count: typeof c.like_count === 'number' ? c.like_count : 0,
})),
has_transcript: v.has_transcript === true,
transcript_snippet: v.transcript_snippet ? String(v.transcript_snippet) : null,
})) || [],
};
} else if (youtubeEnrichData.channel_id) {
// Build from youtube_enrichment FLAT structure (non-Dutch institutions)
// Channel fields are at root level, no videos array in this format
youtubeChannel = {
channel_id: String(youtubeEnrichData.channel_id),
channel_url: String(youtubeEnrichData.channel_url || `https://www.youtube.com/channel/${youtubeEnrichData.channel_id}`),
channel_title: String(youtubeEnrichData.title || youtubeHandle || ''),
channel_description: String(youtubeEnrichData.description || ''),
subscriber_count: typeof youtubeEnrichData.subscriber_count === 'number' ? youtubeEnrichData.subscriber_count : null,
video_count: typeof youtubeEnrichData.video_count === 'number' ? youtubeEnrichData.video_count : null,
view_count: typeof youtubeEnrichData.view_count === 'number' ? youtubeEnrichData.view_count : null,
thumbnail_url: String(youtubeEnrichData.thumbnail_url || ''),
// No videos in flat structure - just channel metadata
videos: [],
};
} else if (youtubeChannelId) {
// Build minimal from Wikidata (just channel ID, no videos)
youtubeChannel = {
channel_id: youtubeChannelId,
channel_url: `https://www.youtube.com/channel/${youtubeChannelId}`,
channel_title: youtubeHandle || '',
channel_description: '',
subscriber_count: null,
video_count: null,
view_count: null,
thumbnail_url: '',
};
} else if (identifiersData.youtube_channel) {
// Fallback to identifiers_json youtube_channel (legacy)
// Sanitize the data to prevent Error #300
const legacy = identifiersData.youtube_channel;
youtubeChannel = {
channel_id: String(legacy.channel_id || ''),
channel_url: String(legacy.channel_url || ''),
channel_title: String(legacy.channel_title || ''),
channel_description: String(legacy.channel_description || ''),
subscriber_count: typeof legacy.subscriber_count === 'number' ? legacy.subscriber_count : null,
video_count: typeof legacy.video_count === 'number' ? legacy.video_count : null,
view_count: typeof legacy.view_count === 'number' ? legacy.view_count : null,
thumbnail_url: String(legacy.thumbnail_url || ''),
// Sanitize legacy videos if present
videos: legacy.videos?.map(v => ({
video_id: String(v.video_id || ''),
video_url: String(v.video_url || ''),
title: String(v.title || ''),
description: String(v.description || ''),
published_at: String(v.published_at || ''),
duration: String(v.duration || ''),
view_count: typeof v.view_count === 'number' ? v.view_count : null,
like_count: typeof v.like_count === 'number' ? v.like_count : null,
comment_count: typeof v.comment_count === 'number' ? v.comment_count : null,
thumbnail_url: String(v.thumbnail_url || ''),
comments: (v.comments || []).map(c => ({
author: String(c.author || 'Anonymous'),
text: String(c.text || ''),
like_count: typeof c.like_count === 'number' ? c.like_count : 0,
})),
has_transcript: v.has_transcript === true,
transcript_snippet: v.transcript_snippet ? String(v.transcript_snippet) : null,
})) || [],
};
}
// =========================================================================
// Extract museum_register from original_entry
@ -528,9 +758,31 @@ export function useDuckLakeInstitutions(): UseDuckLakeInstitutionsReturn {
}
// =========================================================================
// Extract founding year from Wikidata inception claim
// Extract founding year from multiple sources (priority order)
// 1. DuckLake founding_date column (from temporal_extent)
// 2. Wikidata inception column (from wikidata_enrichment)
// 3. identifiers.founding_year
// 4. wikidata_claims.inception
// =========================================================================
let foundingYear = identifiersData.founding_year;
// Try DuckLake founding_date column first
if (!foundingYear && foundingDateStr) {
const yearMatch = foundingDateStr.match(/(\d{4})/);
if (yearMatch) {
foundingYear = parseInt(yearMatch[1], 10);
}
}
// Try Wikidata inception column
if (!foundingYear && wikidataInception) {
const yearMatch = wikidataInception.match(/(\d{4})/);
if (yearMatch) {
foundingYear = parseInt(yearMatch[1], 10);
}
}
// Fallback to wikidata_claims.inception from JSON
if (!foundingYear && wikidataEnrichData.wikidata_claims?.inception) {
const inceptionStr = wikidataEnrichData.wikidata_claims.inception;
// Try to parse year from inception string (e.g., "1910", "+1910-01-01T00:00:00Z")
@ -539,6 +791,62 @@ export function useDuckLakeInstitutions(): UseDuckLakeInstitutionsReturn {
foundingYear = parseInt(yearMatch[1], 10);
}
}
// =========================================================================
// Extract dissolution year from multiple sources (priority order)
// 1. DuckLake dissolution_date column (from temporal_extent)
// 2. DuckLake destruction_date column (conflict-related)
// 3. timespanEnd column (from timespan.begin_of_the_end)
// 4. temporal_extent from identifiers JSON
// =========================================================================
let dissolutionYear: number | undefined;
// Try DuckLake dissolution_date column
if (dissolutionDateStr) {
const yearMatch = dissolutionDateStr.match(/(\d{4})/);
if (yearMatch) {
dissolutionYear = parseInt(yearMatch[1], 10);
}
}
// Try destruction_date (for conflict-destroyed heritage)
if (!dissolutionYear && destructionDate) {
const yearMatch = destructionDate.match(/(\d{4})/);
if (yearMatch) {
dissolutionYear = parseInt(yearMatch[1], 10);
}
}
// Try timespan_end column
if (!dissolutionYear && timespanEnd) {
const yearMatch = timespanEnd.match(/(\d{4})/);
if (yearMatch) {
dissolutionYear = parseInt(yearMatch[1], 10);
}
}
// Fallback to identifiers.temporal_extent
if (!dissolutionYear && identifiersData.temporal_extent?.dissolution_date) {
const yearMatch = String(identifiersData.temporal_extent.dissolution_date).match(/(\d{4})/);
if (yearMatch) {
dissolutionYear = parseInt(yearMatch[1], 10);
}
}
if (!dissolutionYear && identifiersData.temporal_extent?.end_date) {
const yearMatch = String(identifiersData.temporal_extent.end_date).match(/(\d{4})/);
if (yearMatch) {
dissolutionYear = parseInt(yearMatch[1], 10);
}
}
// DEBUG: Log temporal data extraction (first 10 institutions with temporal data)
if (foundingYear || dissolutionYear) {
if (!window._temporalDebugCount) window._temporalDebugCount = 0;
if (window._temporalDebugCount < 10) {
console.log(`[Temporal] ${name}: founding=${foundingYear}, dissolution=${dissolutionYear}, sources: foundingDateStr=${foundingDateStr}, wikidataInception=${wikidataInception?.substring(0, 50)}`);
window._temporalDebugCount++;
}
}
// =========================================================================
// Extract website from Wikidata if not in Google Maps
@ -599,6 +907,7 @@ export function useDuckLakeInstitutions(): UseDuckLakeInstitutionsReturn {
youtube: youtubeChannel,
founding_year: foundingYear,
founding_decade: identifiersData.founding_decade,
dissolution_year: dissolutionYear,
temporal_extent: identifiersData.temporal_extent,
successor_organization: identifiersData.successor_organization,
genealogiewerkbalk: genealogieData.municipality ? genealogieData : undefined,
@ -618,6 +927,11 @@ export function useDuckLakeInstitutions(): UseDuckLakeInstitutionsReturn {
return institution;
});
// DEBUG: Log summary of temporal data extraction
const withFoundingYear = mapped.filter(i => i.founding_year).length;
const withDissolutionYear = mapped.filter(i => i.dissolution_year).length;
console.log(`[DuckLake] Temporal data summary: ${withFoundingYear} with founding_year, ${withDissolutionYear} with dissolution_year out of ${mapped.length} total`);
setInstitutions(mapped);
} catch (err) {
console.error('Failed to load institutions from DuckLake:', err);

View file

@ -30,6 +30,28 @@ export interface OxigraphStats {
}>;
}
// Graph visualization data structures
export interface OxigraphGraphNode {
id: string;
label: string;
type: string;
entityType: string;
attributes: Record<string, unknown>;
}
export interface OxigraphGraphEdge {
id: string;
source: string;
target: string;
predicate: string;
predicateLabel: string;
}
export interface OxigraphGraphData {
nodes: OxigraphGraphNode[];
edges: OxigraphGraphEdge[];
}
export interface OxigraphStatus {
isConnected: boolean;
endpoint: string;
@ -48,6 +70,7 @@ export interface UseOxigraphReturn {
loadRdfData: (data: string, format: string, graphName?: string) => Promise<void>;
clearGraph: (graphName?: string) => Promise<void>;
exportGraph: (graphName?: string, format?: string) => Promise<string>;
getGraphData: (rdfClass?: string, limit?: number) => Promise<OxigraphGraphData>;
}
/**
@ -360,6 +383,165 @@ export function useOxigraph(): UseOxigraphReturn {
return response.text();
}, []);
/**
* Shorten a URI for display
*/
const shortenUri = (uri: string): string => {
const prefixes: Record<string, string> = {
'http://www.w3.org/1999/02/22-rdf-syntax-ns#': 'rdf:',
'http://www.w3.org/2000/01/rdf-schema#': 'rdfs:',
'http://www.w3.org/2002/07/owl#': 'owl:',
'http://schema.org/': 'schema:',
'http://purl.org/dc/terms/': 'dct:',
'http://xmlns.com/foaf/0.1/': 'foaf:',
'https://w3id.org/heritage/custodian/': 'hc:',
'http://www.cidoc-crm.org/cidoc-crm/': 'crm:',
'https://www.ica.org/standards/RiC/ontology#': 'rico:',
'http://data.europa.eu/m8g/': 'cpov:',
};
for (const [ns, prefix] of Object.entries(prefixes)) {
if (uri.startsWith(ns)) {
return prefix + uri.slice(ns.length);
}
}
// Return last part of URI
const lastPart = uri.split(/[#/]/).pop();
return lastPart || uri;
};
/**
* Get graph data for visualization - fetches nodes (subjects/objects) and edges (predicates)
*/
const getGraphData = useCallback(async (
rdfClass?: string,
limit: number = 100
): Promise<OxigraphGraphData> => {
// Build SPARQL query to get triples
// If rdfClass is specified, filter by that class
const query = rdfClass
? `
PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
PREFIX schema: <http://schema.org/>
PREFIX skos: <http://www.w3.org/2004/02/skos/core#>
SELECT ?s ?p ?o ?sLabel ?oLabel ?sType WHERE {
?s a <${rdfClass}> .
?s ?p ?o .
FILTER(isIRI(?o))
OPTIONAL { ?s rdfs:label ?sLabelRdfs }
OPTIONAL { ?s schema:name ?sLabelSchema }
OPTIONAL { ?s skos:prefLabel ?sLabelSkos }
OPTIONAL { ?o rdfs:label ?oLabelRdfs }
OPTIONAL { ?o schema:name ?oLabelSchema }
OPTIONAL { ?s a ?sType }
BIND(COALESCE(?sLabelRdfs, ?sLabelSchema, ?sLabelSkos) AS ?sLabel)
BIND(COALESCE(?oLabelRdfs, ?oLabelSchema) AS ?oLabel)
}
LIMIT ${limit * 3}
`
: `
PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
PREFIX schema: <http://schema.org/>
PREFIX skos: <http://www.w3.org/2004/02/skos/core#>
SELECT ?s ?p ?o ?sLabel ?oLabel ?sType WHERE {
?s ?p ?o .
FILTER(isIRI(?s) && isIRI(?o))
OPTIONAL { ?s rdfs:label ?sLabelRdfs }
OPTIONAL { ?s schema:name ?sLabelSchema }
OPTIONAL { ?s skos:prefLabel ?sLabelSkos }
OPTIONAL { ?o rdfs:label ?oLabelRdfs }
OPTIONAL { ?o schema:name ?oLabelSchema }
OPTIONAL { ?s a ?sType }
BIND(COALESCE(?sLabelRdfs, ?sLabelSchema, ?sLabelSkos) AS ?sLabel)
BIND(COALESCE(?oLabelRdfs, ?oLabelSchema) AS ?oLabel)
}
LIMIT ${limit * 3}
`;
const result = await sparqlQuery(query) as {
results: {
bindings: Array<{
s: { value: string };
p: { value: string };
o: { value: string };
sLabel?: { value: string };
oLabel?: { value: string };
sType?: { value: string };
}>;
};
};
// Build nodes and edges from triples
const nodesMap = new Map<string, OxigraphGraphNode>();
const edges: OxigraphGraphEdge[] = [];
for (const binding of result.results.bindings) {
const subjectUri = binding.s.value;
const predicateUri = binding.p.value;
const objectUri = binding.o.value;
// Skip rdf:type predicates for edges (we use them for node types)
if (predicateUri === 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type') {
// Update node type
if (nodesMap.has(subjectUri)) {
const node = nodesMap.get(subjectUri)!;
node.entityType = shortenUri(objectUri);
}
continue;
}
// Add subject node
if (!nodesMap.has(subjectUri)) {
nodesMap.set(subjectUri, {
id: subjectUri,
label: binding.sLabel?.value || shortenUri(subjectUri),
type: 'subject',
entityType: binding.sType ? shortenUri(binding.sType.value) : 'Resource',
attributes: {},
});
}
// Add object node (only if it's a URI)
if (!nodesMap.has(objectUri)) {
nodesMap.set(objectUri, {
id: objectUri,
label: binding.oLabel?.value || shortenUri(objectUri),
type: 'object',
entityType: 'Resource',
attributes: {},
});
}
// Add edge
edges.push({
id: `${subjectUri}-${predicateUri}-${objectUri}`,
source: subjectUri,
target: objectUri,
predicate: predicateUri,
predicateLabel: shortenUri(predicateUri),
});
}
// Limit nodes to requested amount
const nodes = Array.from(nodesMap.values()).slice(0, limit);
const nodeIds = new Set(nodes.map(n => n.id));
// Filter edges to only include those with valid source/target
const filteredEdges = edges.filter(
e => nodeIds.has(e.source) && nodeIds.has(e.target)
);
return {
nodes,
edges: filteredEdges,
};
}, []);
return {
status,
stats,
@ -370,5 +552,6 @@ export function useOxigraph(): UseOxigraphReturn {
loadRdfData,
clearGraph,
exportGraph,
getGraphData,
};
}

View file

@ -37,6 +37,13 @@ export interface PostgreSQLStatus {
responseTimeMs?: number;
}
export interface TableDataResult {
columns: string[];
rows: unknown[][];
rowCount: number;
totalRows: number;
}
export interface UsePostgreSQLReturn {
status: PostgreSQLStatus;
stats: PostgreSQLStats | null;
@ -44,8 +51,11 @@ export interface UsePostgreSQLReturn {
error: Error | null;
refresh: () => Promise<void>;
executeQuery: (query: string) => Promise<unknown[]>;
executeRawQuery: (query: string) => Promise<QueryResult>;
getTables: () => Promise<string[]>;
getTableSchema: (tableName: string) => Promise<unknown>;
getTableData: (schema: string, tableName: string, limit?: number, offset?: number) => Promise<TableDataResult>;
exportTableData: (schema: string, tableName: string, format: 'json' | 'csv') => Promise<Blob>;
}
/**
@ -246,13 +256,20 @@ export function usePostgreSQL(): UsePostgreSQLReturn {
}, [refresh]);
/**
* Execute a SQL query
* Execute a SQL query and return array of objects
*/
const executeQuery = useCallback(async (query: string): Promise<unknown[]> => {
const result = await postgresQuery(query);
return rowsToObjects(result);
}, []);
/**
* Execute a SQL query and return raw result (columns + rows)
*/
const executeRawQuery = useCallback(async (query: string): Promise<QueryResult> => {
return postgresQuery(query);
}, []);
/**
* Get list of tables
*/
@ -290,6 +307,68 @@ export function usePostgreSQL(): UsePostgreSQLReturn {
return rowsToObjects(result);
}, []);
/**
* Get table data with pagination
*/
const getTableData = useCallback(async (
schema: string,
tableName: string,
limit: number = 50,
offset: number = 0
): Promise<TableDataResult> => {
// Get total count first
const countResult = await postgresQuery(
`SELECT COUNT(*) as cnt FROM "${schema}"."${tableName}"`
);
const totalRows = Number(countResult.rows[0]?.[0] || 0);
// Get data with limit/offset
const dataResult = await postgresQuery(
`SELECT * FROM "${schema}"."${tableName}" LIMIT ${limit} OFFSET ${offset}`
);
return {
columns: dataResult.columns,
rows: dataResult.rows,
rowCount: dataResult.rows.length,
totalRows,
};
}, []);
/**
* Export table data as JSON or CSV
*/
const exportTableData = useCallback(async (
schema: string,
tableName: string,
format: 'json' | 'csv'
): Promise<Blob> => {
// Get all data (be careful with large tables)
const result = await postgresQuery(
`SELECT * FROM "${schema}"."${tableName}" LIMIT 10000`
);
if (format === 'json') {
const data = rowsToObjects(result);
return new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
} else {
// CSV format
const header = result.columns.join(',');
const rows = result.rows.map(row =>
row.map(cell => {
if (cell === null || cell === undefined) return '';
const str = typeof cell === 'object' ? JSON.stringify(cell) : String(cell);
// Escape quotes and wrap in quotes if contains comma or quote
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
}).join(',')
).join('\n');
return new Blob([header + '\n' + rows], { type: 'text/csv' });
}
}, []);
return {
status,
stats,
@ -297,7 +376,10 @@ export function usePostgreSQL(): UsePostgreSQLReturn {
error,
refresh,
executeQuery,
executeRawQuery,
getTables,
getTableSchema,
getTableData,
exportTableData,
};
}

View file

@ -43,6 +43,28 @@ export interface TypeDBStatus {
responseTimeMs?: number;
}
export interface TypeDBGraphNode {
id: string;
label: string;
type: string;
entityType: string;
attributes: Record<string, unknown>;
}
export interface TypeDBGraphEdge {
id: string;
source: string;
target: string;
relationType: string;
role: string;
attributes: Record<string, unknown>;
}
export interface TypeDBGraphData {
nodes: TypeDBGraphNode[];
edges: TypeDBGraphEdge[];
}
export interface UseTypeDBReturn {
status: TypeDBStatus;
stats: TypeDBStats | null;
@ -52,6 +74,9 @@ export interface UseTypeDBReturn {
executeQuery: (query: string, queryType?: 'read' | 'write' | 'schema') => Promise<unknown>;
getDatabases: () => Promise<string[]>;
getSchema: () => Promise<string>;
getEntityInstances: (entityType: string, limit?: number) => Promise<TypeDBGraphNode[]>;
getRelationInstances: (relationType: string, limit?: number) => Promise<TypeDBGraphEdge[]>;
getGraphData: (entityType: string, depth?: number, limit?: number) => Promise<TypeDBGraphData>;
}
/**
@ -251,6 +276,151 @@ export function useTypeDB(): UseTypeDBReturn {
return response.text();
}, []);
/**
* Get entity instances of a specific type
*/
const getEntityInstances = useCallback(async (
entityType: string,
limit: number = 50
): Promise<TypeDBGraphNode[]> => {
const query = `match $x isa ${entityType}, has $attr; get $x, $attr; limit ${limit};`;
const response = await typedbQuery(query, 'read') as { results: Record<string, unknown>[] };
const result = response.results || [];
// Transform query results to graph nodes
const nodeMap = new Map<string, TypeDBGraphNode>();
for (const row of result) {
// Handle backend response format: x is {id, type, _iid, _type}
const entity = row['x'] as { id?: string; _iid?: string; type?: string; _type?: string } | undefined;
// Handle backend response format: attr is {value, type}
const attr = row['attr'] as { type?: string; value?: unknown } | undefined;
if (entity) {
const entityId = entity.id || entity._iid || '';
const entityTypeName = entity.type || entity._type || entityType;
if (entityId && !nodeMap.has(entityId)) {
nodeMap.set(entityId, {
id: entityId,
label: entityId.substring(0, 8),
type: 'entity',
entityType: entityTypeName,
attributes: {},
});
}
if (attr && entityId) {
const node = nodeMap.get(entityId)!;
const attrType = attr.type || 'unknown';
node.attributes[attrType] = attr.value;
// Use 'name' or 'label' attribute as node label if available
if ((attrType === 'name' || attrType === 'label') && attr.value) {
node.label = String(attr.value);
}
}
}
}
return Array.from(nodeMap.values());
}, []);
/**
* Get relation instances of a specific type
*/
const getRelationInstances = useCallback(async (
relationType: string,
limit: number = 50
): Promise<TypeDBGraphEdge[]> => {
const query = `match $r isa ${relationType}; $r ($role: $player); get $r, $role, $player; limit ${limit};`;
const response = await typedbQuery(query, 'read') as { results: Record<string, unknown>[] };
const result = response.results || [];
// Group by relation ID
const relationMap = new Map<string, { players: Array<{ role: string; playerId: string }> }>();
for (const row of result) {
// Handle backend response format
const rel = row['r'] as { id?: string; _iid?: string } | undefined;
const role = row['role'] as string | undefined;
const player = row['player'] as { id?: string; _iid?: string } | undefined;
const relId = rel?.id || rel?._iid;
const playerId = player?.id || player?._iid;
if (relId && role && playerId) {
if (!relationMap.has(relId)) {
relationMap.set(relId, { players: [] });
}
relationMap.get(relId)!.players.push({ role, playerId });
}
}
// Create edges from relations
const edges: TypeDBGraphEdge[] = [];
for (const [relId, data] of relationMap) {
// Create edges between all pairs of players in the relation
const players = data.players;
if (players.length >= 2) {
edges.push({
id: relId,
source: players[0].playerId,
target: players[1].playerId,
relationType,
role: `${players[0].role}${players[1].role}`,
attributes: {},
});
}
}
return edges;
}, []);
/**
* Get graph data for visualization (entities + relations)
* Uses the optimized /graph/{entity_type} endpoint that returns both nodes and edges
*/
const getGraphData = useCallback(async (
entityType: string,
_depth: number = 1, // depth is handled server-side
limit: number = 100
): Promise<TypeDBGraphData> => {
try {
// Use the new graph endpoint that returns both nodes and edges in one call
const response = await fetch(
`${TYPEDB_API_URL}/graph/${entityType}?limit=${limit}`,
{
method: 'GET',
headers: {
'Accept': 'application/json',
},
}
);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Graph query failed: ${response.status} - ${errorText}`);
}
const graphData = await response.json() as {
nodes: TypeDBGraphNode[];
edges: TypeDBGraphEdge[];
nodeCount: number;
edgeCount: number;
};
return {
nodes: graphData.nodes || [],
edges: graphData.edges || [],
};
} catch (error) {
console.error('Failed to fetch graph data:', error);
// Fallback to entity-only fetch if graph endpoint fails
const nodes = await getEntityInstances(entityType, limit);
return { nodes, edges: [] };
}
}, [getEntityInstances]);
return {
status,
stats,
@ -260,5 +430,8 @@ export function useTypeDB(): UseTypeDBReturn {
executeQuery,
getDatabases,
getSchema,
getEntityInstances,
getRelationInstances,
getGraphData,
};
}

View file

@ -142,6 +142,28 @@ export interface WerkgebiedHookResult {
} | null;
}
// Helper to safely check if map is still valid (not destroyed)
function isMapValid(map: maplibregl.Map | null): map is maplibregl.Map {
if (!map) return false;
try {
// Attempt to access a property that would throw if map is destroyed
map.getContainer();
return true;
} catch {
return false;
}
}
// Helper to safely get layer (returns undefined if map is destroyed or layer doesn't exist)
function safeGetLayer(map: maplibregl.Map | null, layerId: string): boolean {
if (!isMapValid(map)) return false;
try {
return !!map.getLayer(layerId);
} catch {
return false;
}
}
export function useWerkgebiedMapLibre(map: maplibregl.Map | null): WerkgebiedHookResult {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -256,52 +278,59 @@ export function useWerkgebiedMapLibre(map: maplibregl.Map | null): WerkgebiedHoo
if (!map || layersAddedRef.current) return;
const setupLayers = () => {
// Check if map style is loaded
if (!map.isStyleLoaded()) {
map.once('styledata', setupLayers);
return;
}
// Check if map is still valid and style is loaded
if (!isMapValid(map)) return;
// Add empty source for werkgebied
if (!map.getSource(WERKGEBIED_SOURCE_ID)) {
map.addSource(WERKGEBIED_SOURCE_ID, {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: [],
},
});
try {
if (!map.isStyleLoaded()) {
map.once('styledata', setupLayers);
return;
}
// Add empty source for werkgebied
if (!map.getSource(WERKGEBIED_SOURCE_ID)) {
map.addSource(WERKGEBIED_SOURCE_ID, {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: [],
},
});
}
// Add fill layer (below markers) - use static values, we'll update them when showing
if (!safeGetLayer(map, WERKGEBIED_FILL_LAYER_ID)) {
map.addLayer({
id: WERKGEBIED_FILL_LAYER_ID,
type: 'fill',
source: WERKGEBIED_SOURCE_ID,
paint: {
'fill-color': WERKGEBIED_FILL_COLOR,
'fill-opacity': WERKGEBIED_FILL_OPACITY,
},
});
}
// Add line layer for borders
if (!safeGetLayer(map, WERKGEBIED_LINE_LAYER_ID)) {
map.addLayer({
id: WERKGEBIED_LINE_LAYER_ID,
type: 'line',
source: WERKGEBIED_SOURCE_ID,
paint: {
'line-color': WERKGEBIED_LINE_COLOR,
'line-width': WERKGEBIED_LINE_WIDTH,
'line-dasharray': [5, 5],
},
});
}
layersAddedRef.current = true;
console.log('[useWerkgebiedMapLibre] Layers setup complete');
} catch {
// Map may be destroyed during setup
console.warn('[useWerkgebiedMapLibre] Map destroyed during layer setup');
}
// Add fill layer (below markers) - use static values, we'll update them when showing
if (!map.getLayer(WERKGEBIED_FILL_LAYER_ID)) {
map.addLayer({
id: WERKGEBIED_FILL_LAYER_ID,
type: 'fill',
source: WERKGEBIED_SOURCE_ID,
paint: {
'fill-color': WERKGEBIED_FILL_COLOR,
'fill-opacity': WERKGEBIED_FILL_OPACITY,
},
});
}
// Add line layer for borders
if (!map.getLayer(WERKGEBIED_LINE_LAYER_ID)) {
map.addLayer({
id: WERKGEBIED_LINE_LAYER_ID,
type: 'line',
source: WERKGEBIED_SOURCE_ID,
paint: {
'line-color': WERKGEBIED_LINE_COLOR,
'line-width': WERKGEBIED_LINE_WIDTH,
'line-dasharray': [5, 5],
},
});
}
layersAddedRef.current = true;
console.log('[useWerkgebiedMapLibre] Layers setup complete');
};
setupLayers();
@ -363,50 +392,55 @@ export function useWerkgebiedMapLibre(map: maplibregl.Map | null): WerkgebiedHoo
// Helper to ensure source and layers exist
const ensureSourceAndLayers = useCallback(() => {
if (!map) return false;
if (!isMapValid(map)) return false;
// Add source if missing
if (!map.getSource(WERKGEBIED_SOURCE_ID)) {
console.log('[useWerkgebiedMapLibre] Adding missing source');
map.addSource(WERKGEBIED_SOURCE_ID, {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: [],
},
});
try {
// Add source if missing
if (!map.getSource(WERKGEBIED_SOURCE_ID)) {
console.log('[useWerkgebiedMapLibre] Adding missing source');
map.addSource(WERKGEBIED_SOURCE_ID, {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: [],
},
});
}
// Add fill layer if missing
if (!safeGetLayer(map, WERKGEBIED_FILL_LAYER_ID)) {
console.log('[useWerkgebiedMapLibre] Adding missing fill layer');
map.addLayer({
id: WERKGEBIED_FILL_LAYER_ID,
type: 'fill',
source: WERKGEBIED_SOURCE_ID,
paint: {
'fill-color': WERKGEBIED_FILL_COLOR,
'fill-opacity': WERKGEBIED_FILL_OPACITY,
},
});
}
// Add line layer if missing
if (!safeGetLayer(map, WERKGEBIED_LINE_LAYER_ID)) {
console.log('[useWerkgebiedMapLibre] Adding missing line layer');
map.addLayer({
id: WERKGEBIED_LINE_LAYER_ID,
type: 'line',
source: WERKGEBIED_SOURCE_ID,
paint: {
'line-color': WERKGEBIED_LINE_COLOR,
'line-width': WERKGEBIED_LINE_WIDTH,
'line-dasharray': [5, 5],
},
});
}
return true;
} catch {
// Map may be destroyed
return false;
}
// Add fill layer if missing
if (!map.getLayer(WERKGEBIED_FILL_LAYER_ID)) {
console.log('[useWerkgebiedMapLibre] Adding missing fill layer');
map.addLayer({
id: WERKGEBIED_FILL_LAYER_ID,
type: 'fill',
source: WERKGEBIED_SOURCE_ID,
paint: {
'fill-color': WERKGEBIED_FILL_COLOR,
'fill-opacity': WERKGEBIED_FILL_OPACITY,
},
});
}
// Add line layer if missing
if (!map.getLayer(WERKGEBIED_LINE_LAYER_ID)) {
console.log('[useWerkgebiedMapLibre] Adding missing line layer');
map.addLayer({
id: WERKGEBIED_LINE_LAYER_ID,
type: 'line',
source: WERKGEBIED_SOURCE_ID,
paint: {
'line-color': WERKGEBIED_LINE_COLOR,
'line-width': WERKGEBIED_LINE_WIDTH,
'line-dasharray': [5, 5],
},
});
}
return true;
}, [map]);
// Helper to update map with GeoJSON features
@ -415,7 +449,7 @@ export function useWerkgebiedMapLibre(map: maplibregl.Map | null): WerkgebiedHoo
isHistorical: boolean,
options: WerkgebiedOptions
) => {
if (!map) return;
if (!isMapValid(map)) return;
// Ensure source and layers exist before updating
if (!ensureSourceAndLayers()) {
@ -431,22 +465,22 @@ export function useWerkgebiedMapLibre(map: maplibregl.Map | null): WerkgebiedHoo
const lineColor = isHistorical ? HISTORICAL_LINE_COLOR : WERKGEBIED_LINE_COLOR;
const lineWidth = isHistorical ? HISTORICAL_LINE_WIDTH : WERKGEBIED_LINE_WIDTH;
// Update layer paint properties
if (map.getLayer(WERKGEBIED_FILL_LAYER_ID)) {
// Update layer paint properties (use safe getLayer)
if (safeGetLayer(map, WERKGEBIED_FILL_LAYER_ID)) {
map.setPaintProperty(WERKGEBIED_FILL_LAYER_ID, 'fill-color', fillColor);
map.setPaintProperty(WERKGEBIED_FILL_LAYER_ID, 'fill-opacity', fillOpacity);
}
if (map.getLayer(WERKGEBIED_LINE_LAYER_ID)) {
if (safeGetLayer(map, WERKGEBIED_LINE_LAYER_ID)) {
map.setPaintProperty(WERKGEBIED_LINE_LAYER_ID, 'line-color', lineColor);
map.setPaintProperty(WERKGEBIED_LINE_LAYER_ID, 'line-width', lineWidth);
}
// Ensure werkgebied layers are below institutions layer
if (map.getLayer('institutions-circles')) {
if (map.getLayer(WERKGEBIED_FILL_LAYER_ID)) {
if (safeGetLayer(map, 'institutions-circles')) {
if (safeGetLayer(map, WERKGEBIED_FILL_LAYER_ID)) {
map.moveLayer(WERKGEBIED_FILL_LAYER_ID, 'institutions-circles');
}
if (map.getLayer(WERKGEBIED_LINE_LAYER_ID)) {
if (safeGetLayer(map, WERKGEBIED_LINE_LAYER_ID)) {
map.moveLayer(WERKGEBIED_LINE_LAYER_ID, 'institutions-circles');
}
}
@ -788,22 +822,22 @@ export function useWerkgebiedMapLibre(map: maplibregl.Map | null): WerkgebiedHoo
return false;
}
// Apply service area styling (green instead of blue)
if (map.getLayer(WERKGEBIED_FILL_LAYER_ID)) {
// Apply service area styling (green instead of blue) - use safe getLayer
if (safeGetLayer(map, WERKGEBIED_FILL_LAYER_ID)) {
map.setPaintProperty(WERKGEBIED_FILL_LAYER_ID, 'fill-color', SERVICE_AREA_FILL_COLOR);
map.setPaintProperty(WERKGEBIED_FILL_LAYER_ID, 'fill-opacity', SERVICE_AREA_FILL_OPACITY);
}
if (map.getLayer(WERKGEBIED_LINE_LAYER_ID)) {
if (safeGetLayer(map, WERKGEBIED_LINE_LAYER_ID)) {
map.setPaintProperty(WERKGEBIED_LINE_LAYER_ID, 'line-color', SERVICE_AREA_LINE_COLOR);
map.setPaintProperty(WERKGEBIED_LINE_LAYER_ID, 'line-width', SERVICE_AREA_LINE_WIDTH);
}
// Ensure layers are below institution markers
if (map.getLayer('institutions-circles')) {
if (map.getLayer(WERKGEBIED_FILL_LAYER_ID)) {
if (safeGetLayer(map, 'institutions-circles')) {
if (safeGetLayer(map, WERKGEBIED_FILL_LAYER_ID)) {
map.moveLayer(WERKGEBIED_FILL_LAYER_ID, 'institutions-circles');
}
if (map.getLayer(WERKGEBIED_LINE_LAYER_ID)) {
if (safeGetLayer(map, WERKGEBIED_LINE_LAYER_ID)) {
map.moveLayer(WERKGEBIED_LINE_LAYER_ID, 'institutions-circles');
}
}

View file

@ -71,7 +71,7 @@ const DATABASES: DatabaseInfo[] = [
nl: 'Kennisgraaf voor complexe erfgoedrelaties',
en: 'Knowledge graph for complex heritage relationships',
},
icon: '🧠',
icon: '🔷',
color: '#6B5CE7',
},
{
@ -208,7 +208,7 @@ export function Database() {
</div>
</div>
<div className="db-type-card" style={{ borderLeftColor: '#6B5CE7' }}>
<span className="db-type-icon">🧠</span>
<span className="db-type-icon">🔷</span>
<div>
<strong>TypeDB</strong>
<p>{t('graphDescription')}</p>

View file

@ -23,6 +23,7 @@ import { useFullscreen, useCollapsibleHeader } from '../hooks/useCollapsibleHead
import { useWerkgebiedMapLibre, type ServiceArea } from '../hooks/useWerkgebiedMapLibre';
import { useDuckLakeInstitutions } from '../hooks/useDuckLakeInstitutions';
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 } from 'lucide-react';
@ -42,43 +43,49 @@ const SEARCH_TEXT = {
hideHeader: { nl: 'Verberg kop', en: 'Hide header' },
};
// Institution type colors matching the GLAMORCUBESFIXPHDNT taxonomy
// Custodian type colors matching the GLAMORCUBESFIXPHDNT taxonomy (19 types)
const TYPE_COLORS: Record<string, string> = {
M: '#e74c3c', // Museum - red
A: '#3498db', // Archive - blue
G: '#00bcd4', // Gallery - cyan
L: '#2ecc71', // Library - green
S: '#9b59b6', // Society - purple
A: '#3498db', // Archive - blue
M: '#e74c3c', // Museum - red
O: '#f39c12', // Official - orange
R: '#1abc9c', // Research - teal
D: '#34495e', // Digital - dark gray
F: '#95a5a6', // Features - gray
N: '#e91e63', // NGO - pink
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
C: '#795548', // Corporation - brown
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
G: '#00bcd4', // Gallery - cyan
};
const TYPE_NAMES: Record<string, { nl: string; en: string }> = {
M: { nl: 'Museum', en: 'Museum' },
A: { nl: 'Archief', en: 'Archive' },
G: { nl: 'Galerie', en: 'Gallery' },
L: { nl: 'Bibliotheek', en: 'Library' },
S: { nl: 'Vereniging', en: 'Society' },
A: { nl: 'Archief', en: 'Archive' },
M: { nl: 'Museum', en: 'Museum' },
O: { nl: 'Officieel', en: 'Official' },
R: { nl: 'Onderzoek', en: 'Research' },
D: { nl: 'Digitaal', en: 'Digital' },
F: { nl: 'Monumenten', en: 'Features' },
N: { nl: 'NGO', en: 'NGO' },
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' },
C: { nl: 'Bedrijf', en: 'Corporation' },
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' },
G: { nl: 'Galerie', en: 'Gallery' },
};
// Map tile styles for light and dark modes
@ -228,6 +235,17 @@ export default function NDEMapPage() {
const [selectedCities, setSelectedCities] = useState<Set<string>>(new Set());
const [selectedMinRating, setSelectedMinRating] = useState<number | null>(null);
// Timeline state
const MIN_YEAR = 1400;
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 });
@ -433,6 +451,45 @@ export default function NDEMapPage() {
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) => {
@ -576,21 +633,52 @@ export default function NDEMapPage() {
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;
selectedSources.size > 0 || selectedMinRating !== null || isTimelineActive;
const hasAnyFilters = hasUrlFilters || hasSidebarFilters;
// Filtered institutions for the map
const filteredInstitutions = useMemo(() => {
return institutions.filter((inst) => {
// DEBUG: Log when filter runs and timeline state
console.log(`[filteredInstitutions] Running filter: isTimelineActive=${isTimelineActive}, timelineRange=[${timelineRange[0]}, ${timelineRange[1]}], total=${institutions.length}`);
let timelineFilteredCount = 0;
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) {
timelineFilteredCount++;
return false;
}
// Institution is visible if:
// 1. Founded before or during the selected end year
if (foundingYear && foundingYear > timelineRange[1]) {
timelineFilteredCount++;
return false;
}
// 2. AND (still operational OR dissolved after the selected start year)
if (dissolutionYear && dissolutionYear < timelineRange[0]) {
timelineFilteredCount++;
return false;
}
}
// Province filter
if (provinceFilter && inst.province !== provinceFilter) return false;
if (selectedProvinces.size > 0 && inst.province && !selectedProvinces.has(inst.province)) return false;
@ -652,9 +740,17 @@ export default function NDEMapPage() {
return true;
});
// DEBUG: Log filter results
if (isTimelineActive) {
console.log(`[filteredInstitutions] Timeline filter: ${timelineFilteredCount} institutions hidden, ${result.length} visible`);
}
return result;
}, [institutions, selectedTypes, provinceFilter, typeFilter, cityFilter, enrichedFilter,
sourceFilter, hasFilter, minRatingFilter, wikidataTypeFilter, foundingDecadeFilter,
selectedProvinces, selectedCities, selectedSources, selectedMinRating]);
selectedProvinces, selectedCities, selectedSources, selectedMinRating,
isTimelineActive, timelineRange]);
const visibleCount = filteredInstitutions.length;
@ -672,7 +768,7 @@ export default function NDEMapPage() {
// If DuckLake is connected and has data, use it exclusively
if (duckLakeData.isConnected && duckLakeData.institutions.length > 0) {
console.log(`[NDEMapPage] Using DuckLake data: ${duckLakeData.institutions.length} institutions`);
console.log(`[NDEMapPage] Using DuckLake data: ${duckLakeData.institutions.length} custodians`);
setInstitutions(duckLakeData.institutions);
const byType: Record<string, number> = {};
@ -989,8 +1085,13 @@ export default function NDEMapPage() {
// Cleanup: remove click handler when effect re-runs or unmounts
return () => {
if (map && map.getLayer('institutions-circles')) {
map.off('click', 'institutions-circles', handleClick);
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]);
@ -1541,6 +1642,20 @@ export default function NDEMapPage() {
<div className="map-wrapper">
<div ref={mapRef} className="maplibre-map" style={{ width: '100%', height: '100%' }}></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>