diff --git a/frontend/public/schemas/20251121/linkml/manifest.json b/frontend/public/schemas/20251121/linkml/manifest.json index f638f58392..0ef55caf0d 100644 --- a/frontend/public/schemas/20251121/linkml/manifest.json +++ b/frontend/public/schemas/20251121/linkml/manifest.json @@ -1,5 +1,5 @@ { - "generated": "2025-12-07T21:46:29.967Z", + "generated": "2025-12-08T13:51:53.356Z", "version": "1.0.0", "categories": [ { diff --git a/frontend/public/schemas/20251121/linkml/modules/classes/CustodianName.yaml b/frontend/public/schemas/20251121/linkml/modules/classes/CustodianName.yaml index e7c25f1c0b..71d17e2c08 100644 --- a/frontend/public/schemas/20251121/linkml/modules/classes/CustodianName.yaml +++ b/frontend/public/schemas/20251121/linkml/modules/classes/CustodianName.yaml @@ -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 diff --git a/frontend/src/components/database/HeritageDashboard.css b/frontend/src/components/database/HeritageDashboard.css index 7652cd85ed..cec52ac3a9 100644 --- a/frontend/src/components/database/HeritageDashboard.css +++ b/frontend/src/components/database/HeritageDashboard.css @@ -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 { diff --git a/frontend/src/components/database/OxigraphPanel.tsx b/frontend/src/components/database/OxigraphPanel.tsx index c67f1fe652..80156d231d 100644 --- a/frontend/src/components/database/OxigraphPanel.tsx +++ b/frontend/src/components/database/OxigraphPanel.tsx @@ -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; +} + +// D3 link interface +interface D3Link extends d3.SimulationLinkDatum { + 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(null); + + const getNodeColor = useCallback((entityType: string): string => { + const colors: Record = { + '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(d3Nodes) + .force('link', d3.forceLink(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() + .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() + .scaleExtent([0.1, 4]) + .on('zoom', (event) => { + container.attr('transform', event.transform); + }); + + svg.call(zoom as any); + + }, [data, width, height, getNodeColor, onNodeClick]); + + return ( + + ); +} + 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(null); const [isQueryRunning, setIsQueryRunning] = useState(false); @@ -113,6 +332,14 @@ export function OxigraphPanel({ compact = false }: OxigraphPanelProps) { const [uploadGraph, setUploadGraph] = useState(''); const fileInputRef = useRef(null); + // Explore tab state + const [selectedClass, setSelectedClass] = useState(null); + const [exploreViewMode, setExploreViewMode] = useState<'graph' | 'table'>('graph'); + const [graphData, setGraphData] = useState(null); + const [isLoadingData, setIsLoadingData] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); + const [selectedNode, setSelectedNode] = useState(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 */}
+ +
+ {graphData && ( +
+ {graphData.nodes.length} {t('nodes')} + {graphData.edges.length} {t('edges')} +
+ )} + + + {/* Data Viewer */} + {graphData && ( +
+
+
+

{selectedClass ? shortenUri(selectedClass) : t('allClasses')}

+ + {filteredNodes.length} {t('nodes')} + +
+
+ setSearchTerm(e.target.value)} + /> +
+ + +
+
+
+ + {isLoadingData ? ( +
+
+

{t('loading')}

+
+ ) : !graphData?.nodes.length ? ( +

{t('noData')}

+ ) : exploreViewMode === 'graph' ? ( +
+ +
+ ) : ( +
+ + + + + + + + + + {filteredNodes.map(node => ( + setSelectedNode(node)} + className={selectedNode?.id === node.id ? 'selected' : ''} + > + + + + + ))} + +
URILabelType
{shortenUri(node.id)}{node.label}{node.entityType}
+
+ )} + + {/* Node Detail Panel */} + {selectedNode && ( +
+
+

{selectedNode.label}

+ +
+
+

URI: {selectedNode.id}

+

Type: {selectedNode.entityType}

+
+
+ )} +
+ )} + + {!graphData && !isLoadingData && ( +

Select a class and click "Load Graph" to visualize RDF data

+ )} +
+ )} + {activeTab === 'graphs' && (
{!stats?.graphs.length ? ( diff --git a/frontend/src/components/database/PostgreSQLPanel.tsx b/frontend/src/components/database/PostgreSQLPanel.tsx index a1deda091e..fbd3d5d0c3 100644 --- a/frontend/src/components/database/PostgreSQLPanel.tsx +++ b/frontend/src/components/database/PostgreSQLPanel.tsx @@ -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(null); const [isQueryRunning, setIsQueryRunning] = useState(false); - const [expandedTable, setExpandedTable] = useState(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 (
@@ -165,10 +306,10 @@ export function PostgreSQLPanel({ compact = false }: PostgreSQLPanelProps) { {/* Tabs */}
+ + {t('showingRows')} {dataPage * PAGE_SIZE + 1}-{Math.min((dataPage + 1) * PAGE_SIZE, tableData.totalRows)} {t('of')} {tableData.totalRows.toLocaleString()} + + +
+ )} +
)}
)} diff --git a/frontend/src/components/database/TypeDBPanel.tsx b/frontend/src/components/database/TypeDBPanel.tsx index c9450c3a34..0d630e16f1 100644 --- a/frontend/src/components/database/TypeDBPanel.tsx +++ b/frontend/src/components/database/TypeDBPanel.tsx @@ -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; +} + +// D3 link interface +interface D3Link extends d3.SimulationLinkDatum { + 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(null); + + const getNodeColor = useCallback((entityType: string): string => { + const colors: Record = { + '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(d3Nodes) + .force('link', d3.forceLink(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() + .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() + .scaleExtent([0.1, 4]) + .on('zoom', (event) => { + container.attr('transform', event.transform); + }); + + svg.call(zoom as any); + + }, [data, width, height, getNodeColor, onNodeClick]); + + return ( + + ); +} + +// ============================================================================ +// 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(null); const [schemaContent, setSchemaContent] = useState(null); const [isQueryRunning, setIsQueryRunning] = useState(false); + // Explore tab state + const [selectedEntityType, setSelectedEntityType] = useState(null); + const [exploreViewMode, setExploreViewMode] = useState<'graph' | 'table'>('table'); + const [graphData, setGraphData] = useState(null); + const [isLoadingData, setIsLoadingData] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); + const [selectedNode, setSelectedNode] = useState(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 (
- 🧠 + 🔷

