/** * 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 = { '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; } // D3 link interface interface D3Link extends d3.SimulationLinkDatum { id: string; predicate: string; predicateLabel: string; } // ============================================================================ // Graph Visualization Component for Oxigraph // ============================================================================ interface OxigraphGraphVisualizationProps { data: OxigraphGraphData; width?: number; height?: number; onNodeClick?: (node: OxigraphGraphNode) => void; } function OxigraphGraphVisualization({ data, width = 800, height = 500, onNodeClick }: OxigraphGraphVisualizationProps) { const svgRef = useRef(null); const getNodeColor = useCallback((entityType: string): string => { const colors: Record = { 'schema:Museum': '#e74c3c', 'schema:Library': '#3498db', 'schema:ArchiveOrganization': '#2ecc71', 'hc:HeritageCustodian': '#9b59b6', 'cpov:PublicOrganisation': '#1abc9c', 'schema:Place': '#16a085', 'schema:Organization': '#f39c12', 'crm:E74_Group': '#e67e22', 'rico:CorporateBody': '#d35400', }; return colors[entityType] || '#95a5a6'; }, []); useEffect(() => { if (!svgRef.current || data.nodes.length === 0) return; const svg = d3.select(svgRef.current); svg.selectAll('*').remove(); // Create container with zoom const container = svg.append('g').attr('class', 'graph-container'); // Arrow marker const defs = svg.append('defs'); defs.append('marker') .attr('id', 'oxigraph-arrow') .attr('viewBox', '0 -5 10 10') .attr('refX', 25) .attr('refY', 0) .attr('markerWidth', 6) .attr('markerHeight', 6) .attr('orient', 'auto') .append('path') .attr('d', 'M0,-5L10,0L0,5') .attr('fill', '#999'); // Create D3 nodes and links const d3Nodes: D3Node[] = data.nodes.map(n => ({ ...n, x: width / 2 + (Math.random() - 0.5) * 200, y: height / 2 + (Math.random() - 0.5) * 200, })); const nodeMap = new Map(d3Nodes.map(n => [n.id, n])); const d3Links: D3Link[] = data.edges .filter(e => nodeMap.has(e.source) && nodeMap.has(e.target)) .map(e => ({ id: e.id, source: nodeMap.get(e.source)!, target: nodeMap.get(e.target)!, predicate: e.predicate, predicateLabel: e.predicateLabel, })); // Force simulation const simulation = d3.forceSimulation(d3Nodes) .force('link', d3.forceLink(d3Links).id(d => d.id).distance(120)) .force('charge', d3.forceManyBody().strength(-400)) .force('center', d3.forceCenter(width / 2, height / 2)) .force('collision', d3.forceCollide().radius(40)); // Links const link = container.append('g') .attr('class', 'links') .selectAll('line') .data(d3Links) .join('line') .attr('stroke', '#999') .attr('stroke-width', 2) .attr('stroke-opacity', 0.6) .attr('marker-end', 'url(#oxigraph-arrow)'); // Link labels const linkLabel = container.append('g') .attr('class', 'link-labels') .selectAll('text') .data(d3Links) .join('text') .attr('font-size', 9) .attr('fill', '#666') .attr('text-anchor', 'middle') .text(d => d.predicateLabel); // Nodes const node = container.append('g') .attr('class', 'nodes') .selectAll('circle') .data(d3Nodes) .join('circle') .attr('r', 15) .attr('fill', d => getNodeColor(d.entityType)) .attr('stroke', '#fff') .attr('stroke-width', 2) .style('cursor', 'pointer') .on('click', (_event, d) => { onNodeClick?.({ id: d.id, label: d.label, type: d.type, entityType: d.entityType, attributes: d.attributes, }); }) .call(d3.drag() .on('start', (event, d) => { if (!event.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; }) .on('drag', (event, d) => { d.fx = event.x; d.fy = event.y; }) .on('end', (event, d) => { if (!event.active) simulation.alphaTarget(0); d.fx = null; d.fy = null; }) as any); // Node labels const nodeLabel = container.append('g') .attr('class', 'node-labels') .selectAll('text') .data(d3Nodes) .join('text') .attr('font-size', 11) .attr('dx', 18) .attr('dy', 4) .attr('fill', '#333') .text(d => d.label.length > 20 ? d.label.substring(0, 17) + '...' : d.label); // Simulation tick simulation.on('tick', () => { link .attr('x1', d => (d.source as D3Node).x!) .attr('y1', d => (d.source as D3Node).y!) .attr('x2', d => (d.target as D3Node).x!) .attr('y2', d => (d.target as D3Node).y!); linkLabel .attr('x', d => ((d.source as D3Node).x! + (d.target as D3Node).x!) / 2) .attr('y', d => ((d.source as D3Node).y! + (d.target as D3Node).y!) / 2 - 5); node .attr('cx', d => d.x!) .attr('cy', d => d.y!); nodeLabel .attr('x', d => d.x!) .attr('y', d => d.y!); }); // Zoom behavior const zoom = d3.zoom() .scaleExtent([0.1, 4]) .on('zoom', (event) => { container.attr('transform', event.transform); }); svg.call(zoom as any); }, [data, width, height, getNodeColor, onNodeClick]); return ( ); } export function OxigraphPanel({ compact = false }: OxigraphPanelProps) { const { language } = useLanguage(); const t = (key: keyof typeof TEXT) => TEXT[key][language]; 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(null); const [isQueryRunning, setIsQueryRunning] = useState(false); const [uploadFormat, setUploadFormat] = useState('ttl'); const [uploadGraph, setUploadGraph] = useState(''); const fileInputRef = useRef(null); // Explore tab state const [selectedClass, setSelectedClass] = useState(null); const [exploreViewMode, setExploreViewMode] = useState<'graph' | 'table'>('graph'); const [graphData, setGraphData] = useState(null); const [isLoadingData, setIsLoadingData] = useState(false); const [searchTerm, setSearchTerm] = useState(''); const [selectedNode, setSelectedNode] = useState(null); const handleRunQuery = async () => { setIsQueryRunning(true); setQueryResult(null); 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) => { 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 (
🔗

Oxigraph

{isLoading ? t('loading') : status.isConnected ? t('connected') : t('disconnected')}
{stats?.totalTriples?.toLocaleString() ?? 0} {t('triples')}
{stats?.totalGraphs ?? 0} {t('graphs')}
{stats?.classes.length ?? 0} {t('classes')}
{error &&
{error.message}
}
); } // Full view return (
{/* Header */}
🔗

{t('title')}

{t('description')}

{isLoading ? t('loading') : status.isConnected ? t('connected') : t('disconnected')}
{/* Stats Overview */}
{stats?.totalTriples?.toLocaleString() ?? 0} {t('triples')}
{stats?.totalGraphs ?? 0} {t('graphs')}
{stats?.classes.length ?? 0} {t('classes')}
{stats?.predicates.length ?? 0} {t('predicates')}
{status.responseTimeMs && (
{status.responseTimeMs}ms {t('responseTime')}
)}
{error && (
Error: {error.message}
)} {/* Tabs */}
{/* Tab Content */}
{/* EXPLORE TAB - Graph Visualization */} {activeTab === 'explore' && (
{/* Class Selector and Controls */}
{graphData && (
{graphData.nodes.length} {t('nodes')} {graphData.edges.length} {t('edges')}
)}
{/* Data Viewer */} {graphData && (

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

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

{t('loading')}

) : !graphData?.nodes.length ? (

{t('noData')}

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

{selectedNode.label}

URI: {selectedNode.id}

Type: {selectedNode.entityType}

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

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

)}
)} {activeTab === 'graphs' && (
{!stats?.graphs.length ? (

{t('noData')}

) : ( <>
{stats.graphs.map((graph, index) => ( ))}
{t('graphName')} {t('tripleCount')} Actions
{shortenUri(graph.name)} {graph.tripleCount.toLocaleString()}
)}
)} {activeTab === 'classes' && (
{!stats?.classes.length ? (

{t('noData')}

) : ( {stats.classes.map((cls, index) => ( ))}
{t('class')} {t('count')}
{shortenUri(cls.class)} {cls.count.toLocaleString()}
)}
)} {activeTab === 'predicates' && (
{!stats?.predicates.length ? (

{t('noData')}

) : ( {stats.predicates.map((pred, index) => ( ))}
{t('predicate')} {t('count')}
{shortenUri(pred.predicate)} {pred.count.toLocaleString()}
)}
)} {activeTab === 'namespaces' && (
{!stats?.namespaces.length ? (

{t('noData')}

) : ( {stats.namespaces.map((ns, index) => ( ))}
{t('prefix')} {t('namespace')} {t('count')}
{ns.prefix} {ns.namespace} {ns.count.toLocaleString()}
)}
)} {activeTab === 'query' && (