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
875 lines
30 KiB
TypeScript
875 lines
30 KiB
TypeScript
/**
|
||
* 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>
|
||
);
|
||
}
|