TypeDB

{isLoading ? t('loading') : status.isConnected ? t('connected') : t('disconnected')} @@ -125,7 +429,7 @@ export function TypeDBPanel({ compact = false }: TypeDBPanelProps) { {/* Header */}
- 🧠 + 🔷

{t('title')}

{t('description')}

@@ -180,6 +484,12 @@ export function TypeDBPanel({ compact = false }: TypeDBPanelProps) { {/* Tabs */}
+ + ))} +
+
+ + {/* Data Viewer */} + {selectedEntityType && ( +
+
+
+

{selectedEntityType}

+ + {isLoadingData ? t('loading') : `${filteredNodes.length} ${t('instances')}`} + +
+
+ setSearchTerm(e.target.value)} + /> +
+ + +
+ + +
+
+ + {isLoadingData ? ( +
+
+

{t('loading')}

+
+ ) : !graphData?.nodes.length ? ( +

{t('noInstances')}

+ ) : exploreViewMode === 'table' ? ( +
+ + + + + + + + + + + {filteredNodes.map(node => ( + setSelectedNode(node)} + className={selectedNode?.id === node.id ? 'selected' : ''} + > + + + + + + ))} + +
IDLabelType{t('attributes')}
{node.id.substring(0, 12)}...{node.label}{node.entityType} + {Object.entries(node.attributes).length > 0 ? ( + + {Object.entries(node.attributes).slice(0, 3).map(([k, v]) => ( + {k}: {String(v).substring(0, 20)} + ))} + {Object.keys(node.attributes).length > 3 && ( + +{Object.keys(node.attributes).length - 3} more + )} + + ) : '-'} +
+
+ ) : ( +
+ +
+ )} + + {/* Node Detail Panel */} + {selectedNode && ( +
+
+

{selectedNode.label}

+ +
+
+

ID: {selectedNode.id}

+

Type: {selectedNode.entityType}

+
+ {t('attributes')}: + {Object.entries(selectedNode.attributes).length > 0 ? ( +
    + {Object.entries(selectedNode.attributes).map(([key, value]) => ( +
  • + {key}: + {String(value)} +
  • + ))} +
+ ) : ( +

No attributes

+ )} +
+
+
+ )} +
+ )} + + {!selectedEntityType && stats?.entityTypes.length && ( +

{t('clickToExplore')}

+ )} +
+ )} + {activeTab === 'entities' && (
{!stats?.entityTypes.length ? ( diff --git a/frontend/src/components/map/InstitutionInfoPanel.tsx b/frontend/src/components/map/InstitutionInfoPanel.tsx index 4c79133f2a..94cedadcc5 100644 --- a/frontend/src/components/map/InstitutionInfoPanel.tsx +++ b/frontend/src/components/map/InstitutionInfoPanel.tsx @@ -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?: { diff --git a/frontend/src/components/map/TimelineSlider.css b/frontend/src/components/map/TimelineSlider.css new file mode 100644 index 0000000000..6ba9f9740c --- /dev/null +++ b/frontend/src/components/map/TimelineSlider.css @@ -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; + } +} diff --git a/frontend/src/components/map/TimelineSlider.tsx b/frontend/src/components/map/TimelineSlider.tsx new file mode 100644 index 0000000000..7c6a654cd3 --- /dev/null +++ b/frontend/src/components/map/TimelineSlider.tsx @@ -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 = ({ + institutions, + selectedRange, + onRangeChange, + isActive, + onToggleActive, + t, + language, +}) => { + // DEBUG: Log props on every render + console.log(`[TimelineSlider] Render - isActive=${isActive}, range=[${selectedRange[0]}, ${selectedRange[1]}], institutions=${institutions.length}`); + + const [isCollapsed, setIsCollapsed] = useState(false); + const [isDragging, setIsDragging] = useState<'start' | 'end' | null>(null); + const sliderRef = useRef(null); + + // Calculate decade histogram - count institutions founded per decade + const histogram = useMemo(() => { + const decades: Record = {}; + + // Initialize all decades + for (let year = MIN_YEAR; year <= MAX_YEAR; year += DECADE_WIDTH) { + decades[year] = { founded: 0, active: 0, dissolved: 0 }; + } + + institutions.forEach(inst => { + const temporal = inst.temporal; + if (!temporal?.founding_year && !temporal?.founding_decade) return; + + const foundingYear = temporal.founding_year || (temporal.founding_decade ? temporal.founding_decade : null); + if (!foundingYear) return; + + const foundingDecade = Math.floor(foundingYear / 10) * 10; + if (foundingDecade >= MIN_YEAR && foundingDecade <= MAX_YEAR) { + decades[foundingDecade].founded++; + } + + // Count as dissolved if has dissolution year + if (temporal.dissolution_year || temporal.is_defunct) { + const dissolutionYear = temporal.dissolution_year; + if (dissolutionYear) { + const dissolutionDecade = Math.floor(dissolutionYear / 10) * 10; + if (dissolutionDecade >= MIN_YEAR && dissolutionDecade <= MAX_YEAR) { + decades[dissolutionDecade].dissolved++; + } + } + } + }); + + // Calculate max for normalization + const maxFounded = Math.max(...Object.values(decades).map(d => d.founded), 1); + + return { decades, maxFounded }; + }, [institutions]); + + // Calculate visible institutions count in selected range + const visibleCount = useMemo(() => { + if (!isActive) return institutions.length; + + return institutions.filter(inst => { + const temporal = inst.temporal; + + const foundingYear = temporal?.founding_year || temporal?.founding_decade; + const dissolutionYear = temporal?.dissolution_year; + + // If no temporal data at all, HIDE the institution when timeline filter is active + if (!foundingYear && !dissolutionYear) return false; + + // Institution is visible if: + // 1. Founded before or during the selected end year + // 2. AND (still operational OR dissolved after the selected start year) + if (foundingYear && foundingYear > selectedRange[1]) return false; + if (dissolutionYear && dissolutionYear < selectedRange[0]) return false; + + return true; + }).length; + }, [institutions, selectedRange, isActive]); + + // Count of defunct/destroyed institutions in range + const defunctCount = useMemo(() => { + if (!isActive) return 0; + + return institutions.filter(inst => { + const temporal = inst.temporal; + if (!temporal?.is_defunct && !temporal?.dissolution_year) return false; + + const foundingYear = temporal.founding_year || temporal.founding_decade; + const dissolutionYear = temporal.dissolution_year; + + if (foundingYear && foundingYear > selectedRange[1]) return false; + if (dissolutionYear && dissolutionYear < selectedRange[0]) return false; + + return true; + }).length; + }, [institutions, selectedRange, isActive]); + + // Convert pixel position to year + const positionToYear = useCallback((clientX: number) => { + if (!sliderRef.current) return selectedRange[0]; + const rect = sliderRef.current.getBoundingClientRect(); + const percentage = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); + return Math.round(MIN_YEAR + percentage * (MAX_YEAR - MIN_YEAR)); + }, [selectedRange]); + + // Handle mouse/touch events for dragging + const handleMouseDown = useCallback((handle: 'start' | 'end') => (e: React.MouseEvent | React.TouchEvent) => { + e.preventDefault(); + setIsDragging(handle); + }, []); + + const handleMouseMove = useCallback((e: MouseEvent | TouchEvent) => { + if (!isDragging) return; + + const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX; + const year = positionToYear(clientX); + + if (isDragging === 'start') { + onRangeChange([Math.min(year, selectedRange[1] - 10), selectedRange[1]]); + } else { + onRangeChange([selectedRange[0], Math.max(year, selectedRange[0] + 10)]); + } + }, [isDragging, positionToYear, selectedRange, onRangeChange]); + + const handleMouseUp = useCallback(() => { + setIsDragging(null); + }, []); + + // Global event listeners for dragging + useEffect(() => { + if (isDragging) { + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + window.addEventListener('touchmove', handleMouseMove); + window.addEventListener('touchend', handleMouseUp); + } + + return () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + window.removeEventListener('touchmove', handleMouseMove); + window.removeEventListener('touchend', handleMouseUp); + }; + }, [isDragging, handleMouseMove, handleMouseUp]); + + // Calculate handle positions as percentages + const startPercent = ((selectedRange[0] - MIN_YEAR) / (MAX_YEAR - MIN_YEAR)) * 100; + const endPercent = ((selectedRange[1] - MIN_YEAR) / (MAX_YEAR - MIN_YEAR)) * 100; + + if (isCollapsed) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Header */} +
+
+ 📅 + {t('Tijdlijn', 'Timeline')} +
+ +
+ {/* Toggle switch */} + + + {/* Collapse button */} + +
+
+ + {/* Stats */} +
+ + {visibleCount.toLocaleString()} {t('zichtbaar', 'visible')} + + {isActive && defunctCount > 0 && ( + + ⚠️ {defunctCount} {t('opgeheven', 'defunct')} + + )} + + {selectedRange[0]} — {selectedRange[1]} + +
+ + {/* Presets */} +
+ {PRESETS.map(preset => ( + + ))} +
+ + {/* Histogram and Slider */} +
+ {/* Histogram bars */} +
+ {Object.entries(histogram.decades).map(([decadeStr, data]) => { + const decade = parseInt(decadeStr); + const height = (data.founded / histogram.maxFounded) * 100; + const left = ((decade - MIN_YEAR) / (MAX_YEAR - MIN_YEAR)) * 100; + const isInRange = decade >= selectedRange[0] && decade <= selectedRange[1]; + const hasDissolved = data.dissolved > 0; + + return ( +
+ ); + })} +
+ + {/* Slider track */} +
+ {/* Selected range highlight */} +
+ + {/* Start handle */} +
+ {selectedRange[0]} +
+ + {/* End handle */} +
+ {selectedRange[1]} +
+
+ + {/* Axis labels */} +
+ {MIN_YEAR} + 1600 + 1800 + 1900 + 2000 + {MAX_YEAR} +
+
+
+ ); +}; + +export default TimelineSlider; diff --git a/frontend/src/hooks/useDuckLakeInstitutions.ts b/frontend/src/hooks/useDuckLakeInstitutions.ts index f2a18b3f3f..6afaec4eba 100644 --- a/frontend/src/hooks/useDuckLakeInstitutions.ts +++ b/frontend/src/hooks/useDuckLakeInstitutions.ts @@ -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 = { // 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 = { 'GALLERY': 'G', 'LIBRARY': 'L', @@ -65,6 +73,13 @@ const TYPE_NAME_TO_CODE: Record = { '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(gmapsJson, {}); const identifiersData = safeParseJSON(identifiersJson, {}); @@ -478,32 +597,143 @@ export function useDuckLakeInstitutions(): UseDuckLakeInstitutionsReturn { const wikidataEnrichData = safeParseJSON(wikidataEnrichJson, {}); const originalEntryData = safeParseJSON(originalEntryJson, {}); const serviceAreaData = safeParseJSON(serviceAreaJson, {}); + const youtubeEnrichData = safeParseJSON(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); diff --git a/frontend/src/hooks/useOxigraph.ts b/frontend/src/hooks/useOxigraph.ts index ac72691b87..faca948476 100644 --- a/frontend/src/hooks/useOxigraph.ts +++ b/frontend/src/hooks/useOxigraph.ts @@ -30,6 +30,28 @@ export interface OxigraphStats { }>; } +// Graph visualization data structures +export interface OxigraphGraphNode { + id: string; + label: string; + type: string; + entityType: string; + attributes: Record; +} + +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; clearGraph: (graphName?: string) => Promise; exportGraph: (graphName?: string, format?: string) => Promise; + getGraphData: (rdfClass?: string, limit?: number) => Promise; } /** @@ -360,6 +383,165 @@ export function useOxigraph(): UseOxigraphReturn { return response.text(); }, []); + /** + * Shorten a URI for display + */ + const shortenUri = (uri: string): string => { + const prefixes: Record = { + '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 => { + // Build SPARQL query to get triples + // If rdfClass is specified, filter by that class + const query = rdfClass + ? ` + PREFIX rdf: + PREFIX rdfs: + PREFIX schema: + PREFIX skos: + + 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: + PREFIX rdfs: + PREFIX schema: + PREFIX skos: + + 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(); + 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, }; } diff --git a/frontend/src/hooks/usePostgreSQL.ts b/frontend/src/hooks/usePostgreSQL.ts index e28effa3c2..6cb3ea3124 100644 --- a/frontend/src/hooks/usePostgreSQL.ts +++ b/frontend/src/hooks/usePostgreSQL.ts @@ -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; executeQuery: (query: string) => Promise; + executeRawQuery: (query: string) => Promise; getTables: () => Promise; getTableSchema: (tableName: string) => Promise; + getTableData: (schema: string, tableName: string, limit?: number, offset?: number) => Promise; + exportTableData: (schema: string, tableName: string, format: 'json' | 'csv') => Promise; } /** @@ -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 => { 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 => { + 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 => { + // 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 => { + // 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, }; } diff --git a/frontend/src/hooks/useTypeDB.ts b/frontend/src/hooks/useTypeDB.ts index 45611a878c..7d4d95aa4c 100644 --- a/frontend/src/hooks/useTypeDB.ts +++ b/frontend/src/hooks/useTypeDB.ts @@ -43,6 +43,28 @@ export interface TypeDBStatus { responseTimeMs?: number; } +export interface TypeDBGraphNode { + id: string; + label: string; + type: string; + entityType: string; + attributes: Record; +} + +export interface TypeDBGraphEdge { + id: string; + source: string; + target: string; + relationType: string; + role: string; + attributes: Record; +} + +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; getDatabases: () => Promise; getSchema: () => Promise; + getEntityInstances: (entityType: string, limit?: number) => Promise; + getRelationInstances: (relationType: string, limit?: number) => Promise; + getGraphData: (entityType: string, depth?: number, limit?: number) => Promise; } /** @@ -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 => { + const query = `match $x isa ${entityType}, has $attr; get $x, $attr; limit ${limit};`; + const response = await typedbQuery(query, 'read') as { results: Record[] }; + const result = response.results || []; + + // Transform query results to graph nodes + const nodeMap = new Map(); + + 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 => { + const query = `match $r isa ${relationType}; $r ($role: $player); get $r, $role, $player; limit ${limit};`; + const response = await typedbQuery(query, 'read') as { results: Record[] }; + const result = response.results || []; + + // Group by relation ID + const relationMap = new Map }>(); + + 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 => { + 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, }; } diff --git a/frontend/src/hooks/useWerkgebiedMapLibre.ts b/frontend/src/hooks/useWerkgebiedMapLibre.ts index fb6e789f0d..4cc60f0211 100644 --- a/frontend/src/hooks/useWerkgebiedMapLibre.ts +++ b/frontend/src/hooks/useWerkgebiedMapLibre.ts @@ -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(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'); } } diff --git a/frontend/src/pages/Database.tsx b/frontend/src/pages/Database.tsx index 55c41727a8..5ac70855df 100644 --- a/frontend/src/pages/Database.tsx +++ b/frontend/src/pages/Database.tsx @@ -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() {
- 🧠 + 🔷
TypeDB

{t('graphDescription')}

diff --git a/frontend/src/pages/NDEMapPageMapLibre.tsx b/frontend/src/pages/NDEMapPageMapLibre.tsx index ed3d7fef81..5aab86dd48 100644 --- a/frontend/src/pages/NDEMapPageMapLibre.tsx +++ b/frontend/src/pages/NDEMapPageMapLibre.tsx @@ -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 = { - 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 = { - 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>(new Set()); const [selectedMinRating, setSelectedMinRating] = useState(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(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 = {}; @@ -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() {
+ + {/* Timeline Slider */} + { + console.log(`[Timeline Toggle] Clicked! Current isTimelineActive=${isTimelineActive}, setting to ${!isTimelineActive}`); + setIsTimelineActive(!isTimelineActive); + }} + t={t} + language={language} + />