glam/frontend/src/components/database/OxigraphPanel.tsx
kempersc 13f67bed19 feat(frontend): add graph visualization and data explorer features
Database Panels:
- Add D3.js force-directed graph visualization to Oxigraph and TypeDB panels
- Add 'Explore' tab with class/entity browser, graph/table toggle, and search
- Add data explorer to PostgreSQL panel with table browser, pagination, search, export
- Fix SPARQL variable naming bug in Oxigraph getGraphData() function
- Add node details panel showing selected entity attributes
- Add zoom/pan controls and node coloring by entity type

Map Features:
- Add TimelineSlider component for temporal filtering of institutions
- Support dual-handle range slider with decade histogram
- Add quick presets (Ancient, Medieval, Modern, Contemporary)
- Show institution density visualization by founding decade

Hooks:
- Extend useOxigraph with getGraphData() for graph visualization
- Extend useTypeDB with getGraphData() for graph visualization
- Extend usePostgreSQL with getTableData() and exportTableData()
- Improve useDuckLakeInstitutions with temporal filtering support

Styles:
- Add HeritageDashboard.css with shared panel styling
- Add TimelineSlider.css for timeline component styling
2025-12-08 14:56:17 +01:00

875 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Oxigraph Panel Component
* SPARQL triplestore for Linked Data
*/
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 {
compact?: boolean;
}
const TEXT = {
title: { nl: 'Oxigraph Triplestore', en: 'Oxigraph Triplestore' },
description: {
nl: 'SPARQL-endpoint voor Linked Data en RDF-queries',
en: 'SPARQL endpoint for Linked Data and RDF queries',
},
connected: { nl: 'Verbonden', en: 'Connected' },
disconnected: { nl: 'Niet verbonden', en: 'Disconnected' },
loading: { nl: 'Laden...', en: 'Loading...' },
refresh: { nl: 'Verversen', en: 'Refresh' },
triples: { nl: 'Triples', en: 'Triples' },
graphs: { nl: 'Graphs', en: 'Graphs' },
classes: { nl: 'Klassen', en: 'Classes' },
predicates: { nl: 'Predicaten', en: 'Predicates' },
namespaces: { nl: 'Namespaces', en: 'Namespaces' },
responseTime: { nl: 'Responstijd', en: 'Response time' },
noData: { nl: 'Geen data gevonden.', en: 'No data found.' },
runQuery: { nl: 'Query uitvoeren', en: 'Run Query' },
enterQuery: { nl: 'Voer SPARQL-query in...', en: 'Enter SPARQL query...' },
running: { nl: 'Uitvoeren...', en: 'Running...' },
uploadData: { nl: 'Data uploaden', en: 'Upload Data' },
uploadDescription: {
nl: 'Laad RDF-data in de triplestore',
en: 'Load RDF data into the triplestore',
},
rdfFormat: { nl: 'Formaat', en: 'Format' },
targetGraph: { nl: 'Doelgraph', en: 'Target Graph' },
graphName: { nl: 'Graph', en: 'Graph' },
tripleCount: { nl: 'Triples', en: 'Triples' },
export: { nl: 'Exporteren', en: 'Export' },
clearAll: { nl: 'Alles wissen', en: 'Clear All' },
confirmClear: {
nl: 'Weet u zeker dat u alle data wilt wissen?',
en: 'Are you sure you want to clear all data?',
},
prefix: { nl: 'Prefix', en: 'Prefix' },
namespace: { nl: 'Namespace', en: 'Namespace' },
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' },
};
/**
* Shorten a URI for display by replacing common prefixes
*/
function 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://www.w3.org/2001/XMLSchema#': 'xsd:',
'http://www.w3.org/2004/02/skos/core#': 'skos:',
'http://purl.org/dc/terms/': 'dct:',
'http://purl.org/dc/elements/1.1/': 'dc:',
'http://xmlns.com/foaf/0.1/': 'foaf:',
'http://schema.org/': 'schema:',
'http://www.w3.org/ns/prov#': 'prov:',
'http://www.cidoc-crm.org/cidoc-crm/': 'crm:',
'https://www.ica.org/standards/RiC/ontology#': 'rico:',
'http://data.europa.eu/m8g/': 'cpov:',
'https://identifier.overheid.nl/tooi/def/ont/': 'tooi:',
'http://www.w3.org/ns/org#': 'org:',
'https://w3id.org/heritage/custodian/': 'hc:',
};
for (const [namespace, prefix] of Object.entries(prefixes)) {
if (uri.startsWith(namespace)) {
return prefix + uri.slice(namespace.length);
}
}
const lastPart = uri.split(/[#/]/).pop();
if (lastPart && lastPart.length < uri.length - 10) {
return '...' + lastPart;
}
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];
const {
status,
stats,
isLoading,
error,
refresh,
executeSparql,
loadRdfData,
clearGraph,
exportGraph,
getGraphData,
} = useOxigraph();
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);
const [uploadFormat, setUploadFormat] = useState('ttl');
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);
try {
const result = await executeSparql(query);
setQueryResult(JSON.stringify(result, null, 2));
} catch (err) {
setQueryResult(`Error: ${err instanceof Error ? err.message : 'Unknown error'}`);
} finally {
setIsQueryRunning(false);
}
};
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
try {
const content = await file.text();
await loadRdfData(content, uploadFormat, uploadGraph || undefined);
alert(`Successfully loaded ${file.name}`);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
} catch (err) {
alert(`Failed to load file: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
};
const handleExport = async (graphName?: string) => {
try {
const data = await exportGraph(graphName, 'ttl');
const blob = new Blob([data], { type: 'text/turtle' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = graphName ? `${graphName.split('/').pop()}.ttl` : 'export.ttl';
a.click();
URL.revokeObjectURL(url);
} catch (err) {
alert(`Export failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
};
const handleClearAll = async () => {
if (window.confirm(t('confirmClear'))) {
try {
await clearGraph();
alert('All data cleared');
} catch (err) {
alert(`Clear failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
}
};
// 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 (
<div className="db-panel compact oxigraph-panel">
<div className="panel-header">
<span className="panel-icon">🔗</span>
<h3>Oxigraph</h3>
<span className={`status-badge ${status.isConnected ? 'connected' : 'disconnected'}`}>
{isLoading ? t('loading') : status.isConnected ? t('connected') : t('disconnected')}
</span>
</div>
<div className="panel-stats">
<div className="stat">
<span className="stat-value">{stats?.totalTriples?.toLocaleString() ?? 0}</span>
<span className="stat-label">{t('triples')}</span>
</div>
<div className="stat">
<span className="stat-value">{stats?.totalGraphs ?? 0}</span>
<span className="stat-label">{t('graphs')}</span>
</div>
<div className="stat">
<span className="stat-value">{stats?.classes.length ?? 0}</span>
<span className="stat-label">{t('classes')}</span>
</div>
</div>
{error && <div className="panel-error">{error.message}</div>}
</div>
);
}
// Full view
return (
<div className="db-panel full oxigraph-panel">
{/* Header */}
<div className="panel-header-full">
<div className="header-info">
<span className="panel-icon-large">🔗</span>
<div>
<h2>{t('title')}</h2>
<p>{t('description')}</p>
</div>
</div>
<div className="header-actions">
<span className={`status-badge large ${status.isConnected ? 'connected' : 'disconnected'}`}>
{isLoading ? t('loading') : status.isConnected ? t('connected') : t('disconnected')}
</span>
<button onClick={refresh} disabled={isLoading} className="refresh-button">
{t('refresh')}
</button>
</div>
</div>
{/* Stats Overview */}
<div className="stats-row">
<div className="stat-card">
<span className="stat-value">{stats?.totalTriples?.toLocaleString() ?? 0}</span>
<span className="stat-label">{t('triples')}</span>
</div>
<div className="stat-card">
<span className="stat-value">{stats?.totalGraphs ?? 0}</span>
<span className="stat-label">{t('graphs')}</span>
</div>
<div className="stat-card">
<span className="stat-value">{stats?.classes.length ?? 0}</span>
<span className="stat-label">{t('classes')}</span>
</div>
<div className="stat-card">
<span className="stat-value">{stats?.predicates.length ?? 0}</span>
<span className="stat-label">{t('predicates')}</span>
</div>
{status.responseTimeMs && (
<div className="stat-card">
<span className="stat-value">{status.responseTimeMs}ms</span>
<span className="stat-label">{t('responseTime')}</span>
</div>
)}
</div>
{error && (
<div className="error-banner">
<strong>Error:</strong> {error.message}
</div>
)}
{/* 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')}
>
{t('graphs')} ({stats?.graphs.length ?? 0})
</button>
<button
className={`tab-btn ${activeTab === 'classes' ? 'active' : ''}`}
onClick={() => setActiveTab('classes')}
>
{t('classes')} ({stats?.classes.length ?? 0})
</button>
<button
className={`tab-btn ${activeTab === 'predicates' ? 'active' : ''}`}
onClick={() => setActiveTab('predicates')}
>
{t('predicates')} ({stats?.predicates.length ?? 0})
</button>
<button
className={`tab-btn ${activeTab === 'namespaces' ? 'active' : ''}`}
onClick={() => setActiveTab('namespaces')}
>
{t('namespaces')}
</button>
<button
className={`tab-btn ${activeTab === 'query' ? 'active' : ''}`}
onClick={() => setActiveTab('query')}
>
{t('runQuery')}
</button>
<button
className={`tab-btn ${activeTab === 'upload' ? 'active' : ''}`}
onClick={() => setActiveTab('upload')}
>
{t('uploadData')}
</button>
</div>
{/* 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 ? (
<p className="empty-message">{t('noData')}</p>
) : (
<>
<div className="action-bar">
<button onClick={() => handleExport()} className="secondary-button">
{t('export')} Default Graph
</button>
<button onClick={handleClearAll} className="danger-button">
{t('clearAll')}
</button>
</div>
<table className="data-table">
<thead>
<tr>
<th>{t('graphName')}</th>
<th>{t('tripleCount')}</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{stats.graphs.map((graph, index) => (
<tr key={index}>
<td><code title={graph.name}>{shortenUri(graph.name)}</code></td>
<td>{graph.tripleCount.toLocaleString()}</td>
<td>
<button
className="small-button"
onClick={() => handleExport(graph.name === '(default graph)' ? undefined : graph.name)}
>
{t('export')}
</button>
</td>
</tr>
))}
</tbody>
</table>
</>
)}
</div>
)}
{activeTab === 'classes' && (
<div className="classes-list">
{!stats?.classes.length ? (
<p className="empty-message">{t('noData')}</p>
) : (
<table className="data-table">
<thead>
<tr>
<th>{t('class')}</th>
<th>{t('count')}</th>
</tr>
</thead>
<tbody>
{stats.classes.map((cls, index) => (
<tr key={index}>
<td><code title={cls.class}>{shortenUri(cls.class)}</code></td>
<td>{cls.count.toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
{activeTab === 'predicates' && (
<div className="predicates-list">
{!stats?.predicates.length ? (
<p className="empty-message">{t('noData')}</p>
) : (
<table className="data-table">
<thead>
<tr>
<th>{t('predicate')}</th>
<th>{t('count')}</th>
</tr>
</thead>
<tbody>
{stats.predicates.map((pred, index) => (
<tr key={index}>
<td><code title={pred.predicate}>{shortenUri(pred.predicate)}</code></td>
<td>{pred.count.toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
{activeTab === 'namespaces' && (
<div className="namespaces-list">
{!stats?.namespaces.length ? (
<p className="empty-message">{t('noData')}</p>
) : (
<table className="data-table">
<thead>
<tr>
<th>{t('prefix')}</th>
<th>{t('namespace')}</th>
<th>{t('count')}</th>
</tr>
</thead>
<tbody>
{stats.namespaces.map((ns, index) => (
<tr key={index}>
<td><strong>{ns.prefix}</strong></td>
<td><code className="namespace-uri">{ns.namespace}</code></td>
<td>{ns.count.toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
{activeTab === 'query' && (
<div className="query-section">
<textarea
value={query}
onChange={(e) => setQuery(e.target.value)}
className="query-input"
rows={6}
placeholder={t('enterQuery')}
/>
<button
onClick={handleRunQuery}
disabled={isQueryRunning || !status.isConnected}
className="primary-button"
>
{isQueryRunning ? t('running') : t('runQuery')}
</button>
{queryResult && <pre className="query-result">{queryResult}</pre>}
</div>
)}
{activeTab === 'upload' && (
<div className="upload-section">
<p>{t('uploadDescription')}</p>
<div className="form-group">
<label>{t('rdfFormat')}:</label>
<select
value={uploadFormat}
onChange={(e) => setUploadFormat(e.target.value)}
className="format-select"
>
<option value="ttl">Turtle (.ttl)</option>
<option value="nt">N-Triples (.nt)</option>
<option value="rdf">RDF/XML (.rdf, .owl)</option>
<option value="jsonld">JSON-LD (.jsonld)</option>
<option value="trig">TriG (.trig)</option>
<option value="nq">N-Quads (.nq)</option>
</select>
</div>
<div className="form-group">
<label>{t('targetGraph')}:</label>
<input
type="text"
value={uploadGraph}
onChange={(e) => setUploadGraph(e.target.value)}
placeholder="https://example.org/graph/my-data"
className="text-input"
/>
</div>
<div className="form-group">
<label>File:</label>
<input
type="file"
ref={fileInputRef}
onChange={handleFileUpload}
accept=".ttl,.nt,.rdf,.owl,.jsonld,.json,.n3,.trig,.nq"
className="file-input"
/>
</div>
</div>
)}
</div>
</div>
);
}