feat(frontend): add graph visualization and data explorer features
Database Panels: - Add D3.js force-directed graph visualization to Oxigraph and TypeDB panels - Add 'Explore' tab with class/entity browser, graph/table toggle, and search - Add data explorer to PostgreSQL panel with table browser, pagination, search, export - Fix SPARQL variable naming bug in Oxigraph getGraphData() function - Add node details panel showing selected entity attributes - Add zoom/pan controls and node coloring by entity type Map Features: - Add TimelineSlider component for temporal filtering of institutions - Support dual-handle range slider with decade histogram - Add quick presets (Ancient, Medieval, Modern, Contemporary) - Show institution density visualization by founding decade Hooks: - Extend useOxigraph with getGraphData() for graph visualization - Extend useTypeDB with getGraphData() for graph visualization - Extend usePostgreSQL with getTableData() and exportTableData() - Improve useDuckLakeInstitutions with temporal filtering support Styles: - Add HeritageDashboard.css with shared panel styling - Add TimelineSlider.css for timeline component styling
This commit is contained in:
parent
7e3559f7e5
commit
13f67bed19
16 changed files with 3496 additions and 218 deletions
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"generated": "2025-12-07T21:46:29.967Z",
|
||||
"generated": "2025-12-08T13:51:53.356Z",
|
||||
"version": "1.0.0",
|
||||
"categories": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@
|
|||
* SPARQL triplestore for Linked Data
|
||||
*/
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import * as d3 from 'd3';
|
||||
import { useOxigraph } from '@/hooks/useOxigraph';
|
||||
import type { OxigraphGraphNode, OxigraphGraphData } from '@/hooks/useOxigraph';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
|
||||
interface OxigraphPanelProps {
|
||||
|
|
@ -51,6 +53,16 @@ const TEXT = {
|
|||
class: { nl: 'Klasse', en: 'Class' },
|
||||
predicate: { nl: 'Predicaat', en: 'Predicate' },
|
||||
count: { nl: 'Aantal', en: 'Count' },
|
||||
explore: { nl: 'Verkennen', en: 'Explore' },
|
||||
graphView: { nl: 'Graaf', en: 'Graph' },
|
||||
tableView: { nl: 'Tabel', en: 'Table' },
|
||||
loadGraph: { nl: 'Graaf laden', en: 'Load Graph' },
|
||||
allClasses: { nl: 'Alle klassen', en: 'All Classes' },
|
||||
selectClass: { nl: 'Selecteer klasse...', en: 'Select class...' },
|
||||
nodes: { nl: 'Knooppunten', en: 'Nodes' },
|
||||
edges: { nl: 'Verbindingen', en: 'Edges' },
|
||||
search: { nl: 'Zoeken...', en: 'Search...' },
|
||||
attributes: { nl: 'Attributen', en: 'Attributes' },
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -89,6 +101,212 @@ function shortenUri(uri: string): string {
|
|||
return uri;
|
||||
}
|
||||
|
||||
// D3 node interface extending simulation datum
|
||||
interface D3Node extends d3.SimulationNodeDatum {
|
||||
id: string;
|
||||
label: string;
|
||||
type: string;
|
||||
entityType: string;
|
||||
attributes: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// D3 link interface
|
||||
interface D3Link extends d3.SimulationLinkDatum<D3Node> {
|
||||
id: string;
|
||||
predicate: string;
|
||||
predicateLabel: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Graph Visualization Component for Oxigraph
|
||||
// ============================================================================
|
||||
|
||||
interface OxigraphGraphVisualizationProps {
|
||||
data: OxigraphGraphData;
|
||||
width?: number;
|
||||
height?: number;
|
||||
onNodeClick?: (node: OxigraphGraphNode) => void;
|
||||
}
|
||||
|
||||
function OxigraphGraphVisualization({ data, width = 800, height = 500, onNodeClick }: OxigraphGraphVisualizationProps) {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
const getNodeColor = useCallback((entityType: string): string => {
|
||||
const colors: Record<string, string> = {
|
||||
'schema:Museum': '#e74c3c',
|
||||
'schema:Library': '#3498db',
|
||||
'schema:ArchiveOrganization': '#2ecc71',
|
||||
'hc:HeritageCustodian': '#9b59b6',
|
||||
'cpov:PublicOrganisation': '#1abc9c',
|
||||
'schema:Place': '#16a085',
|
||||
'schema:Organization': '#f39c12',
|
||||
'crm:E74_Group': '#e67e22',
|
||||
'rico:CorporateBody': '#d35400',
|
||||
};
|
||||
return colors[entityType] || '#95a5a6';
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || data.nodes.length === 0) return;
|
||||
|
||||
const svg = d3.select(svgRef.current);
|
||||
svg.selectAll('*').remove();
|
||||
|
||||
// Create container with zoom
|
||||
const container = svg.append('g').attr('class', 'graph-container');
|
||||
|
||||
// Arrow marker
|
||||
const defs = svg.append('defs');
|
||||
defs.append('marker')
|
||||
.attr('id', 'oxigraph-arrow')
|
||||
.attr('viewBox', '0 -5 10 10')
|
||||
.attr('refX', 25)
|
||||
.attr('refY', 0)
|
||||
.attr('markerWidth', 6)
|
||||
.attr('markerHeight', 6)
|
||||
.attr('orient', 'auto')
|
||||
.append('path')
|
||||
.attr('d', 'M0,-5L10,0L0,5')
|
||||
.attr('fill', '#999');
|
||||
|
||||
// Create D3 nodes and links
|
||||
const d3Nodes: D3Node[] = data.nodes.map(n => ({
|
||||
...n,
|
||||
x: width / 2 + (Math.random() - 0.5) * 200,
|
||||
y: height / 2 + (Math.random() - 0.5) * 200,
|
||||
}));
|
||||
|
||||
const nodeMap = new Map(d3Nodes.map(n => [n.id, n]));
|
||||
|
||||
const d3Links: D3Link[] = data.edges
|
||||
.filter(e => nodeMap.has(e.source) && nodeMap.has(e.target))
|
||||
.map(e => ({
|
||||
id: e.id,
|
||||
source: nodeMap.get(e.source)!,
|
||||
target: nodeMap.get(e.target)!,
|
||||
predicate: e.predicate,
|
||||
predicateLabel: e.predicateLabel,
|
||||
}));
|
||||
|
||||
// Force simulation
|
||||
const simulation = d3.forceSimulation<D3Node>(d3Nodes)
|
||||
.force('link', d3.forceLink<D3Node, D3Link>(d3Links).id(d => d.id).distance(120))
|
||||
.force('charge', d3.forceManyBody().strength(-400))
|
||||
.force('center', d3.forceCenter(width / 2, height / 2))
|
||||
.force('collision', d3.forceCollide().radius(40));
|
||||
|
||||
// Links
|
||||
const link = container.append('g')
|
||||
.attr('class', 'links')
|
||||
.selectAll('line')
|
||||
.data(d3Links)
|
||||
.join('line')
|
||||
.attr('stroke', '#999')
|
||||
.attr('stroke-width', 2)
|
||||
.attr('stroke-opacity', 0.6)
|
||||
.attr('marker-end', 'url(#oxigraph-arrow)');
|
||||
|
||||
// Link labels
|
||||
const linkLabel = container.append('g')
|
||||
.attr('class', 'link-labels')
|
||||
.selectAll('text')
|
||||
.data(d3Links)
|
||||
.join('text')
|
||||
.attr('font-size', 9)
|
||||
.attr('fill', '#666')
|
||||
.attr('text-anchor', 'middle')
|
||||
.text(d => d.predicateLabel);
|
||||
|
||||
// Nodes
|
||||
const node = container.append('g')
|
||||
.attr('class', 'nodes')
|
||||
.selectAll('circle')
|
||||
.data(d3Nodes)
|
||||
.join('circle')
|
||||
.attr('r', 15)
|
||||
.attr('fill', d => getNodeColor(d.entityType))
|
||||
.attr('stroke', '#fff')
|
||||
.attr('stroke-width', 2)
|
||||
.style('cursor', 'pointer')
|
||||
.on('click', (_event, d) => {
|
||||
onNodeClick?.({
|
||||
id: d.id,
|
||||
label: d.label,
|
||||
type: d.type,
|
||||
entityType: d.entityType,
|
||||
attributes: d.attributes,
|
||||
});
|
||||
})
|
||||
.call(d3.drag<SVGCircleElement, D3Node>()
|
||||
.on('start', (event, d) => {
|
||||
if (!event.active) simulation.alphaTarget(0.3).restart();
|
||||
d.fx = d.x;
|
||||
d.fy = d.y;
|
||||
})
|
||||
.on('drag', (event, d) => {
|
||||
d.fx = event.x;
|
||||
d.fy = event.y;
|
||||
})
|
||||
.on('end', (event, d) => {
|
||||
if (!event.active) simulation.alphaTarget(0);
|
||||
d.fx = null;
|
||||
d.fy = null;
|
||||
}) as any);
|
||||
|
||||
// Node labels
|
||||
const nodeLabel = container.append('g')
|
||||
.attr('class', 'node-labels')
|
||||
.selectAll('text')
|
||||
.data(d3Nodes)
|
||||
.join('text')
|
||||
.attr('font-size', 11)
|
||||
.attr('dx', 18)
|
||||
.attr('dy', 4)
|
||||
.attr('fill', '#333')
|
||||
.text(d => d.label.length > 20 ? d.label.substring(0, 17) + '...' : d.label);
|
||||
|
||||
// Simulation tick
|
||||
simulation.on('tick', () => {
|
||||
link
|
||||
.attr('x1', d => (d.source as D3Node).x!)
|
||||
.attr('y1', d => (d.source as D3Node).y!)
|
||||
.attr('x2', d => (d.target as D3Node).x!)
|
||||
.attr('y2', d => (d.target as D3Node).y!);
|
||||
|
||||
linkLabel
|
||||
.attr('x', d => ((d.source as D3Node).x! + (d.target as D3Node).x!) / 2)
|
||||
.attr('y', d => ((d.source as D3Node).y! + (d.target as D3Node).y!) / 2 - 5);
|
||||
|
||||
node
|
||||
.attr('cx', d => d.x!)
|
||||
.attr('cy', d => d.y!);
|
||||
|
||||
nodeLabel
|
||||
.attr('x', d => d.x!)
|
||||
.attr('y', d => d.y!);
|
||||
});
|
||||
|
||||
// Zoom behavior
|
||||
const zoom = d3.zoom<SVGSVGElement, unknown>()
|
||||
.scaleExtent([0.1, 4])
|
||||
.on('zoom', (event) => {
|
||||
container.attr('transform', event.transform);
|
||||
});
|
||||
|
||||
svg.call(zoom as any);
|
||||
|
||||
}, [data, width, height, getNodeColor, onNodeClick]);
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={svgRef}
|
||||
width={width}
|
||||
height={height}
|
||||
style={{ background: '#fafafa', borderRadius: '8px', border: '1px solid #e0e0e0' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function OxigraphPanel({ compact = false }: OxigraphPanelProps) {
|
||||
const { language } = useLanguage();
|
||||
const t = (key: keyof typeof TEXT) => TEXT[key][language];
|
||||
|
|
@ -103,9 +321,10 @@ export function OxigraphPanel({ compact = false }: OxigraphPanelProps) {
|
|||
loadRdfData,
|
||||
clearGraph,
|
||||
exportGraph,
|
||||
getGraphData,
|
||||
} = useOxigraph();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'graphs' | 'classes' | 'predicates' | 'namespaces' | 'query' | 'upload'>('graphs');
|
||||
const [activeTab, setActiveTab] = useState<'explore' | 'graphs' | 'classes' | 'predicates' | 'namespaces' | 'query' | 'upload'>('explore');
|
||||
const [query, setQuery] = useState('SELECT * WHERE { ?s ?p ?o } LIMIT 10');
|
||||
const [queryResult, setQueryResult] = useState<string | null>(null);
|
||||
const [isQueryRunning, setIsQueryRunning] = useState(false);
|
||||
|
|
@ -113,6 +332,14 @@ export function OxigraphPanel({ compact = false }: OxigraphPanelProps) {
|
|||
const [uploadGraph, setUploadGraph] = useState('');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Explore tab state
|
||||
const [selectedClass, setSelectedClass] = useState<string | null>(null);
|
||||
const [exploreViewMode, setExploreViewMode] = useState<'graph' | 'table'>('graph');
|
||||
const [graphData, setGraphData] = useState<OxigraphGraphData | null>(null);
|
||||
const [isLoadingData, setIsLoadingData] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedNode, setSelectedNode] = useState<OxigraphGraphNode | null>(null);
|
||||
|
||||
const handleRunQuery = async () => {
|
||||
setIsQueryRunning(true);
|
||||
setQueryResult(null);
|
||||
|
|
@ -168,6 +395,33 @@ export function OxigraphPanel({ compact = false }: OxigraphPanelProps) {
|
|||
}
|
||||
};
|
||||
|
||||
// Handler for loading graph visualization data
|
||||
const handleLoadGraphData = async (rdfClass?: string) => {
|
||||
setSelectedClass(rdfClass || null);
|
||||
setIsLoadingData(true);
|
||||
setGraphData(null);
|
||||
setSelectedNode(null);
|
||||
try {
|
||||
const data = await getGraphData(rdfClass, 100);
|
||||
setGraphData(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load graph data:', err);
|
||||
} finally {
|
||||
setIsLoadingData(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Filter nodes by search term
|
||||
const filteredNodes = graphData?.nodes.filter(n => {
|
||||
if (!searchTerm) return true;
|
||||
const term = searchTerm.toLowerCase();
|
||||
return (
|
||||
n.label.toLowerCase().includes(term) ||
|
||||
n.id.toLowerCase().includes(term) ||
|
||||
n.entityType.toLowerCase().includes(term)
|
||||
);
|
||||
}) ?? [];
|
||||
|
||||
// Compact view for comparison grid
|
||||
if (compact) {
|
||||
return (
|
||||
|
|
@ -254,6 +508,12 @@ export function OxigraphPanel({ compact = false }: OxigraphPanelProps) {
|
|||
|
||||
{/* Tabs */}
|
||||
<div className="panel-tabs">
|
||||
<button
|
||||
className={`tab-btn ${activeTab === 'explore' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('explore')}
|
||||
>
|
||||
{t('explore')}
|
||||
</button>
|
||||
<button
|
||||
className={`tab-btn ${activeTab === 'graphs' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('graphs')}
|
||||
|
|
@ -294,6 +554,140 @@ export function OxigraphPanel({ compact = false }: OxigraphPanelProps) {
|
|||
|
||||
{/* Tab Content */}
|
||||
<div className="panel-content">
|
||||
{/* EXPLORE TAB - Graph Visualization */}
|
||||
{activeTab === 'explore' && (
|
||||
<div className="explore-section">
|
||||
{/* Class Selector and Controls */}
|
||||
<div className="explore-header">
|
||||
<div className="explore-controls">
|
||||
<select
|
||||
className="class-select"
|
||||
value={selectedClass || ''}
|
||||
onChange={(e) => handleLoadGraphData(e.target.value || undefined)}
|
||||
>
|
||||
<option value="">{t('allClasses')}</option>
|
||||
{stats?.classes.map((cls, index) => (
|
||||
<option key={index} value={cls.class}>
|
||||
{shortenUri(cls.class)} ({cls.count})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className="primary-button"
|
||||
onClick={() => handleLoadGraphData(selectedClass || undefined)}
|
||||
disabled={isLoadingData}
|
||||
>
|
||||
{isLoadingData ? t('loading') : t('loadGraph')}
|
||||
</button>
|
||||
</div>
|
||||
{graphData && (
|
||||
<div className="graph-stats">
|
||||
<span className="stat-badge">{graphData.nodes.length} {t('nodes')}</span>
|
||||
<span className="stat-badge">{graphData.edges.length} {t('edges')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Data Viewer */}
|
||||
{graphData && (
|
||||
<div className="data-viewer">
|
||||
<div className="data-viewer-toolbar">
|
||||
<div className="toolbar-left">
|
||||
<h3>{selectedClass ? shortenUri(selectedClass) : t('allClasses')}</h3>
|
||||
<span className="instance-count">
|
||||
{filteredNodes.length} {t('nodes')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="toolbar-right">
|
||||
<input
|
||||
type="text"
|
||||
className="search-input"
|
||||
placeholder={t('search')}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
<div className="view-toggle">
|
||||
<button
|
||||
className={`toggle-btn ${exploreViewMode === 'graph' ? 'active' : ''}`}
|
||||
onClick={() => setExploreViewMode('graph')}
|
||||
>
|
||||
{t('graphView')}
|
||||
</button>
|
||||
<button
|
||||
className={`toggle-btn ${exploreViewMode === 'table' ? 'active' : ''}`}
|
||||
onClick={() => setExploreViewMode('table')}
|
||||
>
|
||||
{t('tableView')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoadingData ? (
|
||||
<div className="loading-container">
|
||||
<div className="loading-spinner" />
|
||||
<p>{t('loading')}</p>
|
||||
</div>
|
||||
) : !graphData?.nodes.length ? (
|
||||
<p className="empty-message">{t('noData')}</p>
|
||||
) : exploreViewMode === 'graph' ? (
|
||||
<div className="graph-container">
|
||||
<OxigraphGraphVisualization
|
||||
data={{ nodes: filteredNodes, edges: graphData.edges }}
|
||||
width={800}
|
||||
height={500}
|
||||
onNodeClick={setSelectedNode}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>URI</th>
|
||||
<th>Label</th>
|
||||
<th>Type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredNodes.map(node => (
|
||||
<tr
|
||||
key={node.id}
|
||||
onClick={() => setSelectedNode(node)}
|
||||
className={selectedNode?.id === node.id ? 'selected' : ''}
|
||||
>
|
||||
<td><code title={node.id}>{shortenUri(node.id)}</code></td>
|
||||
<td>{node.label}</td>
|
||||
<td>{node.entityType}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Node Detail Panel */}
|
||||
{selectedNode && (
|
||||
<div className="node-detail-panel">
|
||||
<div className="node-detail-header">
|
||||
<h4>{selectedNode.label}</h4>
|
||||
<button className="close-btn" onClick={() => setSelectedNode(null)}>×</button>
|
||||
</div>
|
||||
<div className="node-detail-content">
|
||||
<p><strong>URI:</strong> <code>{selectedNode.id}</code></p>
|
||||
<p><strong>Type:</strong> {selectedNode.entityType}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!graphData && !isLoadingData && (
|
||||
<p className="help-text">Select a class and click "Load Graph" to visualize RDF data</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'graphs' && (
|
||||
<div className="graphs-list">
|
||||
{!stats?.graphs.length ? (
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
/**
|
||||
* PostgreSQL Panel Component
|
||||
* Relational database for structured heritage data
|
||||
* With Data Explorer for viewing table contents (similar to DuckLake)
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { usePostgreSQL } from '@/hooks/usePostgreSQL';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
|
||||
|
|
@ -41,6 +42,52 @@ const TEXT = {
|
|||
nl: 'PostgreSQL API niet beschikbaar. Configureer VITE_POSTGRES_API_URL.',
|
||||
en: 'PostgreSQL API not available. Configure VITE_POSTGRES_API_URL.',
|
||||
},
|
||||
// Data Explorer translations
|
||||
explore: { nl: 'Verkennen', en: 'Explore' },
|
||||
dataExplorer: { nl: 'Data Verkenner', en: 'Data Explorer' },
|
||||
selectTable: { nl: 'Selecteer een tabel om te verkennen', en: 'Select a table to explore' },
|
||||
viewData: { nl: 'Bekijk data', en: 'View data' },
|
||||
backToTables: { nl: '← Terug naar tabellen', en: '← Back to tables' },
|
||||
data: { nl: 'Data', en: 'Data' },
|
||||
search: { nl: 'Zoeken', en: 'Search' },
|
||||
searchPlaceholder: { nl: 'Zoek in alle kolommen...', en: 'Search across all columns...' },
|
||||
resultsFiltered: { nl: 'resultaten gefilterd', en: 'results filtered' },
|
||||
clearSearch: { nl: 'Wissen', en: 'Clear' },
|
||||
showingRows: { nl: 'Toon rijen', en: 'Showing rows' },
|
||||
of: { nl: 'van', en: 'of' },
|
||||
previous: { nl: 'Vorige', en: 'Previous' },
|
||||
next: { nl: 'Volgende', en: 'Next' },
|
||||
export: { nl: 'Exporteren', en: 'Export' },
|
||||
noData: { nl: 'Geen data gevonden.', en: 'No data found.' },
|
||||
};
|
||||
|
||||
// Get table icon based on name
|
||||
const getTableIcon = (tableName: string): string => {
|
||||
const name = tableName.toLowerCase();
|
||||
if (name.includes('institution') || name.includes('custodian')) return '🏛️';
|
||||
if (name.includes('collection')) return '📚';
|
||||
if (name.includes('location') || name.includes('geo') || name.includes('boundary')) return '📍';
|
||||
if (name.includes('person') || name.includes('staff')) return '👤';
|
||||
if (name.includes('event')) return '📅';
|
||||
if (name.includes('identifier') || name.includes('id')) return '🔖';
|
||||
if (name.includes('linkml') || name.includes('schema')) return '📋';
|
||||
if (name.includes('spatial') || name.includes('topology')) return '🗺️';
|
||||
if (name.startsWith('v_')) return '👁️'; // Views
|
||||
return '📊';
|
||||
};
|
||||
|
||||
// Format cell value for display
|
||||
const formatCellValue = (value: unknown): string => {
|
||||
if (value === null || value === undefined) return '—';
|
||||
if (typeof value === 'object') {
|
||||
const json = JSON.stringify(value);
|
||||
if (json.length > 60) return json.slice(0, 57) + '...';
|
||||
return json;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
if (value.length > 80) return value.slice(0, 77) + '...';
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
|
||||
export function PostgreSQLPanel({ compact = false }: PostgreSQLPanelProps) {
|
||||
|
|
@ -54,13 +101,23 @@ export function PostgreSQLPanel({ compact = false }: PostgreSQLPanelProps) {
|
|||
error,
|
||||
refresh,
|
||||
executeQuery,
|
||||
getTableData,
|
||||
exportTableData,
|
||||
} = usePostgreSQL();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'query'>('overview');
|
||||
const [activeTab, setActiveTab] = useState<'explore' | 'query'>('explore');
|
||||
const [query, setQuery] = useState('SELECT tablename FROM pg_tables WHERE schemaname = \'public\'');
|
||||
const [queryResult, setQueryResult] = useState<string | null>(null);
|
||||
const [isQueryRunning, setIsQueryRunning] = useState(false);
|
||||
const [expandedTable, setExpandedTable] = useState<string | null>(null);
|
||||
|
||||
// Data Explorer state
|
||||
const [selectedTable, setSelectedTable] = useState<{ schema: string; name: string } | null>(null);
|
||||
const [tableData, setTableData] = useState<{ columns: string[]; rows: unknown[][]; totalRows: number } | null>(null);
|
||||
const [isLoadingData, setIsLoadingData] = useState(false);
|
||||
const [dataPage, setDataPage] = useState(0);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [explorerView, setExplorerView] = useState<'list' | 'data'>('list');
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const handleRunQuery = async () => {
|
||||
setIsQueryRunning(true);
|
||||
|
|
@ -75,6 +132,86 @@ export function PostgreSQLPanel({ compact = false }: PostgreSQLPanelProps) {
|
|||
}
|
||||
};
|
||||
|
||||
// Load table data for explorer
|
||||
const loadTableData = useCallback(async (schema: string, tableName: string, page: number = 0) => {
|
||||
setIsLoadingData(true);
|
||||
setSelectedTable({ schema, name: tableName });
|
||||
setDataPage(page);
|
||||
setSearchQuery('');
|
||||
try {
|
||||
const result = await getTableData(schema, tableName, PAGE_SIZE, page * PAGE_SIZE);
|
||||
setTableData({
|
||||
columns: result.columns,
|
||||
rows: result.rows,
|
||||
totalRows: result.totalRows,
|
||||
});
|
||||
setExplorerView('data');
|
||||
} catch (err) {
|
||||
console.error('Failed to load table data:', err);
|
||||
setTableData(null);
|
||||
} finally {
|
||||
setIsLoadingData(false);
|
||||
}
|
||||
}, [getTableData]);
|
||||
|
||||
// Load page for pagination
|
||||
const loadPage = useCallback(async (page: number) => {
|
||||
if (!selectedTable) return;
|
||||
setIsLoadingData(true);
|
||||
setDataPage(page);
|
||||
try {
|
||||
const result = await getTableData(selectedTable.schema, selectedTable.name, PAGE_SIZE, page * PAGE_SIZE);
|
||||
setTableData({
|
||||
columns: result.columns,
|
||||
rows: result.rows,
|
||||
totalRows: result.totalRows,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to load page:', err);
|
||||
} finally {
|
||||
setIsLoadingData(false);
|
||||
}
|
||||
}, [selectedTable, getTableData]);
|
||||
|
||||
// Export table data
|
||||
const handleExport = useCallback(async (format: 'json' | 'csv') => {
|
||||
if (!selectedTable) return;
|
||||
try {
|
||||
const blob = await exportTableData(selectedTable.schema, selectedTable.name, format);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${selectedTable.schema}_${selectedTable.name}.${format}`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
alert(`Export failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
}
|
||||
}, [selectedTable, exportTableData]);
|
||||
|
||||
// Filter rows by search query
|
||||
const filterRowsBySearch = useCallback((rows: unknown[][], query: string): unknown[][] => {
|
||||
if (!query.trim()) return rows;
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return rows.filter(row => {
|
||||
return row.some(cell => {
|
||||
if (cell === null || cell === undefined) return false;
|
||||
const cellStr = typeof cell === 'object'
|
||||
? JSON.stringify(cell).toLowerCase()
|
||||
: String(cell).toLowerCase();
|
||||
return cellStr.includes(lowerQuery);
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Back to table list
|
||||
const backToList = () => {
|
||||
setExplorerView('list');
|
||||
setSelectedTable(null);
|
||||
setTableData(null);
|
||||
setSearchQuery('');
|
||||
};
|
||||
|
||||
// Compact view for comparison grid
|
||||
if (compact) {
|
||||
return (
|
||||
|
|
@ -107,6 +244,10 @@ export function PostgreSQLPanel({ compact = false }: PostgreSQLPanelProps) {
|
|||
);
|
||||
}
|
||||
|
||||
// Get filtered rows for display
|
||||
const displayRows = tableData ? filterRowsBySearch(tableData.rows, searchQuery) : [];
|
||||
const totalPages = tableData ? Math.ceil(tableData.totalRows / PAGE_SIZE) : 0;
|
||||
|
||||
// Full view
|
||||
return (
|
||||
<div className="db-panel full postgres-panel">
|
||||
|
|
@ -165,10 +306,10 @@ export function PostgreSQLPanel({ compact = false }: PostgreSQLPanelProps) {
|
|||
{/* Tabs */}
|
||||
<div className="panel-tabs">
|
||||
<button
|
||||
className={`tab-btn ${activeTab === 'overview' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('overview')}
|
||||
className={`tab-btn ${activeTab === 'explore' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('explore')}
|
||||
>
|
||||
{t('tables')}
|
||||
📊 {t('explore')}
|
||||
</button>
|
||||
<button
|
||||
className={`tab-btn ${activeTab === 'query' ? 'active' : ''}`}
|
||||
|
|
@ -180,70 +321,149 @@ export function PostgreSQLPanel({ compact = false }: PostgreSQLPanelProps) {
|
|||
|
||||
{/* Tab Content */}
|
||||
<div className="panel-content">
|
||||
{activeTab === 'overview' && (
|
||||
<div className="tables-list">
|
||||
{!stats?.tables.length ? (
|
||||
<p className="empty-message">{t('noTables')}</p>
|
||||
) : (
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('schema')}</th>
|
||||
<th>{t('tableName')}</th>
|
||||
<th>{t('rows')}</th>
|
||||
<th>{t('columns')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stats.tables.map((table) => (
|
||||
<>
|
||||
<tr
|
||||
{activeTab === 'explore' && (
|
||||
<div className="data-explorer">
|
||||
{explorerView === 'list' ? (
|
||||
/* Table Selection Grid */
|
||||
<div className="table-selection">
|
||||
<p className="selection-hint">{t('selectTable')}</p>
|
||||
{!stats?.tables.length ? (
|
||||
<p className="empty-message">{t('noTables')}</p>
|
||||
) : (
|
||||
<div className="table-grid">
|
||||
{stats.tables.map((table) => (
|
||||
<div
|
||||
key={`${table.schema}.${table.name}`}
|
||||
className="clickable"
|
||||
onClick={() =>
|
||||
setExpandedTable(
|
||||
expandedTable === `${table.schema}.${table.name}`
|
||||
? null
|
||||
: `${table.schema}.${table.name}`
|
||||
)
|
||||
}
|
||||
className="table-card clickable"
|
||||
onClick={() => loadTableData(table.schema, table.name)}
|
||||
>
|
||||
<td><code>{table.schema}</code></td>
|
||||
<td><code>{table.name}</code></td>
|
||||
<td>{table.rowCount.toLocaleString()}</td>
|
||||
<td>{table.columns.length}</td>
|
||||
</tr>
|
||||
{expandedTable === `${table.schema}.${table.name}` && (
|
||||
<tr className="expanded-row">
|
||||
<td colSpan={4}>
|
||||
<div className="column-list">
|
||||
<h4>{t('columns')}</h4>
|
||||
<table className="nested-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Column</th>
|
||||
<th>{t('type')}</th>
|
||||
<th>{t('nullable')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{table.columns.map((col) => (
|
||||
<tr key={col.name}>
|
||||
<td><code>{col.name}</code></td>
|
||||
<td>{col.type}</td>
|
||||
<td>{col.nullable ? 'Yes' : 'No'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<div className="table-card-icon">{getTableIcon(table.name)}</div>
|
||||
<div className="table-card-info">
|
||||
<div className="table-card-name">
|
||||
<code>{table.name}</code>
|
||||
</div>
|
||||
<div className="table-card-meta">
|
||||
<span>{table.schema}</span>
|
||||
<span>{table.rowCount.toLocaleString()} {t('rows')}</span>
|
||||
<span>{table.columns.length} {t('columns')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Data Viewer */
|
||||
<div className="data-viewer">
|
||||
{/* Header with back button and table info */}
|
||||
<div className="data-viewer-header">
|
||||
<button className="back-btn" onClick={backToList}>
|
||||
{t('backToTables')}
|
||||
</button>
|
||||
<div className="current-table">
|
||||
<span className="table-icon">{selectedTable ? getTableIcon(selectedTable.name) : '📊'}</span>
|
||||
<span className="table-name">
|
||||
<code>{selectedTable?.schema}.{selectedTable?.name}</code>
|
||||
</span>
|
||||
<span className="row-count">
|
||||
({tableData?.totalRows.toLocaleString() ?? 0} {t('rows')})
|
||||
</span>
|
||||
</div>
|
||||
<div className="export-buttons">
|
||||
<button
|
||||
className="export-btn"
|
||||
onClick={() => handleExport('csv')}
|
||||
title="Export as CSV"
|
||||
>
|
||||
📥 CSV
|
||||
</button>
|
||||
<button
|
||||
className="export-btn"
|
||||
onClick={() => handleExport('json')}
|
||||
title="Export as JSON"
|
||||
>
|
||||
📥 JSON
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search bar */}
|
||||
<div className="search-bar">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t('searchPlaceholder')}
|
||||
className="search-input"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<>
|
||||
<span className="search-results">
|
||||
{displayRows.length} {t('resultsFiltered')}
|
||||
</span>
|
||||
<button className="clear-btn" onClick={() => setSearchQuery('')}>
|
||||
{t('clearSearch')}
|
||||
</button>
|
||||
</>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Data table */}
|
||||
{isLoadingData ? (
|
||||
<div className="loading-indicator">{t('loading')}</div>
|
||||
) : !tableData || displayRows.length === 0 ? (
|
||||
<p className="empty-message">{t('noData')}</p>
|
||||
) : (
|
||||
<div className="data-table-wrapper">
|
||||
<table className="data-table resizable">
|
||||
<thead>
|
||||
<tr>
|
||||
{tableData.columns.map((col) => (
|
||||
<th key={col}>
|
||||
<span>{col}</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{displayRows.map((row, rowIdx) => (
|
||||
<tr key={rowIdx}>
|
||||
{row.map((cell, cellIdx) => (
|
||||
<td key={cellIdx} title={typeof cell === 'string' ? cell : undefined}>
|
||||
{formatCellValue(cell)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{tableData && tableData.totalRows > PAGE_SIZE && !searchQuery && (
|
||||
<div className="pagination">
|
||||
<button
|
||||
className="page-btn"
|
||||
onClick={() => loadPage(dataPage - 1)}
|
||||
disabled={dataPage === 0 || isLoadingData}
|
||||
>
|
||||
{t('previous')}
|
||||
</button>
|
||||
<span className="page-info">
|
||||
{t('showingRows')} {dataPage * PAGE_SIZE + 1}-{Math.min((dataPage + 1) * PAGE_SIZE, tableData.totalRows)} {t('of')} {tableData.totalRows.toLocaleString()}
|
||||
</span>
|
||||
<button
|
||||
className="page-btn"
|
||||
onClick={() => loadPage(dataPage + 1)}
|
||||
disabled={dataPage >= totalPages - 1 || isLoadingData}
|
||||
>
|
||||
{t('next')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,18 @@
|
|||
/**
|
||||
* TypeDB Panel Component
|
||||
* Knowledge graph for complex heritage relationships
|
||||
*
|
||||
* Features:
|
||||
* - Explore tab with data viewer and D3.js graph visualization
|
||||
* - Entity/Relation/Attribute type browser
|
||||
* - TypeQL query interface
|
||||
* - Schema viewer
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import * as d3 from 'd3';
|
||||
import { useTypeDB } from '@/hooks/useTypeDB';
|
||||
import type { TypeDBGraphNode, TypeDBGraphData } from '@/hooks/useTypeDB';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
|
||||
interface TypeDBPanelProps {
|
||||
|
|
@ -44,8 +52,234 @@ const TEXT = {
|
|||
nl: 'TypeDB API niet beschikbaar. Configureer VITE_TYPEDB_API_URL.',
|
||||
en: 'TypeDB API not available. Configure VITE_TYPEDB_API_URL.',
|
||||
},
|
||||
explore: { nl: 'Verkennen', en: 'Explore' },
|
||||
selectEntityType: { nl: 'Selecteer entity type...', en: 'Select entity type...' },
|
||||
loadData: { nl: 'Data laden', en: 'Load Data' },
|
||||
graphView: { nl: 'Graaf', en: 'Graph' },
|
||||
tableView: { nl: 'Tabel', en: 'Table' },
|
||||
instances: { nl: 'instances', en: 'instances' },
|
||||
search: { nl: 'Zoeken...', en: 'Search...' },
|
||||
exportCsv: { nl: 'CSV', en: 'CSV' },
|
||||
exportJson: { nl: 'JSON', en: 'JSON' },
|
||||
attributes: { nl: 'Attributen', en: 'Attributes' },
|
||||
noInstances: { nl: 'Geen instances gevonden voor dit type.', en: 'No instances found for this type.' },
|
||||
clickToExplore: { nl: 'Klik op een entity type om te verkennen', en: 'Click on an entity type to explore' },
|
||||
};
|
||||
|
||||
// D3 node interface extending simulation datum
|
||||
interface D3Node extends d3.SimulationNodeDatum {
|
||||
id: string;
|
||||
label: string;
|
||||
type: string;
|
||||
entityType: string;
|
||||
attributes: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// D3 link interface
|
||||
interface D3Link extends d3.SimulationLinkDatum<D3Node> {
|
||||
id: string;
|
||||
relationType: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Graph Visualization Component
|
||||
// ============================================================================
|
||||
|
||||
interface GraphVisualizationProps {
|
||||
data: TypeDBGraphData;
|
||||
width?: number;
|
||||
height?: number;
|
||||
onNodeClick?: (node: TypeDBGraphNode) => void;
|
||||
}
|
||||
|
||||
function GraphVisualization({ data, width = 800, height = 500, onNodeClick }: GraphVisualizationProps) {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
const getNodeColor = useCallback((entityType: string): string => {
|
||||
const colors: Record<string, string> = {
|
||||
'custodian': '#e74c3c',
|
||||
'museum': '#e74c3c',
|
||||
'library': '#3498db',
|
||||
'archive': '#2ecc71',
|
||||
'gallery': '#f39c12',
|
||||
'collection': '#9b59b6',
|
||||
'organization': '#1abc9c',
|
||||
'person': '#34495e',
|
||||
'place': '#16a085',
|
||||
'location': '#16a085',
|
||||
'event': '#d35400',
|
||||
'identifier': '#7f8c8d',
|
||||
};
|
||||
const key = entityType.toLowerCase();
|
||||
return colors[key] || '#95a5a6';
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || data.nodes.length === 0) return;
|
||||
|
||||
const svg = d3.select(svgRef.current);
|
||||
svg.selectAll('*').remove();
|
||||
|
||||
// Create container with zoom
|
||||
const container = svg.append('g').attr('class', 'graph-container');
|
||||
|
||||
// Arrow marker
|
||||
const defs = svg.append('defs');
|
||||
defs.append('marker')
|
||||
.attr('id', 'typedb-arrow')
|
||||
.attr('viewBox', '0 -5 10 10')
|
||||
.attr('refX', 25)
|
||||
.attr('refY', 0)
|
||||
.attr('markerWidth', 6)
|
||||
.attr('markerHeight', 6)
|
||||
.attr('orient', 'auto')
|
||||
.append('path')
|
||||
.attr('d', 'M0,-5L10,0L0,5')
|
||||
.attr('fill', '#999');
|
||||
|
||||
// Create D3 nodes and links
|
||||
const d3Nodes: D3Node[] = data.nodes.map(n => ({
|
||||
...n,
|
||||
x: width / 2 + (Math.random() - 0.5) * 200,
|
||||
y: height / 2 + (Math.random() - 0.5) * 200,
|
||||
}));
|
||||
|
||||
const nodeMap = new Map(d3Nodes.map(n => [n.id, n]));
|
||||
|
||||
const d3Links: D3Link[] = data.edges
|
||||
.filter(e => nodeMap.has(e.source) && nodeMap.has(e.target))
|
||||
.map(e => ({
|
||||
id: e.id,
|
||||
source: nodeMap.get(e.source)!,
|
||||
target: nodeMap.get(e.target)!,
|
||||
relationType: e.relationType,
|
||||
role: e.role,
|
||||
}));
|
||||
|
||||
// Force simulation
|
||||
const simulation = d3.forceSimulation<D3Node>(d3Nodes)
|
||||
.force('link', d3.forceLink<D3Node, D3Link>(d3Links).id(d => d.id).distance(120))
|
||||
.force('charge', d3.forceManyBody().strength(-400))
|
||||
.force('center', d3.forceCenter(width / 2, height / 2))
|
||||
.force('collision', d3.forceCollide().radius(40));
|
||||
|
||||
// Links
|
||||
const link = container.append('g')
|
||||
.attr('class', 'links')
|
||||
.selectAll('line')
|
||||
.data(d3Links)
|
||||
.join('line')
|
||||
.attr('stroke', '#999')
|
||||
.attr('stroke-width', 2)
|
||||
.attr('stroke-opacity', 0.6)
|
||||
.attr('marker-end', 'url(#typedb-arrow)');
|
||||
|
||||
// Link labels
|
||||
const linkLabel = container.append('g')
|
||||
.attr('class', 'link-labels')
|
||||
.selectAll('text')
|
||||
.data(d3Links)
|
||||
.join('text')
|
||||
.attr('font-size', 9)
|
||||
.attr('fill', '#666')
|
||||
.attr('text-anchor', 'middle')
|
||||
.text(d => d.relationType);
|
||||
|
||||
// Nodes
|
||||
const node = container.append('g')
|
||||
.attr('class', 'nodes')
|
||||
.selectAll('circle')
|
||||
.data(d3Nodes)
|
||||
.join('circle')
|
||||
.attr('r', 15)
|
||||
.attr('fill', d => getNodeColor(d.entityType))
|
||||
.attr('stroke', '#fff')
|
||||
.attr('stroke-width', 2)
|
||||
.style('cursor', 'pointer')
|
||||
.on('click', (_event, d) => {
|
||||
onNodeClick?.({
|
||||
id: d.id,
|
||||
label: d.label,
|
||||
type: d.type,
|
||||
entityType: d.entityType,
|
||||
attributes: d.attributes,
|
||||
});
|
||||
})
|
||||
.call(d3.drag<SVGCircleElement, D3Node>()
|
||||
.on('start', (event, d) => {
|
||||
if (!event.active) simulation.alphaTarget(0.3).restart();
|
||||
d.fx = d.x;
|
||||
d.fy = d.y;
|
||||
})
|
||||
.on('drag', (event, d) => {
|
||||
d.fx = event.x;
|
||||
d.fy = event.y;
|
||||
})
|
||||
.on('end', (event, d) => {
|
||||
if (!event.active) simulation.alphaTarget(0);
|
||||
d.fx = null;
|
||||
d.fy = null;
|
||||
}) as any);
|
||||
|
||||
// Node labels
|
||||
const nodeLabel = container.append('g')
|
||||
.attr('class', 'node-labels')
|
||||
.selectAll('text')
|
||||
.data(d3Nodes)
|
||||
.join('text')
|
||||
.attr('font-size', 11)
|
||||
.attr('dx', 18)
|
||||
.attr('dy', 4)
|
||||
.attr('fill', '#333')
|
||||
.text(d => d.label.length > 20 ? d.label.substring(0, 17) + '...' : d.label);
|
||||
|
||||
// Simulation tick
|
||||
simulation.on('tick', () => {
|
||||
link
|
||||
.attr('x1', d => (d.source as D3Node).x!)
|
||||
.attr('y1', d => (d.source as D3Node).y!)
|
||||
.attr('x2', d => (d.target as D3Node).x!)
|
||||
.attr('y2', d => (d.target as D3Node).y!);
|
||||
|
||||
linkLabel
|
||||
.attr('x', d => ((d.source as D3Node).x! + (d.target as D3Node).x!) / 2)
|
||||
.attr('y', d => ((d.source as D3Node).y! + (d.target as D3Node).y!) / 2 - 5);
|
||||
|
||||
node
|
||||
.attr('cx', d => d.x!)
|
||||
.attr('cy', d => d.y!);
|
||||
|
||||
nodeLabel
|
||||
.attr('x', d => d.x!)
|
||||
.attr('y', d => d.y!);
|
||||
});
|
||||
|
||||
// Zoom behavior
|
||||
const zoom = d3.zoom<SVGSVGElement, unknown>()
|
||||
.scaleExtent([0.1, 4])
|
||||
.on('zoom', (event) => {
|
||||
container.attr('transform', event.transform);
|
||||
});
|
||||
|
||||
svg.call(zoom as any);
|
||||
|
||||
}, [data, width, height, getNodeColor, onNodeClick]);
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={svgRef}
|
||||
width={width}
|
||||
height={height}
|
||||
style={{ background: '#fafafa', borderRadius: '8px', border: '1px solid #e0e0e0' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Panel Component
|
||||
// ============================================================================
|
||||
|
||||
export function TypeDBPanel({ compact = false }: TypeDBPanelProps) {
|
||||
const { language } = useLanguage();
|
||||
const t = (key: keyof typeof TEXT) => TEXT[key][language];
|
||||
|
|
@ -58,14 +292,23 @@ export function TypeDBPanel({ compact = false }: TypeDBPanelProps) {
|
|||
refresh,
|
||||
executeQuery,
|
||||
getSchema,
|
||||
getGraphData,
|
||||
} = useTypeDB();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'entities' | 'relations' | 'attributes' | 'query' | 'schema'>('entities');
|
||||
const [activeTab, setActiveTab] = useState<'explore' | 'entities' | 'relations' | 'attributes' | 'query' | 'schema'>('explore');
|
||||
const [query, setQuery] = useState('match $x isa entity; get $x; limit 10;');
|
||||
const [queryResult, setQueryResult] = useState<string | null>(null);
|
||||
const [schemaContent, setSchemaContent] = useState<string | null>(null);
|
||||
const [isQueryRunning, setIsQueryRunning] = useState(false);
|
||||
|
||||
// Explore tab state
|
||||
const [selectedEntityType, setSelectedEntityType] = useState<string | null>(null);
|
||||
const [exploreViewMode, setExploreViewMode] = useState<'graph' | 'table'>('table');
|
||||
const [graphData, setGraphData] = useState<TypeDBGraphData | null>(null);
|
||||
const [isLoadingData, setIsLoadingData] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedNode, setSelectedNode] = useState<TypeDBGraphNode | null>(null);
|
||||
|
||||
const handleRunQuery = async () => {
|
||||
setIsQueryRunning(true);
|
||||
setQueryResult(null);
|
||||
|
|
@ -89,12 +332,73 @@ export function TypeDBPanel({ compact = false }: TypeDBPanelProps) {
|
|||
}
|
||||
};
|
||||
|
||||
const handleLoadEntityData = async (entityType: string) => {
|
||||
setSelectedEntityType(entityType);
|
||||
setIsLoadingData(true);
|
||||
setGraphData(null);
|
||||
setSelectedNode(null);
|
||||
try {
|
||||
const data = await getGraphData(entityType, 1, 100);
|
||||
setGraphData(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load graph data:', err);
|
||||
} finally {
|
||||
setIsLoadingData(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportCsv = () => {
|
||||
if (!graphData?.nodes.length) return;
|
||||
|
||||
const headers = ['id', 'label', 'entityType', 'attributes'];
|
||||
const rows = graphData.nodes.map(n => [
|
||||
n.id,
|
||||
n.label,
|
||||
n.entityType,
|
||||
JSON.stringify(n.attributes),
|
||||
]);
|
||||
|
||||
const csv = [headers.join(','), ...rows.map(r => r.map(v => `"${v}"`).join(','))].join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `typedb_${selectedEntityType}_export.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleExportJson = () => {
|
||||
if (!graphData?.nodes.length) return;
|
||||
|
||||
const json = JSON.stringify(graphData, null, 2);
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `typedb_${selectedEntityType}_export.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// Filter nodes by search term
|
||||
const filteredNodes = graphData?.nodes.filter(n => {
|
||||
if (!searchTerm) return true;
|
||||
const term = searchTerm.toLowerCase();
|
||||
return (
|
||||
n.label.toLowerCase().includes(term) ||
|
||||
n.id.toLowerCase().includes(term) ||
|
||||
n.entityType.toLowerCase().includes(term) ||
|
||||
Object.values(n.attributes).some(v => String(v).toLowerCase().includes(term))
|
||||
);
|
||||
}) ?? [];
|
||||
|
||||
// Compact view for comparison grid
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="db-panel compact typedb-panel">
|
||||
<div className="panel-header">
|
||||
<span className="panel-icon">🧠</span>
|
||||
<span className="panel-icon">🔷</span>
|
||||
<h3>TypeDB</h3>
|
||||
<span className={`status-badge ${status.isConnected ? 'connected' : 'disconnected'}`}>
|
||||
{isLoading ? t('loading') : status.isConnected ? t('connected') : t('disconnected')}
|
||||
|
|
@ -125,7 +429,7 @@ export function TypeDBPanel({ compact = false }: TypeDBPanelProps) {
|
|||
{/* Header */}
|
||||
<div className="panel-header-full">
|
||||
<div className="header-info">
|
||||
<span className="panel-icon-large">🧠</span>
|
||||
<span className="panel-icon-large">🔷</span>
|
||||
<div>
|
||||
<h2>{t('title')}</h2>
|
||||
<p>{t('description')}</p>
|
||||
|
|
@ -180,6 +484,12 @@ export function TypeDBPanel({ compact = false }: TypeDBPanelProps) {
|
|||
|
||||
{/* Tabs */}
|
||||
<div className="panel-tabs">
|
||||
<button
|
||||
className={`tab-btn ${activeTab === 'explore' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('explore')}
|
||||
>
|
||||
{t('explore')}
|
||||
</button>
|
||||
<button
|
||||
className={`tab-btn ${activeTab === 'entities' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('entities')}
|
||||
|
|
@ -214,6 +524,161 @@ export function TypeDBPanel({ compact = false }: TypeDBPanelProps) {
|
|||
|
||||
{/* Tab Content */}
|
||||
<div className="panel-content">
|
||||
{/* EXPLORE TAB */}
|
||||
{activeTab === 'explore' && (
|
||||
<div className="explore-section">
|
||||
{/* Entity Type Selector */}
|
||||
<div className="explore-header">
|
||||
<div className="entity-type-grid">
|
||||
{stats?.entityTypes
|
||||
.filter(et => !et.abstract && et.count > 0)
|
||||
.map(et => (
|
||||
<button
|
||||
key={et.label}
|
||||
className={`entity-type-card ${selectedEntityType === et.label ? 'selected' : ''}`}
|
||||
onClick={() => handleLoadEntityData(et.label)}
|
||||
>
|
||||
<span className="entity-type-name">{et.label}</span>
|
||||
<span className="entity-type-count">{et.count.toLocaleString()} {t('instances')}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data Viewer */}
|
||||
{selectedEntityType && (
|
||||
<div className="data-viewer">
|
||||
<div className="data-viewer-toolbar">
|
||||
<div className="toolbar-left">
|
||||
<h3>{selectedEntityType}</h3>
|
||||
<span className="instance-count">
|
||||
{isLoadingData ? t('loading') : `${filteredNodes.length} ${t('instances')}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="toolbar-right">
|
||||
<input
|
||||
type="text"
|
||||
className="search-input"
|
||||
placeholder={t('search')}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
<div className="view-toggle">
|
||||
<button
|
||||
className={`toggle-btn ${exploreViewMode === 'table' ? 'active' : ''}`}
|
||||
onClick={() => setExploreViewMode('table')}
|
||||
>
|
||||
{t('tableView')}
|
||||
</button>
|
||||
<button
|
||||
className={`toggle-btn ${exploreViewMode === 'graph' ? 'active' : ''}`}
|
||||
onClick={() => setExploreViewMode('graph')}
|
||||
>
|
||||
{t('graphView')}
|
||||
</button>
|
||||
</div>
|
||||
<button className="export-btn" onClick={handleExportCsv} disabled={!graphData?.nodes.length}>
|
||||
{t('exportCsv')}
|
||||
</button>
|
||||
<button className="export-btn" onClick={handleExportJson} disabled={!graphData?.nodes.length}>
|
||||
{t('exportJson')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoadingData ? (
|
||||
<div className="loading-container">
|
||||
<div className="loading-spinner" />
|
||||
<p>{t('loading')}</p>
|
||||
</div>
|
||||
) : !graphData?.nodes.length ? (
|
||||
<p className="empty-message">{t('noInstances')}</p>
|
||||
) : exploreViewMode === 'table' ? (
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Label</th>
|
||||
<th>Type</th>
|
||||
<th>{t('attributes')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredNodes.map(node => (
|
||||
<tr
|
||||
key={node.id}
|
||||
onClick={() => setSelectedNode(node)}
|
||||
className={selectedNode?.id === node.id ? 'selected' : ''}
|
||||
>
|
||||
<td><code>{node.id.substring(0, 12)}...</code></td>
|
||||
<td>{node.label}</td>
|
||||
<td>{node.entityType}</td>
|
||||
<td>
|
||||
{Object.entries(node.attributes).length > 0 ? (
|
||||
<span className="attr-preview">
|
||||
{Object.entries(node.attributes).slice(0, 3).map(([k, v]) => (
|
||||
<span key={k} className="attr-badge">{k}: {String(v).substring(0, 20)}</span>
|
||||
))}
|
||||
{Object.keys(node.attributes).length > 3 && (
|
||||
<span className="attr-more">+{Object.keys(node.attributes).length - 3} more</span>
|
||||
)}
|
||||
</span>
|
||||
) : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="graph-container">
|
||||
<GraphVisualization
|
||||
data={{ nodes: filteredNodes, edges: graphData.edges }}
|
||||
width={800}
|
||||
height={500}
|
||||
onNodeClick={setSelectedNode}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Node Detail Panel */}
|
||||
{selectedNode && (
|
||||
<div className="node-detail-panel">
|
||||
<div className="node-detail-header">
|
||||
<h4>{selectedNode.label}</h4>
|
||||
<button className="close-btn" onClick={() => setSelectedNode(null)}>×</button>
|
||||
</div>
|
||||
<div className="node-detail-content">
|
||||
<p><strong>ID:</strong> <code>{selectedNode.id}</code></p>
|
||||
<p><strong>Type:</strong> {selectedNode.entityType}</p>
|
||||
<div className="attributes-list">
|
||||
<strong>{t('attributes')}:</strong>
|
||||
{Object.entries(selectedNode.attributes).length > 0 ? (
|
||||
<ul>
|
||||
{Object.entries(selectedNode.attributes).map(([key, value]) => (
|
||||
<li key={key}>
|
||||
<span className="attr-key">{key}:</span>
|
||||
<span className="attr-value">{String(value)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="no-attrs">No attributes</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!selectedEntityType && stats?.entityTypes.length && (
|
||||
<p className="help-text">{t('clickToExplore')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'entities' && (
|
||||
<div className="types-list">
|
||||
{!stats?.entityTypes.length ? (
|
||||
|
|
|
|||
|
|
@ -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?: {
|
||||
|
|
|
|||
467
frontend/src/components/map/TimelineSlider.css
Normal file
467
frontend/src/components/map/TimelineSlider.css
Normal file
|
|
@ -0,0 +1,467 @@
|
|||
/**
|
||||
* TimelineSlider.css
|
||||
*
|
||||
* Styles for the heritage institution timeline filter component.
|
||||
* Positioned at the bottom of the map, above the map controls.
|
||||
* Supports light and dark themes.
|
||||
*/
|
||||
|
||||
/* Base container */
|
||||
.timeline-slider {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: calc(100% - 380px); /* Account for sidebar width */
|
||||
max-width: 900px;
|
||||
min-width: 400px;
|
||||
background: var(--card-background, #ffffff);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
padding: 16px 20px;
|
||||
z-index: 1000;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
[data-theme="dark"] .timeline-slider,
|
||||
.dark .timeline-slider {
|
||||
background: var(--card-background, #1e293b);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* Active state - slight highlight */
|
||||
.timeline-slider--active {
|
||||
border: 2px solid var(--primary-color, #3b82f6);
|
||||
}
|
||||
|
||||
/* Collapsed state */
|
||||
.timeline-slider--collapsed {
|
||||
width: auto;
|
||||
min-width: auto;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
/* Header section */
|
||||
.timeline-slider__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.timeline-slider__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary, #1f2937);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .timeline-slider__title,
|
||||
.dark .timeline-slider__title {
|
||||
color: var(--text-primary, #f1f5f9);
|
||||
}
|
||||
|
||||
.timeline-slider__icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.timeline-slider__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Toggle switch */
|
||||
.timeline-slider__toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.timeline-slider__toggle input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.timeline-slider__toggle-slider {
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
background: var(--border-color, #d1d5db);
|
||||
border-radius: 10px;
|
||||
position: relative;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.timeline-slider__toggle-slider::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.2s ease;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.timeline-slider__toggle input:checked + .timeline-slider__toggle-slider {
|
||||
background: var(--primary-color, #3b82f6);
|
||||
}
|
||||
|
||||
.timeline-slider__toggle input:checked + .timeline-slider__toggle-slider::after {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
.timeline-slider__toggle-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
min-width: 24px;
|
||||
}
|
||||
|
||||
/* Collapse/Expand buttons */
|
||||
.timeline-slider__collapse-btn,
|
||||
.timeline-slider__expand-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.timeline-slider__collapse-btn:hover,
|
||||
.timeline-slider__expand-btn:hover {
|
||||
background: var(--hover-background, #f3f4f6);
|
||||
color: var(--text-primary, #1f2937);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .timeline-slider__collapse-btn:hover,
|
||||
[data-theme="dark"] .timeline-slider__expand-btn:hover,
|
||||
.dark .timeline-slider__collapse-btn:hover,
|
||||
.dark .timeline-slider__expand-btn:hover {
|
||||
background: var(--hover-background, #374151);
|
||||
color: var(--text-primary, #f1f5f9);
|
||||
}
|
||||
|
||||
.timeline-slider__expand-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.timeline-slider__expand-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.timeline-slider__expand-text {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Stats row */
|
||||
.timeline-slider__stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
.timeline-slider__stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.timeline-slider__stat--defunct {
|
||||
color: var(--warning-color, #f59e0b);
|
||||
}
|
||||
|
||||
.timeline-slider__range-display {
|
||||
margin-left: auto;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary, #1f2937);
|
||||
background: var(--hover-background, #f3f4f6);
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .timeline-slider__range-display,
|
||||
.dark .timeline-slider__range-display {
|
||||
color: var(--text-primary, #f1f5f9);
|
||||
background: var(--hover-background, #374151);
|
||||
}
|
||||
|
||||
/* Presets row */
|
||||
.timeline-slider__presets {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.timeline-slider__preset {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--border-color, #d1d5db);
|
||||
background: transparent;
|
||||
border-radius: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
.timeline-slider__preset:hover:not(:disabled) {
|
||||
border-color: var(--primary-color, #3b82f6);
|
||||
color: var(--primary-color, #3b82f6);
|
||||
}
|
||||
|
||||
.timeline-slider__preset:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.timeline-slider__preset--active {
|
||||
background: var(--primary-color, #3b82f6);
|
||||
border-color: var(--primary-color, #3b82f6);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.timeline-slider__preset--active:hover {
|
||||
background: var(--primary-hover, #2563eb);
|
||||
border-color: var(--primary-hover, #2563eb);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Track container - holds histogram and slider */
|
||||
.timeline-slider__track-container {
|
||||
position: relative;
|
||||
padding-top: 60px; /* Space for histogram */
|
||||
}
|
||||
|
||||
/* Histogram bars */
|
||||
.timeline-slider__histogram {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 50px;
|
||||
display: flex;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.timeline-slider__histogram-bar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 1.2%;
|
||||
background: var(--border-color, #d1d5db);
|
||||
border-radius: 2px 2px 0 0;
|
||||
transition: background 0.2s ease, opacity 0.2s ease;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.timeline-slider__histogram-bar--in-range {
|
||||
background: var(--primary-color, #3b82f6);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.timeline-slider__histogram-bar--dissolved {
|
||||
background: linear-gradient(to top, var(--warning-color, #f59e0b) 30%, var(--primary-color, #3b82f6) 30%);
|
||||
}
|
||||
|
||||
.timeline-slider__histogram-bar--in-range.timeline-slider__histogram-bar--dissolved {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Slider track */
|
||||
.timeline-slider__track {
|
||||
position: relative;
|
||||
height: 8px;
|
||||
background: var(--border-color, #e5e7eb);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .timeline-slider__track,
|
||||
.dark .timeline-slider__track {
|
||||
background: var(--border-color, #374151);
|
||||
}
|
||||
|
||||
/* Selected range highlight */
|
||||
.timeline-slider__range-highlight {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
background: var(--primary-color, #3b82f6);
|
||||
border-radius: 4px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Slider handles */
|
||||
.timeline-slider__handle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: var(--primary-color, #3b82f6);
|
||||
border: 3px solid white;
|
||||
border-radius: 50%;
|
||||
cursor: grab;
|
||||
z-index: 10;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||
transition: transform 0.1s ease, box-shadow 0.1s ease;
|
||||
}
|
||||
|
||||
.timeline-slider__handle:hover {
|
||||
transform: translate(-50%, -50%) scale(1.1);
|
||||
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.timeline-slider__handle--dragging {
|
||||
cursor: grabbing;
|
||||
transform: translate(-50%, -50%) scale(1.15);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.timeline-slider__handle:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
/* Handle labels */
|
||||
.timeline-slider__handle-label {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--text-primary, #1f2937);
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.timeline-slider__handle-label::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 4px solid transparent;
|
||||
border-top-color: var(--text-primary, #1f2937);
|
||||
}
|
||||
|
||||
/* Axis labels */
|
||||
.timeline-slider__axis {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 1200px) {
|
||||
.timeline-slider {
|
||||
width: calc(100% - 340px);
|
||||
min-width: 350px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.timeline-slider {
|
||||
width: calc(100% - 40px);
|
||||
min-width: 300px;
|
||||
left: 20px;
|
||||
transform: none;
|
||||
bottom: 60px; /* Above mobile controls */
|
||||
}
|
||||
|
||||
.timeline-slider__presets {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.timeline-slider__preset {
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.timeline-slider__stats {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.timeline-slider__range-display {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.timeline-slider {
|
||||
padding: 12px 16px;
|
||||
bottom: 80px;
|
||||
}
|
||||
|
||||
.timeline-slider__header {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.timeline-slider__presets {
|
||||
overflow-x: auto;
|
||||
flex-wrap: nowrap;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.timeline-slider__histogram {
|
||||
height: 35px;
|
||||
}
|
||||
|
||||
.timeline-slider__track-container {
|
||||
padding-top: 45px;
|
||||
}
|
||||
|
||||
.timeline-slider__axis span:nth-child(2),
|
||||
.timeline-slider__axis span:nth-child(4) {
|
||||
display: none; /* Hide some axis labels on mobile */
|
||||
}
|
||||
}
|
||||
|
||||
/* Fullscreen mode adjustments */
|
||||
.fullscreen-active .timeline-slider {
|
||||
width: calc(100% - 60px);
|
||||
left: 30px;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* When sidebar is collapsed on mobile */
|
||||
.is-mobile .timeline-slider {
|
||||
width: calc(100% - 40px);
|
||||
left: 20px;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Animation for collapse/expand */
|
||||
.timeline-slider--collapsed .timeline-slider__expand-btn {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
385
frontend/src/components/map/TimelineSlider.tsx
Normal file
385
frontend/src/components/map/TimelineSlider.tsx
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
/**
|
||||
* TimelineSlider.tsx
|
||||
*
|
||||
* A horizontal timeline slider for filtering heritage institutions by temporal extent.
|
||||
* Displays a time range selector that allows users to see institutions that existed
|
||||
* during a specific period.
|
||||
*
|
||||
* Features:
|
||||
* - Dual-handle range slider (start year to end year)
|
||||
* - Visual histogram showing institution density by decade
|
||||
* - Quick presets (Ancient, Medieval, Modern, Contemporary)
|
||||
* - Shows count of institutions visible in selected range
|
||||
* - Collapsible to minimize map obstruction
|
||||
* - Highlights destroyed/defunct institutions
|
||||
*
|
||||
* Uses CIDOC-CRM E52_Time-Span pattern conceptually:
|
||||
* - Institution visible if: founding_year <= selectedEnd AND (dissolution_year >= selectedStart OR is_operational)
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo, useCallback, useRef, useEffect } from 'react';
|
||||
import './TimelineSlider.css';
|
||||
|
||||
export interface TemporalData {
|
||||
founding_year?: number;
|
||||
founding_decade?: number;
|
||||
dissolution_year?: number; // Extracted from temporal_extent.dissolution_date
|
||||
is_operational?: boolean;
|
||||
is_defunct?: boolean;
|
||||
}
|
||||
|
||||
export interface TimelineSliderProps {
|
||||
/** All institutions with temporal data */
|
||||
institutions: Array<{ temporal?: TemporalData; name: string }>;
|
||||
/** Current selected year range [start, end] */
|
||||
selectedRange: [number, number];
|
||||
/** Called when range changes */
|
||||
onRangeChange: (range: [number, number]) => void;
|
||||
/** Whether the timeline filter is active */
|
||||
isActive: boolean;
|
||||
/** Toggle timeline filter on/off */
|
||||
onToggleActive: () => void;
|
||||
/** Translation function */
|
||||
t: (nl: string, en: string) => string;
|
||||
/** Current language */
|
||||
language: 'nl' | 'en';
|
||||
}
|
||||
|
||||
// Timeline bounds - covers most heritage institutions
|
||||
const MIN_YEAR = 1400;
|
||||
const MAX_YEAR = new Date().getFullYear();
|
||||
const DECADE_WIDTH = 10;
|
||||
|
||||
// Quick preset ranges
|
||||
const PRESETS = [
|
||||
{ id: 'all', nl: 'Alles', en: 'All', range: [MIN_YEAR, MAX_YEAR] as [number, number] },
|
||||
{ id: 'medieval', nl: 'Middeleeuwen', en: 'Medieval', range: [1400, 1500] as [number, number] },
|
||||
{ id: 'early-modern', nl: 'Vroegmodern', en: 'Early Modern', range: [1500, 1800] as [number, number] },
|
||||
{ id: 'modern', nl: '19e eeuw', en: '19th Century', range: [1800, 1900] as [number, number] },
|
||||
{ id: 'contemporary', nl: '20e eeuw+', en: '20th Century+', range: [1900, MAX_YEAR] as [number, number] },
|
||||
];
|
||||
|
||||
export const TimelineSlider: React.FC<TimelineSliderProps> = ({
|
||||
institutions,
|
||||
selectedRange,
|
||||
onRangeChange,
|
||||
isActive,
|
||||
onToggleActive,
|
||||
t,
|
||||
language,
|
||||
}) => {
|
||||
// DEBUG: Log props on every render
|
||||
console.log(`[TimelineSlider] Render - isActive=${isActive}, range=[${selectedRange[0]}, ${selectedRange[1]}], institutions=${institutions.length}`);
|
||||
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState<'start' | 'end' | null>(null);
|
||||
const sliderRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Calculate decade histogram - count institutions founded per decade
|
||||
const histogram = useMemo(() => {
|
||||
const decades: Record<number, { founded: number; active: number; dissolved: number }> = {};
|
||||
|
||||
// Initialize all decades
|
||||
for (let year = MIN_YEAR; year <= MAX_YEAR; year += DECADE_WIDTH) {
|
||||
decades[year] = { founded: 0, active: 0, dissolved: 0 };
|
||||
}
|
||||
|
||||
institutions.forEach(inst => {
|
||||
const temporal = inst.temporal;
|
||||
if (!temporal?.founding_year && !temporal?.founding_decade) return;
|
||||
|
||||
const foundingYear = temporal.founding_year || (temporal.founding_decade ? temporal.founding_decade : null);
|
||||
if (!foundingYear) return;
|
||||
|
||||
const foundingDecade = Math.floor(foundingYear / 10) * 10;
|
||||
if (foundingDecade >= MIN_YEAR && foundingDecade <= MAX_YEAR) {
|
||||
decades[foundingDecade].founded++;
|
||||
}
|
||||
|
||||
// Count as dissolved if has dissolution year
|
||||
if (temporal.dissolution_year || temporal.is_defunct) {
|
||||
const dissolutionYear = temporal.dissolution_year;
|
||||
if (dissolutionYear) {
|
||||
const dissolutionDecade = Math.floor(dissolutionYear / 10) * 10;
|
||||
if (dissolutionDecade >= MIN_YEAR && dissolutionDecade <= MAX_YEAR) {
|
||||
decades[dissolutionDecade].dissolved++;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate max for normalization
|
||||
const maxFounded = Math.max(...Object.values(decades).map(d => d.founded), 1);
|
||||
|
||||
return { decades, maxFounded };
|
||||
}, [institutions]);
|
||||
|
||||
// Calculate visible institutions count in selected range
|
||||
const visibleCount = useMemo(() => {
|
||||
if (!isActive) return institutions.length;
|
||||
|
||||
return institutions.filter(inst => {
|
||||
const temporal = inst.temporal;
|
||||
|
||||
const foundingYear = temporal?.founding_year || temporal?.founding_decade;
|
||||
const dissolutionYear = temporal?.dissolution_year;
|
||||
|
||||
// If no temporal data at all, HIDE the institution when timeline filter is active
|
||||
if (!foundingYear && !dissolutionYear) return false;
|
||||
|
||||
// Institution is visible if:
|
||||
// 1. Founded before or during the selected end year
|
||||
// 2. AND (still operational OR dissolved after the selected start year)
|
||||
if (foundingYear && foundingYear > selectedRange[1]) return false;
|
||||
if (dissolutionYear && dissolutionYear < selectedRange[0]) return false;
|
||||
|
||||
return true;
|
||||
}).length;
|
||||
}, [institutions, selectedRange, isActive]);
|
||||
|
||||
// Count of defunct/destroyed institutions in range
|
||||
const defunctCount = useMemo(() => {
|
||||
if (!isActive) return 0;
|
||||
|
||||
return institutions.filter(inst => {
|
||||
const temporal = inst.temporal;
|
||||
if (!temporal?.is_defunct && !temporal?.dissolution_year) return false;
|
||||
|
||||
const foundingYear = temporal.founding_year || temporal.founding_decade;
|
||||
const dissolutionYear = temporal.dissolution_year;
|
||||
|
||||
if (foundingYear && foundingYear > selectedRange[1]) return false;
|
||||
if (dissolutionYear && dissolutionYear < selectedRange[0]) return false;
|
||||
|
||||
return true;
|
||||
}).length;
|
||||
}, [institutions, selectedRange, isActive]);
|
||||
|
||||
// Convert pixel position to year
|
||||
const positionToYear = useCallback((clientX: number) => {
|
||||
if (!sliderRef.current) return selectedRange[0];
|
||||
const rect = sliderRef.current.getBoundingClientRect();
|
||||
const percentage = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||
return Math.round(MIN_YEAR + percentage * (MAX_YEAR - MIN_YEAR));
|
||||
}, [selectedRange]);
|
||||
|
||||
// Handle mouse/touch events for dragging
|
||||
const handleMouseDown = useCallback((handle: 'start' | 'end') => (e: React.MouseEvent | React.TouchEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(handle);
|
||||
}, []);
|
||||
|
||||
const handleMouseMove = useCallback((e: MouseEvent | TouchEvent) => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
|
||||
const year = positionToYear(clientX);
|
||||
|
||||
if (isDragging === 'start') {
|
||||
onRangeChange([Math.min(year, selectedRange[1] - 10), selectedRange[1]]);
|
||||
} else {
|
||||
onRangeChange([selectedRange[0], Math.max(year, selectedRange[0] + 10)]);
|
||||
}
|
||||
}, [isDragging, positionToYear, selectedRange, onRangeChange]);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsDragging(null);
|
||||
}, []);
|
||||
|
||||
// Global event listeners for dragging
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
window.addEventListener('touchmove', handleMouseMove);
|
||||
window.addEventListener('touchend', handleMouseUp);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
window.removeEventListener('touchmove', handleMouseMove);
|
||||
window.removeEventListener('touchend', handleMouseUp);
|
||||
};
|
||||
}, [isDragging, handleMouseMove, handleMouseUp]);
|
||||
|
||||
// Calculate handle positions as percentages
|
||||
const startPercent = ((selectedRange[0] - MIN_YEAR) / (MAX_YEAR - MIN_YEAR)) * 100;
|
||||
const endPercent = ((selectedRange[1] - MIN_YEAR) / (MAX_YEAR - MIN_YEAR)) * 100;
|
||||
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<div className="timeline-slider timeline-slider--collapsed">
|
||||
<button
|
||||
className="timeline-slider__expand-btn"
|
||||
onClick={() => setIsCollapsed(false)}
|
||||
title={t('Tijdlijn uitvouwen', 'Expand timeline')}
|
||||
>
|
||||
<span className="timeline-slider__expand-icon">📅</span>
|
||||
<span className="timeline-slider__expand-text">
|
||||
{isActive
|
||||
? `${selectedRange[0]} - ${selectedRange[1]}`
|
||||
: t('Tijdlijn', 'Timeline')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`timeline-slider ${isActive ? 'timeline-slider--active' : ''}`}>
|
||||
{/* Header */}
|
||||
<div className="timeline-slider__header">
|
||||
<div className="timeline-slider__title">
|
||||
<span className="timeline-slider__icon">📅</span>
|
||||
<span>{t('Tijdlijn', 'Timeline')}</span>
|
||||
</div>
|
||||
|
||||
<div className="timeline-slider__controls">
|
||||
{/* Toggle switch */}
|
||||
<label className="timeline-slider__toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isActive}
|
||||
onChange={(e) => {
|
||||
console.log(`[TimelineSlider] Checkbox onChange fired! checked=${e.target.checked}, calling onToggleActive`);
|
||||
onToggleActive();
|
||||
}}
|
||||
/>
|
||||
<span className="timeline-slider__toggle-slider"></span>
|
||||
<span className="timeline-slider__toggle-label">
|
||||
{isActive ? t('Aan', 'On') : t('Uit', 'Off')}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* Collapse button */}
|
||||
<button
|
||||
className="timeline-slider__collapse-btn"
|
||||
onClick={() => setIsCollapsed(true)}
|
||||
title={t('Tijdlijn inklappen', 'Collapse timeline')}
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="timeline-slider__stats">
|
||||
<span className="timeline-slider__stat">
|
||||
{visibleCount.toLocaleString()} {t('zichtbaar', 'visible')}
|
||||
</span>
|
||||
{isActive && defunctCount > 0 && (
|
||||
<span className="timeline-slider__stat timeline-slider__stat--defunct">
|
||||
⚠️ {defunctCount} {t('opgeheven', 'defunct')}
|
||||
</span>
|
||||
)}
|
||||
<span className="timeline-slider__range-display">
|
||||
{selectedRange[0]} — {selectedRange[1]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Presets */}
|
||||
<div className="timeline-slider__presets">
|
||||
{PRESETS.map(preset => (
|
||||
<button
|
||||
key={preset.id}
|
||||
className={`timeline-slider__preset ${
|
||||
selectedRange[0] === preset.range[0] && selectedRange[1] === preset.range[1]
|
||||
? 'timeline-slider__preset--active'
|
||||
: ''
|
||||
}`}
|
||||
onClick={() => onRangeChange(preset.range)}
|
||||
disabled={!isActive}
|
||||
>
|
||||
{language === 'nl' ? preset.nl : preset.en}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Histogram and Slider */}
|
||||
<div className="timeline-slider__track-container">
|
||||
{/* Histogram bars */}
|
||||
<div className="timeline-slider__histogram">
|
||||
{Object.entries(histogram.decades).map(([decadeStr, data]) => {
|
||||
const decade = parseInt(decadeStr);
|
||||
const height = (data.founded / histogram.maxFounded) * 100;
|
||||
const left = ((decade - MIN_YEAR) / (MAX_YEAR - MIN_YEAR)) * 100;
|
||||
const isInRange = decade >= selectedRange[0] && decade <= selectedRange[1];
|
||||
const hasDissolved = data.dissolved > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={decade}
|
||||
className={`timeline-slider__histogram-bar ${isInRange ? 'timeline-slider__histogram-bar--in-range' : ''} ${hasDissolved ? 'timeline-slider__histogram-bar--dissolved' : ''}`}
|
||||
style={{
|
||||
left: `${left}%`,
|
||||
height: `${Math.max(height, 2)}%`,
|
||||
}}
|
||||
title={`${decade}s: ${data.founded} ${t('opgericht', 'founded')}${hasDissolved ? `, ${data.dissolved} ${t('opgeheven', 'dissolved')}` : ''}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Slider track */}
|
||||
<div
|
||||
className="timeline-slider__track"
|
||||
ref={sliderRef}
|
||||
>
|
||||
{/* Selected range highlight */}
|
||||
<div
|
||||
className="timeline-slider__range-highlight"
|
||||
style={{
|
||||
left: `${startPercent}%`,
|
||||
width: `${endPercent - startPercent}%`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Start handle */}
|
||||
<div
|
||||
className={`timeline-slider__handle timeline-slider__handle--start ${isDragging === 'start' ? 'timeline-slider__handle--dragging' : ''}`}
|
||||
style={{ left: `${startPercent}%` }}
|
||||
onMouseDown={handleMouseDown('start')}
|
||||
onTouchStart={handleMouseDown('start')}
|
||||
role="slider"
|
||||
aria-label={t('Startjaar', 'Start year')}
|
||||
aria-valuenow={selectedRange[0]}
|
||||
aria-valuemin={MIN_YEAR}
|
||||
aria-valuemax={MAX_YEAR}
|
||||
tabIndex={0}
|
||||
>
|
||||
<span className="timeline-slider__handle-label">{selectedRange[0]}</span>
|
||||
</div>
|
||||
|
||||
{/* End handle */}
|
||||
<div
|
||||
className={`timeline-slider__handle timeline-slider__handle--end ${isDragging === 'end' ? 'timeline-slider__handle--dragging' : ''}`}
|
||||
style={{ left: `${endPercent}%` }}
|
||||
onMouseDown={handleMouseDown('end')}
|
||||
onTouchStart={handleMouseDown('end')}
|
||||
role="slider"
|
||||
aria-label={t('Eindjaar', 'End year')}
|
||||
aria-valuenow={selectedRange[1]}
|
||||
aria-valuemin={MIN_YEAR}
|
||||
aria-valuemax={MAX_YEAR}
|
||||
tabIndex={0}
|
||||
>
|
||||
<span className="timeline-slider__handle-label">{selectedRange[1]}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Axis labels */}
|
||||
<div className="timeline-slider__axis">
|
||||
<span>{MIN_YEAR}</span>
|
||||
<span>1600</span>
|
||||
<span>1800</span>
|
||||
<span>1900</span>
|
||||
<span>2000</span>
|
||||
<span>{MAX_YEAR}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimelineSlider;
|
||||
|
|
@ -9,6 +9,13 @@ import { useState, useEffect, useCallback } from 'react';
|
|||
import { useDuckLake } from './useDuckLake';
|
||||
import type { Institution } from '../components/map/InstitutionInfoPanel';
|
||||
|
||||
// TypeScript declaration for debug counter on window
|
||||
declare global {
|
||||
interface Window {
|
||||
_temporalDebugCount?: number;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export Institution type for convenience
|
||||
export type { Institution };
|
||||
|
||||
|
|
@ -37,6 +44,7 @@ const TYPE_COLORS: Record<string, string> = {
|
|||
|
||||
// Map full type names to single-letter codes
|
||||
// DuckLake stores full names like "MUSEUM", but frontend expects "M"
|
||||
// Also handles CH-Annotator entity codes (GRP.HER.*)
|
||||
const TYPE_NAME_TO_CODE: Record<string, string> = {
|
||||
'GALLERY': 'G',
|
||||
'LIBRARY': 'L',
|
||||
|
|
@ -65,6 +73,13 @@ const TYPE_NAME_TO_CODE: Record<string, string> = {
|
|||
'DIGITAL_PLATFORM': 'D',
|
||||
'NGO': 'N',
|
||||
'TASTE_SMELL': 'T',
|
||||
// CH-Annotator entity codes (GRP.HER.*)
|
||||
'GRP.HER': 'U', // Generic heritage group -> Unknown (needs classification)
|
||||
'GRP.HER.GAL': 'G', // Gallery
|
||||
'GRP.HER.LIB': 'L', // Library
|
||||
'GRP.HER.ARC': 'A', // Archive
|
||||
'GRP.HER.MUS': 'M', // Museum
|
||||
'GRP.HER.MIX': 'X', // Mixed
|
||||
};
|
||||
|
||||
// Convert org_type from DuckLake to single-letter code
|
||||
|
|
@ -145,7 +160,20 @@ const INSTITUTIONS_QUERY = `
|
|||
file_name,
|
||||
wikidata_enrichment_json,
|
||||
original_entry_json,
|
||||
service_area_json
|
||||
service_area_json,
|
||||
-- Temporal data columns
|
||||
timespan_begin,
|
||||
timespan_end,
|
||||
timespan_json,
|
||||
time_of_destruction_json,
|
||||
conflict_status_json,
|
||||
destruction_date,
|
||||
founding_date,
|
||||
dissolution_date,
|
||||
temporal_extent_json,
|
||||
wikidata_inception,
|
||||
-- YouTube enrichment data
|
||||
youtube_enrichment_json
|
||||
FROM heritage.custodians_raw
|
||||
WHERE latitude IS NOT NULL
|
||||
AND longitude IS NOT NULL
|
||||
|
|
@ -400,6 +428,75 @@ interface ServiceAreaData {
|
|||
notes?: string;
|
||||
}
|
||||
|
||||
// Parse youtube_enrichment JSON (from YAML youtube_enrichment field)
|
||||
// Supports TWO structures:
|
||||
// 1. Dutch (nested): { channel: {...}, videos: [...] }
|
||||
// 2. Non-Dutch (flat): { channel_id: "...", title: "...", ... } (fields at root level)
|
||||
interface YouTubeEnrichmentData {
|
||||
// Nested structure (Dutch institutions)
|
||||
channel?: {
|
||||
channel_id?: string;
|
||||
channel_url?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
subscriber_count?: number | null;
|
||||
video_count?: number | null;
|
||||
view_count?: number | null;
|
||||
thumbnail_url?: string;
|
||||
published_at?: string;
|
||||
};
|
||||
videos?: Array<{
|
||||
video_id: string;
|
||||
video_url?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
published_at?: string;
|
||||
duration?: string;
|
||||
view_count?: number | null;
|
||||
like_count?: number | null;
|
||||
comment_count?: number | null;
|
||||
thumbnail_url?: string;
|
||||
comments?: Array<{
|
||||
author_display_name?: string;
|
||||
author?: string;
|
||||
text?: string;
|
||||
text_display?: string;
|
||||
like_count?: number;
|
||||
}>;
|
||||
has_transcript?: boolean;
|
||||
transcript_snippet?: string | null;
|
||||
}>;
|
||||
enrichment_date?: string;
|
||||
|
||||
// Flat structure (non-Dutch institutions: SA, ZW, VN, VE, PS, etc.)
|
||||
// These fields appear at the root level instead of nested in 'channel'
|
||||
channel_id?: string;
|
||||
channel_url?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
custom_url?: string;
|
||||
published_at?: string;
|
||||
country?: string;
|
||||
fetch_timestamp?: string;
|
||||
subscriber_count?: number | null;
|
||||
video_count?: number | null;
|
||||
view_count?: number | null;
|
||||
thumbnail_url?: string;
|
||||
llm_verification?: {
|
||||
is_match?: boolean;
|
||||
confidence?: number;
|
||||
reasoning?: string;
|
||||
agent?: string;
|
||||
verified?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// Reserved for future temporal data parsing:
|
||||
// - TimespanData: CIDOC-CRM E52_Time-Span (begin_of_the_begin, end_of_the_begin, begin_of_the_end, end_of_the_end)
|
||||
// - TimeOfDestructionData: conflict-related destruction info
|
||||
// - ConflictStatusData: operational status (destroyed, damaged, operational)
|
||||
// - TemporalExtentData: founding/dissolution dates and precision
|
||||
|
||||
// ============================================================================
|
||||
// Main Hook
|
||||
// ============================================================================
|
||||
|
|
@ -445,7 +542,11 @@ export function useDuckLakeInstitutions(): UseDuckLakeInstitutionsReturn {
|
|||
// 5: org_type, 6: wikidata_id, 7: google_rating, 8: google_total_ratings,
|
||||
// 9: formatted_address, 10: record_id, 11: google_maps_enrichment_json,
|
||||
// 12: identifiers_json, 13: genealogiewerkbalk_json, 14: file_name,
|
||||
// 15: wikidata_enrichment_json, 16: original_entry_json, 17: service_area_json
|
||||
// 15: wikidata_enrichment_json, 16: original_entry_json, 17: service_area_json,
|
||||
// 18: timespan_begin, 19: timespan_end, 20: timespan_json,
|
||||
// 21: time_of_destruction_json, 22: conflict_status_json, 23: destruction_date,
|
||||
// 24: founding_date, 25: dissolution_date, 26: temporal_extent_json, 27: wikidata_inception,
|
||||
// 28: youtube_enrichment_json
|
||||
|
||||
const lat = Number(row[0]);
|
||||
const lon = Number(row[1]);
|
||||
|
|
@ -466,11 +567,29 @@ export function useDuckLakeInstitutions(): UseDuckLakeInstitutionsReturn {
|
|||
const wikidataEnrichJson = row[15] ? String(row[15]) : null;
|
||||
const originalEntryJson = row[16] ? String(row[16]) : null;
|
||||
const serviceAreaJson = row[17] ? String(row[17]) : null;
|
||||
|
||||
// Temporal data columns (new) - prefixed with _ to indicate future use
|
||||
const _timespanBegin = row[18] ? String(row[18]) : null;
|
||||
const timespanEnd = row[19] ? String(row[19]) : null;
|
||||
const _timespanJson = row[20] ? String(row[20]) : null;
|
||||
const _timeOfDestructionJson = row[21] ? String(row[21]) : null;
|
||||
const _conflictStatusJson = row[22] ? String(row[22]) : null;
|
||||
const destructionDate = row[23] ? String(row[23]) : null;
|
||||
const foundingDateStr = row[24] ? String(row[24]) : null;
|
||||
const dissolutionDateStr = row[25] ? String(row[25]) : null;
|
||||
const _temporalExtentJson = row[26] ? String(row[26]) : null;
|
||||
const wikidataInception = row[27] ? String(row[27]) : null;
|
||||
const youtubeEnrichJson = row[28] ? String(row[28]) : null;
|
||||
|
||||
const province = parseProvinceFromGhcid(ghcidCurrent || null);
|
||||
const color = TYPE_COLORS[typeCode] || '#9e9e9e';
|
||||
const typeName = TYPE_NAMES[typeCode] || 'Unknown';
|
||||
|
||||
// Suppress unused variable warnings for future temporal data parsing
|
||||
// TODO: Parse these JSON columns when temporal UI is implemented
|
||||
void _timespanBegin; void _timespanJson; void _timeOfDestructionJson;
|
||||
void _conflictStatusJson; void _temporalExtentJson;
|
||||
|
||||
// Parse JSON columns
|
||||
const gmapsData = safeParseJSON<GoogleMapsEnrichment>(gmapsJson, {});
|
||||
const identifiersData = safeParseJSON<IdentifiersData>(identifiersJson, {});
|
||||
|
|
@ -478,32 +597,143 @@ export function useDuckLakeInstitutions(): UseDuckLakeInstitutionsReturn {
|
|||
const wikidataEnrichData = safeParseJSON<WikidataEnrichmentData>(wikidataEnrichJson, {});
|
||||
const originalEntryData = safeParseJSON<OriginalEntryData>(originalEntryJson, {});
|
||||
const serviceAreaData = safeParseJSON<ServiceAreaData>(serviceAreaJson, {});
|
||||
const youtubeEnrichData = safeParseJSON<YouTubeEnrichmentData>(youtubeEnrichJson, {});
|
||||
|
||||
// =========================================================================
|
||||
// Extract YouTube channel from Wikidata enrichment
|
||||
// Path: wikidata_claims.P2397_youtube_channel_id.value
|
||||
// Extract YouTube channel from youtube_enrichment (PRIMARY)
|
||||
// Supports TWO structures:
|
||||
// 1. Dutch (nested): { channel: {...}, videos: [...] }
|
||||
// 2. Non-Dutch (flat): { channel_id: "...", title: "...", ... }
|
||||
// Falls back to Wikidata enrichment if youtube_enrichment not available
|
||||
// All values are sanitized to strings/numbers to prevent React Error #300
|
||||
// =========================================================================
|
||||
let youtubeChannelId: string | undefined;
|
||||
let youtubeHandle: string | undefined;
|
||||
|
||||
if (wikidataEnrichData.wikidata_claims?.P2397_youtube_channel_id?.value) {
|
||||
youtubeChannelId = wikidataEnrichData.wikidata_claims.P2397_youtube_channel_id.value;
|
||||
// First try youtube_enrichment - check both nested and flat structures
|
||||
if (youtubeEnrichData.channel?.channel_id) {
|
||||
// Nested structure (Dutch)
|
||||
youtubeChannelId = String(youtubeEnrichData.channel.channel_id);
|
||||
} else if (youtubeEnrichData.channel_id) {
|
||||
// Flat structure (non-Dutch: SA, ZW, VN, VE, PS, etc.)
|
||||
youtubeChannelId = String(youtubeEnrichData.channel_id);
|
||||
}
|
||||
|
||||
// Get custom_url as handle from flat structure
|
||||
if (youtubeEnrichData.custom_url) {
|
||||
youtubeHandle = String(youtubeEnrichData.custom_url);
|
||||
}
|
||||
|
||||
// Fallback to Wikidata P2397 (YouTube channel ID property)
|
||||
if (!youtubeChannelId && wikidataEnrichData.wikidata_claims?.P2397_youtube_channel_id?.value) {
|
||||
youtubeChannelId = String(wikidataEnrichData.wikidata_claims.P2397_youtube_channel_id.value);
|
||||
}
|
||||
if (wikidataEnrichData.wikidata_claims?.P11245_youtube_handle?.value) {
|
||||
youtubeHandle = wikidataEnrichData.wikidata_claims.P11245_youtube_handle.value;
|
||||
youtubeHandle = String(wikidataEnrichData.wikidata_claims.P11245_youtube_handle.value);
|
||||
}
|
||||
|
||||
// Build YouTube channel object if we have channel ID
|
||||
const youtubeChannel = youtubeChannelId ? {
|
||||
channel_id: youtubeChannelId,
|
||||
channel_url: `https://www.youtube.com/channel/${youtubeChannelId}`,
|
||||
channel_title: youtubeHandle || '',
|
||||
channel_description: '',
|
||||
subscriber_count: null,
|
||||
video_count: null,
|
||||
view_count: null,
|
||||
thumbnail_url: '',
|
||||
} : identifiersData.youtube_channel;
|
||||
// Build YouTube channel object with full video data from youtube_enrichment
|
||||
// IMPORTANT: Sanitize all values to primitives to prevent React Error #300
|
||||
let youtubeChannel: IdentifiersData['youtube_channel'] | undefined;
|
||||
|
||||
if (youtubeEnrichData.channel) {
|
||||
// Build from youtube_enrichment NESTED structure (Dutch institutions)
|
||||
// Has full video data with comments
|
||||
const channel = youtubeEnrichData.channel;
|
||||
youtubeChannel = {
|
||||
channel_id: String(channel.channel_id || ''),
|
||||
channel_url: String(channel.channel_url || `https://www.youtube.com/channel/${channel.channel_id}`),
|
||||
channel_title: String(channel.title || youtubeHandle || ''),
|
||||
channel_description: String(channel.description || ''),
|
||||
subscriber_count: typeof channel.subscriber_count === 'number' ? channel.subscriber_count : null,
|
||||
video_count: typeof channel.video_count === 'number' ? channel.video_count : null,
|
||||
view_count: typeof channel.view_count === 'number' ? channel.view_count : null,
|
||||
thumbnail_url: String(channel.thumbnail_url || ''),
|
||||
// Map videos with sanitized comments
|
||||
videos: youtubeEnrichData.videos?.map(v => ({
|
||||
video_id: String(v.video_id || ''),
|
||||
video_url: String(v.video_url || `https://www.youtube.com/watch?v=${v.video_id}`),
|
||||
title: String(v.title || ''),
|
||||
description: String(v.description || ''),
|
||||
published_at: String(v.published_at || ''),
|
||||
duration: String(v.duration || ''),
|
||||
view_count: typeof v.view_count === 'number' ? v.view_count : null,
|
||||
like_count: typeof v.like_count === 'number' ? v.like_count : null,
|
||||
comment_count: typeof v.comment_count === 'number' ? v.comment_count : null,
|
||||
thumbnail_url: String(v.thumbnail_url || ''),
|
||||
// CRITICAL: Sanitize comments to prevent Error #300
|
||||
// Comments may have nested objects - ensure all values are primitives
|
||||
comments: (v.comments || []).map(c => ({
|
||||
author: String(c.author_display_name || c.author || 'Anonymous'),
|
||||
text: String(c.text_display || c.text || ''),
|
||||
like_count: typeof c.like_count === 'number' ? c.like_count : 0,
|
||||
})),
|
||||
has_transcript: v.has_transcript === true,
|
||||
transcript_snippet: v.transcript_snippet ? String(v.transcript_snippet) : null,
|
||||
})) || [],
|
||||
};
|
||||
} else if (youtubeEnrichData.channel_id) {
|
||||
// Build from youtube_enrichment FLAT structure (non-Dutch institutions)
|
||||
// Channel fields are at root level, no videos array in this format
|
||||
youtubeChannel = {
|
||||
channel_id: String(youtubeEnrichData.channel_id),
|
||||
channel_url: String(youtubeEnrichData.channel_url || `https://www.youtube.com/channel/${youtubeEnrichData.channel_id}`),
|
||||
channel_title: String(youtubeEnrichData.title || youtubeHandle || ''),
|
||||
channel_description: String(youtubeEnrichData.description || ''),
|
||||
subscriber_count: typeof youtubeEnrichData.subscriber_count === 'number' ? youtubeEnrichData.subscriber_count : null,
|
||||
video_count: typeof youtubeEnrichData.video_count === 'number' ? youtubeEnrichData.video_count : null,
|
||||
view_count: typeof youtubeEnrichData.view_count === 'number' ? youtubeEnrichData.view_count : null,
|
||||
thumbnail_url: String(youtubeEnrichData.thumbnail_url || ''),
|
||||
// No videos in flat structure - just channel metadata
|
||||
videos: [],
|
||||
};
|
||||
} else if (youtubeChannelId) {
|
||||
// Build minimal from Wikidata (just channel ID, no videos)
|
||||
youtubeChannel = {
|
||||
channel_id: youtubeChannelId,
|
||||
channel_url: `https://www.youtube.com/channel/${youtubeChannelId}`,
|
||||
channel_title: youtubeHandle || '',
|
||||
channel_description: '',
|
||||
subscriber_count: null,
|
||||
video_count: null,
|
||||
view_count: null,
|
||||
thumbnail_url: '',
|
||||
};
|
||||
} else if (identifiersData.youtube_channel) {
|
||||
// Fallback to identifiers_json youtube_channel (legacy)
|
||||
// Sanitize the data to prevent Error #300
|
||||
const legacy = identifiersData.youtube_channel;
|
||||
youtubeChannel = {
|
||||
channel_id: String(legacy.channel_id || ''),
|
||||
channel_url: String(legacy.channel_url || ''),
|
||||
channel_title: String(legacy.channel_title || ''),
|
||||
channel_description: String(legacy.channel_description || ''),
|
||||
subscriber_count: typeof legacy.subscriber_count === 'number' ? legacy.subscriber_count : null,
|
||||
video_count: typeof legacy.video_count === 'number' ? legacy.video_count : null,
|
||||
view_count: typeof legacy.view_count === 'number' ? legacy.view_count : null,
|
||||
thumbnail_url: String(legacy.thumbnail_url || ''),
|
||||
// Sanitize legacy videos if present
|
||||
videos: legacy.videos?.map(v => ({
|
||||
video_id: String(v.video_id || ''),
|
||||
video_url: String(v.video_url || ''),
|
||||
title: String(v.title || ''),
|
||||
description: String(v.description || ''),
|
||||
published_at: String(v.published_at || ''),
|
||||
duration: String(v.duration || ''),
|
||||
view_count: typeof v.view_count === 'number' ? v.view_count : null,
|
||||
like_count: typeof v.like_count === 'number' ? v.like_count : null,
|
||||
comment_count: typeof v.comment_count === 'number' ? v.comment_count : null,
|
||||
thumbnail_url: String(v.thumbnail_url || ''),
|
||||
comments: (v.comments || []).map(c => ({
|
||||
author: String(c.author || 'Anonymous'),
|
||||
text: String(c.text || ''),
|
||||
like_count: typeof c.like_count === 'number' ? c.like_count : 0,
|
||||
})),
|
||||
has_transcript: v.has_transcript === true,
|
||||
transcript_snippet: v.transcript_snippet ? String(v.transcript_snippet) : null,
|
||||
})) || [],
|
||||
};
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Extract museum_register from original_entry
|
||||
|
|
@ -528,9 +758,31 @@ export function useDuckLakeInstitutions(): UseDuckLakeInstitutionsReturn {
|
|||
}
|
||||
|
||||
// =========================================================================
|
||||
// Extract founding year from Wikidata inception claim
|
||||
// Extract founding year from multiple sources (priority order)
|
||||
// 1. DuckLake founding_date column (from temporal_extent)
|
||||
// 2. Wikidata inception column (from wikidata_enrichment)
|
||||
// 3. identifiers.founding_year
|
||||
// 4. wikidata_claims.inception
|
||||
// =========================================================================
|
||||
let foundingYear = identifiersData.founding_year;
|
||||
|
||||
// Try DuckLake founding_date column first
|
||||
if (!foundingYear && foundingDateStr) {
|
||||
const yearMatch = foundingDateStr.match(/(\d{4})/);
|
||||
if (yearMatch) {
|
||||
foundingYear = parseInt(yearMatch[1], 10);
|
||||
}
|
||||
}
|
||||
|
||||
// Try Wikidata inception column
|
||||
if (!foundingYear && wikidataInception) {
|
||||
const yearMatch = wikidataInception.match(/(\d{4})/);
|
||||
if (yearMatch) {
|
||||
foundingYear = parseInt(yearMatch[1], 10);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to wikidata_claims.inception from JSON
|
||||
if (!foundingYear && wikidataEnrichData.wikidata_claims?.inception) {
|
||||
const inceptionStr = wikidataEnrichData.wikidata_claims.inception;
|
||||
// Try to parse year from inception string (e.g., "1910", "+1910-01-01T00:00:00Z")
|
||||
|
|
@ -539,6 +791,62 @@ export function useDuckLakeInstitutions(): UseDuckLakeInstitutionsReturn {
|
|||
foundingYear = parseInt(yearMatch[1], 10);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Extract dissolution year from multiple sources (priority order)
|
||||
// 1. DuckLake dissolution_date column (from temporal_extent)
|
||||
// 2. DuckLake destruction_date column (conflict-related)
|
||||
// 3. timespanEnd column (from timespan.begin_of_the_end)
|
||||
// 4. temporal_extent from identifiers JSON
|
||||
// =========================================================================
|
||||
let dissolutionYear: number | undefined;
|
||||
|
||||
// Try DuckLake dissolution_date column
|
||||
if (dissolutionDateStr) {
|
||||
const yearMatch = dissolutionDateStr.match(/(\d{4})/);
|
||||
if (yearMatch) {
|
||||
dissolutionYear = parseInt(yearMatch[1], 10);
|
||||
}
|
||||
}
|
||||
|
||||
// Try destruction_date (for conflict-destroyed heritage)
|
||||
if (!dissolutionYear && destructionDate) {
|
||||
const yearMatch = destructionDate.match(/(\d{4})/);
|
||||
if (yearMatch) {
|
||||
dissolutionYear = parseInt(yearMatch[1], 10);
|
||||
}
|
||||
}
|
||||
|
||||
// Try timespan_end column
|
||||
if (!dissolutionYear && timespanEnd) {
|
||||
const yearMatch = timespanEnd.match(/(\d{4})/);
|
||||
if (yearMatch) {
|
||||
dissolutionYear = parseInt(yearMatch[1], 10);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to identifiers.temporal_extent
|
||||
if (!dissolutionYear && identifiersData.temporal_extent?.dissolution_date) {
|
||||
const yearMatch = String(identifiersData.temporal_extent.dissolution_date).match(/(\d{4})/);
|
||||
if (yearMatch) {
|
||||
dissolutionYear = parseInt(yearMatch[1], 10);
|
||||
}
|
||||
}
|
||||
if (!dissolutionYear && identifiersData.temporal_extent?.end_date) {
|
||||
const yearMatch = String(identifiersData.temporal_extent.end_date).match(/(\d{4})/);
|
||||
if (yearMatch) {
|
||||
dissolutionYear = parseInt(yearMatch[1], 10);
|
||||
}
|
||||
}
|
||||
|
||||
// DEBUG: Log temporal data extraction (first 10 institutions with temporal data)
|
||||
if (foundingYear || dissolutionYear) {
|
||||
if (!window._temporalDebugCount) window._temporalDebugCount = 0;
|
||||
if (window._temporalDebugCount < 10) {
|
||||
console.log(`[Temporal] ${name}: founding=${foundingYear}, dissolution=${dissolutionYear}, sources: foundingDateStr=${foundingDateStr}, wikidataInception=${wikidataInception?.substring(0, 50)}`);
|
||||
window._temporalDebugCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Extract website from Wikidata if not in Google Maps
|
||||
|
|
@ -599,6 +907,7 @@ export function useDuckLakeInstitutions(): UseDuckLakeInstitutionsReturn {
|
|||
youtube: youtubeChannel,
|
||||
founding_year: foundingYear,
|
||||
founding_decade: identifiersData.founding_decade,
|
||||
dissolution_year: dissolutionYear,
|
||||
temporal_extent: identifiersData.temporal_extent,
|
||||
successor_organization: identifiersData.successor_organization,
|
||||
genealogiewerkbalk: genealogieData.municipality ? genealogieData : undefined,
|
||||
|
|
@ -618,6 +927,11 @@ export function useDuckLakeInstitutions(): UseDuckLakeInstitutionsReturn {
|
|||
return institution;
|
||||
});
|
||||
|
||||
// DEBUG: Log summary of temporal data extraction
|
||||
const withFoundingYear = mapped.filter(i => i.founding_year).length;
|
||||
const withDissolutionYear = mapped.filter(i => i.dissolution_year).length;
|
||||
console.log(`[DuckLake] Temporal data summary: ${withFoundingYear} with founding_year, ${withDissolutionYear} with dissolution_year out of ${mapped.length} total`);
|
||||
|
||||
setInstitutions(mapped);
|
||||
} catch (err) {
|
||||
console.error('Failed to load institutions from DuckLake:', err);
|
||||
|
|
|
|||
|
|
@ -30,6 +30,28 @@ export interface OxigraphStats {
|
|||
}>;
|
||||
}
|
||||
|
||||
// Graph visualization data structures
|
||||
export interface OxigraphGraphNode {
|
||||
id: string;
|
||||
label: string;
|
||||
type: string;
|
||||
entityType: string;
|
||||
attributes: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface OxigraphGraphEdge {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
predicate: string;
|
||||
predicateLabel: string;
|
||||
}
|
||||
|
||||
export interface OxigraphGraphData {
|
||||
nodes: OxigraphGraphNode[];
|
||||
edges: OxigraphGraphEdge[];
|
||||
}
|
||||
|
||||
export interface OxigraphStatus {
|
||||
isConnected: boolean;
|
||||
endpoint: string;
|
||||
|
|
@ -48,6 +70,7 @@ export interface UseOxigraphReturn {
|
|||
loadRdfData: (data: string, format: string, graphName?: string) => Promise<void>;
|
||||
clearGraph: (graphName?: string) => Promise<void>;
|
||||
exportGraph: (graphName?: string, format?: string) => Promise<string>;
|
||||
getGraphData: (rdfClass?: string, limit?: number) => Promise<OxigraphGraphData>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -360,6 +383,165 @@ export function useOxigraph(): UseOxigraphReturn {
|
|||
return response.text();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Shorten a URI for display
|
||||
*/
|
||||
const shortenUri = (uri: string): string => {
|
||||
const prefixes: Record<string, string> = {
|
||||
'http://www.w3.org/1999/02/22-rdf-syntax-ns#': 'rdf:',
|
||||
'http://www.w3.org/2000/01/rdf-schema#': 'rdfs:',
|
||||
'http://www.w3.org/2002/07/owl#': 'owl:',
|
||||
'http://schema.org/': 'schema:',
|
||||
'http://purl.org/dc/terms/': 'dct:',
|
||||
'http://xmlns.com/foaf/0.1/': 'foaf:',
|
||||
'https://w3id.org/heritage/custodian/': 'hc:',
|
||||
'http://www.cidoc-crm.org/cidoc-crm/': 'crm:',
|
||||
'https://www.ica.org/standards/RiC/ontology#': 'rico:',
|
||||
'http://data.europa.eu/m8g/': 'cpov:',
|
||||
};
|
||||
|
||||
for (const [ns, prefix] of Object.entries(prefixes)) {
|
||||
if (uri.startsWith(ns)) {
|
||||
return prefix + uri.slice(ns.length);
|
||||
}
|
||||
}
|
||||
|
||||
// Return last part of URI
|
||||
const lastPart = uri.split(/[#/]/).pop();
|
||||
return lastPart || uri;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get graph data for visualization - fetches nodes (subjects/objects) and edges (predicates)
|
||||
*/
|
||||
const getGraphData = useCallback(async (
|
||||
rdfClass?: string,
|
||||
limit: number = 100
|
||||
): Promise<OxigraphGraphData> => {
|
||||
// Build SPARQL query to get triples
|
||||
// If rdfClass is specified, filter by that class
|
||||
const query = rdfClass
|
||||
? `
|
||||
PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
|
||||
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
||||
PREFIX schema: <http://schema.org/>
|
||||
PREFIX skos: <http://www.w3.org/2004/02/skos/core#>
|
||||
|
||||
SELECT ?s ?p ?o ?sLabel ?oLabel ?sType WHERE {
|
||||
?s a <${rdfClass}> .
|
||||
?s ?p ?o .
|
||||
FILTER(isIRI(?o))
|
||||
OPTIONAL { ?s rdfs:label ?sLabelRdfs }
|
||||
OPTIONAL { ?s schema:name ?sLabelSchema }
|
||||
OPTIONAL { ?s skos:prefLabel ?sLabelSkos }
|
||||
OPTIONAL { ?o rdfs:label ?oLabelRdfs }
|
||||
OPTIONAL { ?o schema:name ?oLabelSchema }
|
||||
OPTIONAL { ?s a ?sType }
|
||||
BIND(COALESCE(?sLabelRdfs, ?sLabelSchema, ?sLabelSkos) AS ?sLabel)
|
||||
BIND(COALESCE(?oLabelRdfs, ?oLabelSchema) AS ?oLabel)
|
||||
}
|
||||
LIMIT ${limit * 3}
|
||||
`
|
||||
: `
|
||||
PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
|
||||
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
||||
PREFIX schema: <http://schema.org/>
|
||||
PREFIX skos: <http://www.w3.org/2004/02/skos/core#>
|
||||
|
||||
SELECT ?s ?p ?o ?sLabel ?oLabel ?sType WHERE {
|
||||
?s ?p ?o .
|
||||
FILTER(isIRI(?s) && isIRI(?o))
|
||||
OPTIONAL { ?s rdfs:label ?sLabelRdfs }
|
||||
OPTIONAL { ?s schema:name ?sLabelSchema }
|
||||
OPTIONAL { ?s skos:prefLabel ?sLabelSkos }
|
||||
OPTIONAL { ?o rdfs:label ?oLabelRdfs }
|
||||
OPTIONAL { ?o schema:name ?oLabelSchema }
|
||||
OPTIONAL { ?s a ?sType }
|
||||
BIND(COALESCE(?sLabelRdfs, ?sLabelSchema, ?sLabelSkos) AS ?sLabel)
|
||||
BIND(COALESCE(?oLabelRdfs, ?oLabelSchema) AS ?oLabel)
|
||||
}
|
||||
LIMIT ${limit * 3}
|
||||
`;
|
||||
|
||||
const result = await sparqlQuery(query) as {
|
||||
results: {
|
||||
bindings: Array<{
|
||||
s: { value: string };
|
||||
p: { value: string };
|
||||
o: { value: string };
|
||||
sLabel?: { value: string };
|
||||
oLabel?: { value: string };
|
||||
sType?: { value: string };
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
// Build nodes and edges from triples
|
||||
const nodesMap = new Map<string, OxigraphGraphNode>();
|
||||
const edges: OxigraphGraphEdge[] = [];
|
||||
|
||||
for (const binding of result.results.bindings) {
|
||||
const subjectUri = binding.s.value;
|
||||
const predicateUri = binding.p.value;
|
||||
const objectUri = binding.o.value;
|
||||
|
||||
// Skip rdf:type predicates for edges (we use them for node types)
|
||||
if (predicateUri === 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type') {
|
||||
// Update node type
|
||||
if (nodesMap.has(subjectUri)) {
|
||||
const node = nodesMap.get(subjectUri)!;
|
||||
node.entityType = shortenUri(objectUri);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add subject node
|
||||
if (!nodesMap.has(subjectUri)) {
|
||||
nodesMap.set(subjectUri, {
|
||||
id: subjectUri,
|
||||
label: binding.sLabel?.value || shortenUri(subjectUri),
|
||||
type: 'subject',
|
||||
entityType: binding.sType ? shortenUri(binding.sType.value) : 'Resource',
|
||||
attributes: {},
|
||||
});
|
||||
}
|
||||
|
||||
// Add object node (only if it's a URI)
|
||||
if (!nodesMap.has(objectUri)) {
|
||||
nodesMap.set(objectUri, {
|
||||
id: objectUri,
|
||||
label: binding.oLabel?.value || shortenUri(objectUri),
|
||||
type: 'object',
|
||||
entityType: 'Resource',
|
||||
attributes: {},
|
||||
});
|
||||
}
|
||||
|
||||
// Add edge
|
||||
edges.push({
|
||||
id: `${subjectUri}-${predicateUri}-${objectUri}`,
|
||||
source: subjectUri,
|
||||
target: objectUri,
|
||||
predicate: predicateUri,
|
||||
predicateLabel: shortenUri(predicateUri),
|
||||
});
|
||||
}
|
||||
|
||||
// Limit nodes to requested amount
|
||||
const nodes = Array.from(nodesMap.values()).slice(0, limit);
|
||||
const nodeIds = new Set(nodes.map(n => n.id));
|
||||
|
||||
// Filter edges to only include those with valid source/target
|
||||
const filteredEdges = edges.filter(
|
||||
e => nodeIds.has(e.source) && nodeIds.has(e.target)
|
||||
);
|
||||
|
||||
return {
|
||||
nodes,
|
||||
edges: filteredEdges,
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
status,
|
||||
stats,
|
||||
|
|
@ -370,5 +552,6 @@ export function useOxigraph(): UseOxigraphReturn {
|
|||
loadRdfData,
|
||||
clearGraph,
|
||||
exportGraph,
|
||||
getGraphData,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,13 @@ export interface PostgreSQLStatus {
|
|||
responseTimeMs?: number;
|
||||
}
|
||||
|
||||
export interface TableDataResult {
|
||||
columns: string[];
|
||||
rows: unknown[][];
|
||||
rowCount: number;
|
||||
totalRows: number;
|
||||
}
|
||||
|
||||
export interface UsePostgreSQLReturn {
|
||||
status: PostgreSQLStatus;
|
||||
stats: PostgreSQLStats | null;
|
||||
|
|
@ -44,8 +51,11 @@ export interface UsePostgreSQLReturn {
|
|||
error: Error | null;
|
||||
refresh: () => Promise<void>;
|
||||
executeQuery: (query: string) => Promise<unknown[]>;
|
||||
executeRawQuery: (query: string) => Promise<QueryResult>;
|
||||
getTables: () => Promise<string[]>;
|
||||
getTableSchema: (tableName: string) => Promise<unknown>;
|
||||
getTableData: (schema: string, tableName: string, limit?: number, offset?: number) => Promise<TableDataResult>;
|
||||
exportTableData: (schema: string, tableName: string, format: 'json' | 'csv') => Promise<Blob>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -246,13 +256,20 @@ export function usePostgreSQL(): UsePostgreSQLReturn {
|
|||
}, [refresh]);
|
||||
|
||||
/**
|
||||
* Execute a SQL query
|
||||
* Execute a SQL query and return array of objects
|
||||
*/
|
||||
const executeQuery = useCallback(async (query: string): Promise<unknown[]> => {
|
||||
const result = await postgresQuery(query);
|
||||
return rowsToObjects(result);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Execute a SQL query and return raw result (columns + rows)
|
||||
*/
|
||||
const executeRawQuery = useCallback(async (query: string): Promise<QueryResult> => {
|
||||
return postgresQuery(query);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get list of tables
|
||||
*/
|
||||
|
|
@ -290,6 +307,68 @@ export function usePostgreSQL(): UsePostgreSQLReturn {
|
|||
return rowsToObjects(result);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get table data with pagination
|
||||
*/
|
||||
const getTableData = useCallback(async (
|
||||
schema: string,
|
||||
tableName: string,
|
||||
limit: number = 50,
|
||||
offset: number = 0
|
||||
): Promise<TableDataResult> => {
|
||||
// Get total count first
|
||||
const countResult = await postgresQuery(
|
||||
`SELECT COUNT(*) as cnt FROM "${schema}"."${tableName}"`
|
||||
);
|
||||
const totalRows = Number(countResult.rows[0]?.[0] || 0);
|
||||
|
||||
// Get data with limit/offset
|
||||
const dataResult = await postgresQuery(
|
||||
`SELECT * FROM "${schema}"."${tableName}" LIMIT ${limit} OFFSET ${offset}`
|
||||
);
|
||||
|
||||
return {
|
||||
columns: dataResult.columns,
|
||||
rows: dataResult.rows,
|
||||
rowCount: dataResult.rows.length,
|
||||
totalRows,
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Export table data as JSON or CSV
|
||||
*/
|
||||
const exportTableData = useCallback(async (
|
||||
schema: string,
|
||||
tableName: string,
|
||||
format: 'json' | 'csv'
|
||||
): Promise<Blob> => {
|
||||
// Get all data (be careful with large tables)
|
||||
const result = await postgresQuery(
|
||||
`SELECT * FROM "${schema}"."${tableName}" LIMIT 10000`
|
||||
);
|
||||
|
||||
if (format === 'json') {
|
||||
const data = rowsToObjects(result);
|
||||
return new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
} else {
|
||||
// CSV format
|
||||
const header = result.columns.join(',');
|
||||
const rows = result.rows.map(row =>
|
||||
row.map(cell => {
|
||||
if (cell === null || cell === undefined) return '';
|
||||
const str = typeof cell === 'object' ? JSON.stringify(cell) : String(cell);
|
||||
// Escape quotes and wrap in quotes if contains comma or quote
|
||||
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return str;
|
||||
}).join(',')
|
||||
).join('\n');
|
||||
return new Blob([header + '\n' + rows], { type: 'text/csv' });
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
status,
|
||||
stats,
|
||||
|
|
@ -297,7 +376,10 @@ export function usePostgreSQL(): UsePostgreSQLReturn {
|
|||
error,
|
||||
refresh,
|
||||
executeQuery,
|
||||
executeRawQuery,
|
||||
getTables,
|
||||
getTableSchema,
|
||||
getTableData,
|
||||
exportTableData,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,28 @@ export interface TypeDBStatus {
|
|||
responseTimeMs?: number;
|
||||
}
|
||||
|
||||
export interface TypeDBGraphNode {
|
||||
id: string;
|
||||
label: string;
|
||||
type: string;
|
||||
entityType: string;
|
||||
attributes: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TypeDBGraphEdge {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
relationType: string;
|
||||
role: string;
|
||||
attributes: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TypeDBGraphData {
|
||||
nodes: TypeDBGraphNode[];
|
||||
edges: TypeDBGraphEdge[];
|
||||
}
|
||||
|
||||
export interface UseTypeDBReturn {
|
||||
status: TypeDBStatus;
|
||||
stats: TypeDBStats | null;
|
||||
|
|
@ -52,6 +74,9 @@ export interface UseTypeDBReturn {
|
|||
executeQuery: (query: string, queryType?: 'read' | 'write' | 'schema') => Promise<unknown>;
|
||||
getDatabases: () => Promise<string[]>;
|
||||
getSchema: () => Promise<string>;
|
||||
getEntityInstances: (entityType: string, limit?: number) => Promise<TypeDBGraphNode[]>;
|
||||
getRelationInstances: (relationType: string, limit?: number) => Promise<TypeDBGraphEdge[]>;
|
||||
getGraphData: (entityType: string, depth?: number, limit?: number) => Promise<TypeDBGraphData>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -251,6 +276,151 @@ export function useTypeDB(): UseTypeDBReturn {
|
|||
return response.text();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get entity instances of a specific type
|
||||
*/
|
||||
const getEntityInstances = useCallback(async (
|
||||
entityType: string,
|
||||
limit: number = 50
|
||||
): Promise<TypeDBGraphNode[]> => {
|
||||
const query = `match $x isa ${entityType}, has $attr; get $x, $attr; limit ${limit};`;
|
||||
const response = await typedbQuery(query, 'read') as { results: Record<string, unknown>[] };
|
||||
const result = response.results || [];
|
||||
|
||||
// Transform query results to graph nodes
|
||||
const nodeMap = new Map<string, TypeDBGraphNode>();
|
||||
|
||||
for (const row of result) {
|
||||
// Handle backend response format: x is {id, type, _iid, _type}
|
||||
const entity = row['x'] as { id?: string; _iid?: string; type?: string; _type?: string } | undefined;
|
||||
// Handle backend response format: attr is {value, type}
|
||||
const attr = row['attr'] as { type?: string; value?: unknown } | undefined;
|
||||
|
||||
if (entity) {
|
||||
const entityId = entity.id || entity._iid || '';
|
||||
const entityTypeName = entity.type || entity._type || entityType;
|
||||
|
||||
if (entityId && !nodeMap.has(entityId)) {
|
||||
nodeMap.set(entityId, {
|
||||
id: entityId,
|
||||
label: entityId.substring(0, 8),
|
||||
type: 'entity',
|
||||
entityType: entityTypeName,
|
||||
attributes: {},
|
||||
});
|
||||
}
|
||||
|
||||
if (attr && entityId) {
|
||||
const node = nodeMap.get(entityId)!;
|
||||
const attrType = attr.type || 'unknown';
|
||||
node.attributes[attrType] = attr.value;
|
||||
// Use 'name' or 'label' attribute as node label if available
|
||||
if ((attrType === 'name' || attrType === 'label') && attr.value) {
|
||||
node.label = String(attr.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(nodeMap.values());
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get relation instances of a specific type
|
||||
*/
|
||||
const getRelationInstances = useCallback(async (
|
||||
relationType: string,
|
||||
limit: number = 50
|
||||
): Promise<TypeDBGraphEdge[]> => {
|
||||
const query = `match $r isa ${relationType}; $r ($role: $player); get $r, $role, $player; limit ${limit};`;
|
||||
const response = await typedbQuery(query, 'read') as { results: Record<string, unknown>[] };
|
||||
const result = response.results || [];
|
||||
|
||||
// Group by relation ID
|
||||
const relationMap = new Map<string, { players: Array<{ role: string; playerId: string }> }>();
|
||||
|
||||
for (const row of result) {
|
||||
// Handle backend response format
|
||||
const rel = row['r'] as { id?: string; _iid?: string } | undefined;
|
||||
const role = row['role'] as string | undefined;
|
||||
const player = row['player'] as { id?: string; _iid?: string } | undefined;
|
||||
|
||||
const relId = rel?.id || rel?._iid;
|
||||
const playerId = player?.id || player?._iid;
|
||||
|
||||
if (relId && role && playerId) {
|
||||
if (!relationMap.has(relId)) {
|
||||
relationMap.set(relId, { players: [] });
|
||||
}
|
||||
relationMap.get(relId)!.players.push({ role, playerId });
|
||||
}
|
||||
}
|
||||
|
||||
// Create edges from relations
|
||||
const edges: TypeDBGraphEdge[] = [];
|
||||
for (const [relId, data] of relationMap) {
|
||||
// Create edges between all pairs of players in the relation
|
||||
const players = data.players;
|
||||
if (players.length >= 2) {
|
||||
edges.push({
|
||||
id: relId,
|
||||
source: players[0].playerId,
|
||||
target: players[1].playerId,
|
||||
relationType,
|
||||
role: `${players[0].role} → ${players[1].role}`,
|
||||
attributes: {},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return edges;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get graph data for visualization (entities + relations)
|
||||
* Uses the optimized /graph/{entity_type} endpoint that returns both nodes and edges
|
||||
*/
|
||||
const getGraphData = useCallback(async (
|
||||
entityType: string,
|
||||
_depth: number = 1, // depth is handled server-side
|
||||
limit: number = 100
|
||||
): Promise<TypeDBGraphData> => {
|
||||
try {
|
||||
// Use the new graph endpoint that returns both nodes and edges in one call
|
||||
const response = await fetch(
|
||||
`${TYPEDB_API_URL}/graph/${entityType}?limit=${limit}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Graph query failed: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
const graphData = await response.json() as {
|
||||
nodes: TypeDBGraphNode[];
|
||||
edges: TypeDBGraphEdge[];
|
||||
nodeCount: number;
|
||||
edgeCount: number;
|
||||
};
|
||||
|
||||
return {
|
||||
nodes: graphData.nodes || [],
|
||||
edges: graphData.edges || [],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch graph data:', error);
|
||||
// Fallback to entity-only fetch if graph endpoint fails
|
||||
const nodes = await getEntityInstances(entityType, limit);
|
||||
return { nodes, edges: [] };
|
||||
}
|
||||
}, [getEntityInstances]);
|
||||
|
||||
return {
|
||||
status,
|
||||
stats,
|
||||
|
|
@ -260,5 +430,8 @@ export function useTypeDB(): UseTypeDBReturn {
|
|||
executeQuery,
|
||||
getDatabases,
|
||||
getSchema,
|
||||
getEntityInstances,
|
||||
getRelationInstances,
|
||||
getGraphData,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -142,6 +142,28 @@ export interface WerkgebiedHookResult {
|
|||
} | null;
|
||||
}
|
||||
|
||||
// Helper to safely check if map is still valid (not destroyed)
|
||||
function isMapValid(map: maplibregl.Map | null): map is maplibregl.Map {
|
||||
if (!map) return false;
|
||||
try {
|
||||
// Attempt to access a property that would throw if map is destroyed
|
||||
map.getContainer();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to safely get layer (returns undefined if map is destroyed or layer doesn't exist)
|
||||
function safeGetLayer(map: maplibregl.Map | null, layerId: string): boolean {
|
||||
if (!isMapValid(map)) return false;
|
||||
try {
|
||||
return !!map.getLayer(layerId);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function useWerkgebiedMapLibre(map: maplibregl.Map | null): WerkgebiedHookResult {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -256,52 +278,59 @@ export function useWerkgebiedMapLibre(map: maplibregl.Map | null): WerkgebiedHoo
|
|||
if (!map || layersAddedRef.current) return;
|
||||
|
||||
const setupLayers = () => {
|
||||
// Check if map style is loaded
|
||||
if (!map.isStyleLoaded()) {
|
||||
map.once('styledata', setupLayers);
|
||||
return;
|
||||
}
|
||||
// Check if map is still valid and style is loaded
|
||||
if (!isMapValid(map)) return;
|
||||
|
||||
// Add empty source for werkgebied
|
||||
if (!map.getSource(WERKGEBIED_SOURCE_ID)) {
|
||||
map.addSource(WERKGEBIED_SOURCE_ID, {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'FeatureCollection',
|
||||
features: [],
|
||||
},
|
||||
});
|
||||
try {
|
||||
if (!map.isStyleLoaded()) {
|
||||
map.once('styledata', setupLayers);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add empty source for werkgebied
|
||||
if (!map.getSource(WERKGEBIED_SOURCE_ID)) {
|
||||
map.addSource(WERKGEBIED_SOURCE_ID, {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'FeatureCollection',
|
||||
features: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Add fill layer (below markers) - use static values, we'll update them when showing
|
||||
if (!safeGetLayer(map, WERKGEBIED_FILL_LAYER_ID)) {
|
||||
map.addLayer({
|
||||
id: WERKGEBIED_FILL_LAYER_ID,
|
||||
type: 'fill',
|
||||
source: WERKGEBIED_SOURCE_ID,
|
||||
paint: {
|
||||
'fill-color': WERKGEBIED_FILL_COLOR,
|
||||
'fill-opacity': WERKGEBIED_FILL_OPACITY,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Add line layer for borders
|
||||
if (!safeGetLayer(map, WERKGEBIED_LINE_LAYER_ID)) {
|
||||
map.addLayer({
|
||||
id: WERKGEBIED_LINE_LAYER_ID,
|
||||
type: 'line',
|
||||
source: WERKGEBIED_SOURCE_ID,
|
||||
paint: {
|
||||
'line-color': WERKGEBIED_LINE_COLOR,
|
||||
'line-width': WERKGEBIED_LINE_WIDTH,
|
||||
'line-dasharray': [5, 5],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
layersAddedRef.current = true;
|
||||
console.log('[useWerkgebiedMapLibre] Layers setup complete');
|
||||
} catch {
|
||||
// Map may be destroyed during setup
|
||||
console.warn('[useWerkgebiedMapLibre] Map destroyed during layer setup');
|
||||
}
|
||||
|
||||
// Add fill layer (below markers) - use static values, we'll update them when showing
|
||||
if (!map.getLayer(WERKGEBIED_FILL_LAYER_ID)) {
|
||||
map.addLayer({
|
||||
id: WERKGEBIED_FILL_LAYER_ID,
|
||||
type: 'fill',
|
||||
source: WERKGEBIED_SOURCE_ID,
|
||||
paint: {
|
||||
'fill-color': WERKGEBIED_FILL_COLOR,
|
||||
'fill-opacity': WERKGEBIED_FILL_OPACITY,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Add line layer for borders
|
||||
if (!map.getLayer(WERKGEBIED_LINE_LAYER_ID)) {
|
||||
map.addLayer({
|
||||
id: WERKGEBIED_LINE_LAYER_ID,
|
||||
type: 'line',
|
||||
source: WERKGEBIED_SOURCE_ID,
|
||||
paint: {
|
||||
'line-color': WERKGEBIED_LINE_COLOR,
|
||||
'line-width': WERKGEBIED_LINE_WIDTH,
|
||||
'line-dasharray': [5, 5],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
layersAddedRef.current = true;
|
||||
console.log('[useWerkgebiedMapLibre] Layers setup complete');
|
||||
};
|
||||
|
||||
setupLayers();
|
||||
|
|
@ -363,50 +392,55 @@ export function useWerkgebiedMapLibre(map: maplibregl.Map | null): WerkgebiedHoo
|
|||
|
||||
// Helper to ensure source and layers exist
|
||||
const ensureSourceAndLayers = useCallback(() => {
|
||||
if (!map) return false;
|
||||
if (!isMapValid(map)) return false;
|
||||
|
||||
// Add source if missing
|
||||
if (!map.getSource(WERKGEBIED_SOURCE_ID)) {
|
||||
console.log('[useWerkgebiedMapLibre] Adding missing source');
|
||||
map.addSource(WERKGEBIED_SOURCE_ID, {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'FeatureCollection',
|
||||
features: [],
|
||||
},
|
||||
});
|
||||
try {
|
||||
// Add source if missing
|
||||
if (!map.getSource(WERKGEBIED_SOURCE_ID)) {
|
||||
console.log('[useWerkgebiedMapLibre] Adding missing source');
|
||||
map.addSource(WERKGEBIED_SOURCE_ID, {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'FeatureCollection',
|
||||
features: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Add fill layer if missing
|
||||
if (!safeGetLayer(map, WERKGEBIED_FILL_LAYER_ID)) {
|
||||
console.log('[useWerkgebiedMapLibre] Adding missing fill layer');
|
||||
map.addLayer({
|
||||
id: WERKGEBIED_FILL_LAYER_ID,
|
||||
type: 'fill',
|
||||
source: WERKGEBIED_SOURCE_ID,
|
||||
paint: {
|
||||
'fill-color': WERKGEBIED_FILL_COLOR,
|
||||
'fill-opacity': WERKGEBIED_FILL_OPACITY,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Add line layer if missing
|
||||
if (!safeGetLayer(map, WERKGEBIED_LINE_LAYER_ID)) {
|
||||
console.log('[useWerkgebiedMapLibre] Adding missing line layer');
|
||||
map.addLayer({
|
||||
id: WERKGEBIED_LINE_LAYER_ID,
|
||||
type: 'line',
|
||||
source: WERKGEBIED_SOURCE_ID,
|
||||
paint: {
|
||||
'line-color': WERKGEBIED_LINE_COLOR,
|
||||
'line-width': WERKGEBIED_LINE_WIDTH,
|
||||
'line-dasharray': [5, 5],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
// Map may be destroyed
|
||||
return false;
|
||||
}
|
||||
|
||||
// Add fill layer if missing
|
||||
if (!map.getLayer(WERKGEBIED_FILL_LAYER_ID)) {
|
||||
console.log('[useWerkgebiedMapLibre] Adding missing fill layer');
|
||||
map.addLayer({
|
||||
id: WERKGEBIED_FILL_LAYER_ID,
|
||||
type: 'fill',
|
||||
source: WERKGEBIED_SOURCE_ID,
|
||||
paint: {
|
||||
'fill-color': WERKGEBIED_FILL_COLOR,
|
||||
'fill-opacity': WERKGEBIED_FILL_OPACITY,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Add line layer if missing
|
||||
if (!map.getLayer(WERKGEBIED_LINE_LAYER_ID)) {
|
||||
console.log('[useWerkgebiedMapLibre] Adding missing line layer');
|
||||
map.addLayer({
|
||||
id: WERKGEBIED_LINE_LAYER_ID,
|
||||
type: 'line',
|
||||
source: WERKGEBIED_SOURCE_ID,
|
||||
paint: {
|
||||
'line-color': WERKGEBIED_LINE_COLOR,
|
||||
'line-width': WERKGEBIED_LINE_WIDTH,
|
||||
'line-dasharray': [5, 5],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [map]);
|
||||
|
||||
// Helper to update map with GeoJSON features
|
||||
|
|
@ -415,7 +449,7 @@ export function useWerkgebiedMapLibre(map: maplibregl.Map | null): WerkgebiedHoo
|
|||
isHistorical: boolean,
|
||||
options: WerkgebiedOptions
|
||||
) => {
|
||||
if (!map) return;
|
||||
if (!isMapValid(map)) return;
|
||||
|
||||
// Ensure source and layers exist before updating
|
||||
if (!ensureSourceAndLayers()) {
|
||||
|
|
@ -431,22 +465,22 @@ export function useWerkgebiedMapLibre(map: maplibregl.Map | null): WerkgebiedHoo
|
|||
const lineColor = isHistorical ? HISTORICAL_LINE_COLOR : WERKGEBIED_LINE_COLOR;
|
||||
const lineWidth = isHistorical ? HISTORICAL_LINE_WIDTH : WERKGEBIED_LINE_WIDTH;
|
||||
|
||||
// Update layer paint properties
|
||||
if (map.getLayer(WERKGEBIED_FILL_LAYER_ID)) {
|
||||
// Update layer paint properties (use safe getLayer)
|
||||
if (safeGetLayer(map, WERKGEBIED_FILL_LAYER_ID)) {
|
||||
map.setPaintProperty(WERKGEBIED_FILL_LAYER_ID, 'fill-color', fillColor);
|
||||
map.setPaintProperty(WERKGEBIED_FILL_LAYER_ID, 'fill-opacity', fillOpacity);
|
||||
}
|
||||
if (map.getLayer(WERKGEBIED_LINE_LAYER_ID)) {
|
||||
if (safeGetLayer(map, WERKGEBIED_LINE_LAYER_ID)) {
|
||||
map.setPaintProperty(WERKGEBIED_LINE_LAYER_ID, 'line-color', lineColor);
|
||||
map.setPaintProperty(WERKGEBIED_LINE_LAYER_ID, 'line-width', lineWidth);
|
||||
}
|
||||
|
||||
// Ensure werkgebied layers are below institutions layer
|
||||
if (map.getLayer('institutions-circles')) {
|
||||
if (map.getLayer(WERKGEBIED_FILL_LAYER_ID)) {
|
||||
if (safeGetLayer(map, 'institutions-circles')) {
|
||||
if (safeGetLayer(map, WERKGEBIED_FILL_LAYER_ID)) {
|
||||
map.moveLayer(WERKGEBIED_FILL_LAYER_ID, 'institutions-circles');
|
||||
}
|
||||
if (map.getLayer(WERKGEBIED_LINE_LAYER_ID)) {
|
||||
if (safeGetLayer(map, WERKGEBIED_LINE_LAYER_ID)) {
|
||||
map.moveLayer(WERKGEBIED_LINE_LAYER_ID, 'institutions-circles');
|
||||
}
|
||||
}
|
||||
|
|
@ -788,22 +822,22 @@ export function useWerkgebiedMapLibre(map: maplibregl.Map | null): WerkgebiedHoo
|
|||
return false;
|
||||
}
|
||||
|
||||
// Apply service area styling (green instead of blue)
|
||||
if (map.getLayer(WERKGEBIED_FILL_LAYER_ID)) {
|
||||
// Apply service area styling (green instead of blue) - use safe getLayer
|
||||
if (safeGetLayer(map, WERKGEBIED_FILL_LAYER_ID)) {
|
||||
map.setPaintProperty(WERKGEBIED_FILL_LAYER_ID, 'fill-color', SERVICE_AREA_FILL_COLOR);
|
||||
map.setPaintProperty(WERKGEBIED_FILL_LAYER_ID, 'fill-opacity', SERVICE_AREA_FILL_OPACITY);
|
||||
}
|
||||
if (map.getLayer(WERKGEBIED_LINE_LAYER_ID)) {
|
||||
if (safeGetLayer(map, WERKGEBIED_LINE_LAYER_ID)) {
|
||||
map.setPaintProperty(WERKGEBIED_LINE_LAYER_ID, 'line-color', SERVICE_AREA_LINE_COLOR);
|
||||
map.setPaintProperty(WERKGEBIED_LINE_LAYER_ID, 'line-width', SERVICE_AREA_LINE_WIDTH);
|
||||
}
|
||||
|
||||
// Ensure layers are below institution markers
|
||||
if (map.getLayer('institutions-circles')) {
|
||||
if (map.getLayer(WERKGEBIED_FILL_LAYER_ID)) {
|
||||
if (safeGetLayer(map, 'institutions-circles')) {
|
||||
if (safeGetLayer(map, WERKGEBIED_FILL_LAYER_ID)) {
|
||||
map.moveLayer(WERKGEBIED_FILL_LAYER_ID, 'institutions-circles');
|
||||
}
|
||||
if (map.getLayer(WERKGEBIED_LINE_LAYER_ID)) {
|
||||
if (safeGetLayer(map, WERKGEBIED_LINE_LAYER_ID)) {
|
||||
map.moveLayer(WERKGEBIED_LINE_LAYER_ID, 'institutions-circles');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ const DATABASES: DatabaseInfo[] = [
|
|||
nl: 'Kennisgraaf voor complexe erfgoedrelaties',
|
||||
en: 'Knowledge graph for complex heritage relationships',
|
||||
},
|
||||
icon: '🧠',
|
||||
icon: '🔷',
|
||||
color: '#6B5CE7',
|
||||
},
|
||||
{
|
||||
|
|
@ -208,7 +208,7 @@ export function Database() {
|
|||
</div>
|
||||
</div>
|
||||
<div className="db-type-card" style={{ borderLeftColor: '#6B5CE7' }}>
|
||||
<span className="db-type-icon">🧠</span>
|
||||
<span className="db-type-icon">🔷</span>
|
||||
<div>
|
||||
<strong>TypeDB</strong>
|
||||
<p>{t('graphDescription')}</p>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { useFullscreen, useCollapsibleHeader } from '../hooks/useCollapsibleHead
|
|||
import { useWerkgebiedMapLibre, type ServiceArea } from '../hooks/useWerkgebiedMapLibre';
|
||||
import { useDuckLakeInstitutions } from '../hooks/useDuckLakeInstitutions';
|
||||
import { InstitutionInfoPanel, type Institution } from '../components/map/InstitutionInfoPanel';
|
||||
import { TimelineSlider, type TemporalData } from '../components/map/TimelineSlider';
|
||||
import { LoadingScreen } from '../components/LoadingScreen';
|
||||
import type { GenealogiewerkbalkData } from '../types/werkgebied';
|
||||
import { Menu, X, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
|
|
@ -42,43 +43,49 @@ const SEARCH_TEXT = {
|
|||
hideHeader: { nl: 'Verberg kop', en: 'Hide header' },
|
||||
};
|
||||
|
||||
// Institution type colors matching the GLAMORCUBESFIXPHDNT taxonomy
|
||||
// Custodian type colors matching the GLAMORCUBESFIXPHDNT taxonomy (19 types)
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
M: '#e74c3c', // Museum - red
|
||||
A: '#3498db', // Archive - blue
|
||||
G: '#00bcd4', // Gallery - cyan
|
||||
L: '#2ecc71', // Library - green
|
||||
S: '#9b59b6', // Society - purple
|
||||
A: '#3498db', // Archive - blue
|
||||
M: '#e74c3c', // Museum - red
|
||||
O: '#f39c12', // Official - orange
|
||||
R: '#1abc9c', // Research - teal
|
||||
D: '#34495e', // Digital - dark gray
|
||||
F: '#95a5a6', // Features - gray
|
||||
N: '#e91e63', // NGO - pink
|
||||
C: '#795548', // Corporation - brown
|
||||
U: '#9e9e9e', // Unknown - gray
|
||||
B: '#4caf50', // Botanical - green
|
||||
E: '#ff9800', // Education - amber
|
||||
S: '#9b59b6', // Society - purple
|
||||
F: '#95a5a6', // Features - gray
|
||||
I: '#673ab7', // Intangible - deep purple
|
||||
C: '#795548', // Corporation - brown
|
||||
X: '#607d8b', // Mixed - blue gray
|
||||
P: '#8bc34a', // Personal - light green
|
||||
H: '#607d8b', // Holy sites - blue gray
|
||||
D: '#34495e', // Digital - dark gray
|
||||
N: '#e91e63', // NGO - pink
|
||||
T: '#ff5722', // Taste/smell - deep orange
|
||||
G: '#00bcd4', // Gallery - cyan
|
||||
};
|
||||
|
||||
const TYPE_NAMES: Record<string, { nl: string; en: string }> = {
|
||||
M: { nl: 'Museum', en: 'Museum' },
|
||||
A: { nl: 'Archief', en: 'Archive' },
|
||||
G: { nl: 'Galerie', en: 'Gallery' },
|
||||
L: { nl: 'Bibliotheek', en: 'Library' },
|
||||
S: { nl: 'Vereniging', en: 'Society' },
|
||||
A: { nl: 'Archief', en: 'Archive' },
|
||||
M: { nl: 'Museum', en: 'Museum' },
|
||||
O: { nl: 'Officieel', en: 'Official' },
|
||||
R: { nl: 'Onderzoek', en: 'Research' },
|
||||
D: { nl: 'Digitaal', en: 'Digital' },
|
||||
F: { nl: 'Monumenten', en: 'Features' },
|
||||
N: { nl: 'NGO', en: 'NGO' },
|
||||
C: { nl: 'Bedrijf', en: 'Corporation' },
|
||||
U: { nl: 'Onbekend', en: 'Unknown' },
|
||||
B: { nl: 'Botanisch', en: 'Botanical' },
|
||||
E: { nl: 'Onderwijs', en: 'Education' },
|
||||
S: { nl: 'Vereniging', en: 'Society' },
|
||||
F: { nl: 'Monumenten', en: 'Features' },
|
||||
I: { nl: 'Immaterieel', en: 'Intangible' },
|
||||
C: { nl: 'Bedrijf', en: 'Corporation' },
|
||||
X: { nl: 'Gemengd', en: 'Mixed' },
|
||||
P: { nl: 'Persoonlijk', en: 'Personal' },
|
||||
H: { nl: 'Heilige plaatsen', en: 'Holy sites' },
|
||||
D: { nl: 'Digitaal', en: 'Digital' },
|
||||
N: { nl: 'NGO', en: 'NGO' },
|
||||
T: { nl: 'Smaak/geur', en: 'Taste/smell' },
|
||||
G: { nl: 'Galerie', en: 'Gallery' },
|
||||
};
|
||||
|
||||
// Map tile styles for light and dark modes
|
||||
|
|
@ -228,6 +235,17 @@ export default function NDEMapPage() {
|
|||
const [selectedCities, setSelectedCities] = useState<Set<string>>(new Set());
|
||||
const [selectedMinRating, setSelectedMinRating] = useState<number | null>(null);
|
||||
|
||||
// Timeline state
|
||||
const MIN_YEAR = 1400;
|
||||
const MAX_YEAR = new Date().getFullYear();
|
||||
const [timelineRange, setTimelineRange] = useState<[number, number]>([MIN_YEAR, MAX_YEAR]);
|
||||
const [isTimelineActive, setIsTimelineActive] = useState(false);
|
||||
|
||||
// DEBUG: Log isTimelineActive state changes
|
||||
useEffect(() => {
|
||||
console.log(`[Timeline State] isTimelineActive changed to: ${isTimelineActive}`);
|
||||
}, [isTimelineActive]);
|
||||
|
||||
// Selected institution state
|
||||
const [selectedInstitution, setSelectedInstitution] = useState<Institution | null>(null);
|
||||
const [markerScreenPosition, setMarkerScreenPosition] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||
|
|
@ -433,6 +451,45 @@ export default function NDEMapPage() {
|
|||
return { dataSources, provinces, cities, ratingBuckets };
|
||||
}, [institutions]);
|
||||
|
||||
// Prepare temporal data for timeline component
|
||||
// Now uses founding_year and dissolution_year directly from Institution object
|
||||
// (extracted from DuckLake temporal columns in useDuckLakeInstitutions)
|
||||
const institutionsWithTemporal = useMemo(() => {
|
||||
return institutions.map(inst => {
|
||||
// Use pre-extracted years from DuckLake (already parsed in useDuckLakeInstitutions)
|
||||
const foundingYear = inst.founding_year;
|
||||
const foundingDecade = inst.founding_decade;
|
||||
const dissolutionYear = inst.dissolution_year;
|
||||
|
||||
// Fallback to temporal_extent for is_operational/is_defunct flags
|
||||
const temporalExtent = inst.temporal_extent;
|
||||
|
||||
const temporal: TemporalData = {
|
||||
founding_year: foundingYear,
|
||||
founding_decade: foundingDecade,
|
||||
dissolution_year: dissolutionYear,
|
||||
is_operational: temporalExtent?.is_operational,
|
||||
is_defunct: temporalExtent?.is_defunct,
|
||||
};
|
||||
|
||||
return {
|
||||
name: inst.name,
|
||||
temporal,
|
||||
// Keep reference to original for filtering
|
||||
_original: inst,
|
||||
};
|
||||
});
|
||||
}, [institutions]);
|
||||
|
||||
// DEBUG: Log temporal data being passed to timeline
|
||||
useEffect(() => {
|
||||
const withTemporal = institutionsWithTemporal.filter(i => i.temporal?.founding_year || i.temporal?.dissolution_year);
|
||||
console.log(`[Timeline] institutionsWithTemporal: ${institutionsWithTemporal.length} total, ${withTemporal.length} with temporal data`);
|
||||
if (withTemporal.length > 0 && withTemporal.length <= 10) {
|
||||
withTemporal.forEach(i => console.log(` - ${i.name}: founding=${i.temporal?.founding_year}, dissolution=${i.temporal?.dissolution_year}`));
|
||||
}
|
||||
}, [institutionsWithTemporal]);
|
||||
|
||||
// Close search results on outside click
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
|
|
@ -576,21 +633,52 @@ export default function NDEMapPage() {
|
|||
setSelectedProvinces(new Set());
|
||||
setSelectedCities(new Set());
|
||||
setSelectedMinRating(null);
|
||||
setIsTimelineActive(false);
|
||||
setTimelineRange([MIN_YEAR, MAX_YEAR]);
|
||||
};
|
||||
|
||||
const hasUrlFilters = provinceFilter || typeFilter || cityFilter || highlightName || ghcidFilter ||
|
||||
enrichedFilter || sourceFilter || hasFilter || minRatingFilter || wikidataTypeFilter || foundingDecadeFilter;
|
||||
|
||||
const hasSidebarFilters = selectedProvinces.size > 0 || selectedCities.size > 0 ||
|
||||
selectedSources.size > 0 || selectedMinRating !== null;
|
||||
selectedSources.size > 0 || selectedMinRating !== null || isTimelineActive;
|
||||
|
||||
const hasAnyFilters = hasUrlFilters || hasSidebarFilters;
|
||||
|
||||
// Filtered institutions for the map
|
||||
const filteredInstitutions = useMemo(() => {
|
||||
return institutions.filter((inst) => {
|
||||
// DEBUG: Log when filter runs and timeline state
|
||||
console.log(`[filteredInstitutions] Running filter: isTimelineActive=${isTimelineActive}, timelineRange=[${timelineRange[0]}, ${timelineRange[1]}], total=${institutions.length}`);
|
||||
|
||||
let timelineFilteredCount = 0;
|
||||
const result = institutions.filter((inst) => {
|
||||
if (!selectedTypes.has(inst.type)) return false;
|
||||
|
||||
// Timeline filter - use founding_year and dissolution_year directly from Institution
|
||||
// (already extracted from DuckLake temporal columns in useDuckLakeInstitutions)
|
||||
if (isTimelineActive) {
|
||||
const foundingYear = inst.founding_year || inst.founding_decade;
|
||||
const dissolutionYear = inst.dissolution_year;
|
||||
|
||||
// If no temporal data at all, HIDE the institution when timeline filter is active
|
||||
if (!foundingYear && !dissolutionYear) {
|
||||
timelineFilteredCount++;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Institution is visible if:
|
||||
// 1. Founded before or during the selected end year
|
||||
if (foundingYear && foundingYear > timelineRange[1]) {
|
||||
timelineFilteredCount++;
|
||||
return false;
|
||||
}
|
||||
// 2. AND (still operational OR dissolved after the selected start year)
|
||||
if (dissolutionYear && dissolutionYear < timelineRange[0]) {
|
||||
timelineFilteredCount++;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Province filter
|
||||
if (provinceFilter && inst.province !== provinceFilter) return false;
|
||||
if (selectedProvinces.size > 0 && inst.province && !selectedProvinces.has(inst.province)) return false;
|
||||
|
|
@ -652,9 +740,17 @@ export default function NDEMapPage() {
|
|||
|
||||
return true;
|
||||
});
|
||||
|
||||
// DEBUG: Log filter results
|
||||
if (isTimelineActive) {
|
||||
console.log(`[filteredInstitutions] Timeline filter: ${timelineFilteredCount} institutions hidden, ${result.length} visible`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [institutions, selectedTypes, provinceFilter, typeFilter, cityFilter, enrichedFilter,
|
||||
sourceFilter, hasFilter, minRatingFilter, wikidataTypeFilter, foundingDecadeFilter,
|
||||
selectedProvinces, selectedCities, selectedSources, selectedMinRating]);
|
||||
selectedProvinces, selectedCities, selectedSources, selectedMinRating,
|
||||
isTimelineActive, timelineRange]);
|
||||
|
||||
const visibleCount = filteredInstitutions.length;
|
||||
|
||||
|
|
@ -672,7 +768,7 @@ export default function NDEMapPage() {
|
|||
|
||||
// If DuckLake is connected and has data, use it exclusively
|
||||
if (duckLakeData.isConnected && duckLakeData.institutions.length > 0) {
|
||||
console.log(`[NDEMapPage] Using DuckLake data: ${duckLakeData.institutions.length} institutions`);
|
||||
console.log(`[NDEMapPage] Using DuckLake data: ${duckLakeData.institutions.length} custodians`);
|
||||
setInstitutions(duckLakeData.institutions);
|
||||
|
||||
const byType: Record<string, number> = {};
|
||||
|
|
@ -989,8 +1085,13 @@ export default function NDEMapPage() {
|
|||
|
||||
// Cleanup: remove click handler when effect re-runs or unmounts
|
||||
return () => {
|
||||
if (map && map.getLayer('institutions-circles')) {
|
||||
map.off('click', 'institutions-circles', handleClick);
|
||||
try {
|
||||
// Check if map still exists and has the layer before removing handler
|
||||
if (map && mapInstanceRef.current && map.getLayer('institutions-circles')) {
|
||||
map.off('click', 'institutions-circles', handleClick);
|
||||
}
|
||||
} catch {
|
||||
// Map may already be destroyed during navigation
|
||||
}
|
||||
};
|
||||
}, [mapReady]);
|
||||
|
|
@ -1541,6 +1642,20 @@ export default function NDEMapPage() {
|
|||
|
||||
<div className="map-wrapper">
|
||||
<div ref={mapRef} className="maplibre-map" style={{ width: '100%', height: '100%' }}></div>
|
||||
|
||||
{/* Timeline Slider */}
|
||||
<TimelineSlider
|
||||
institutions={institutionsWithTemporal}
|
||||
selectedRange={timelineRange}
|
||||
onRangeChange={setTimelineRange}
|
||||
isActive={isTimelineActive}
|
||||
onToggleActive={() => {
|
||||
console.log(`[Timeline Toggle] Clicked! Current isTimelineActive=${isTimelineActive}, setting to ${!isTimelineActive}`);
|
||||
setIsTimelineActive(!isTimelineActive);
|
||||
}}
|
||||
t={t}
|
||||
language={language}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue