UNPKG

atuin

Version:

An RDF and SPARQL editor with graph visualization

815 lines (715 loc) 22.3 kB
import { Network } from 'vis-network/standalone/esm/vis-network.js' import { DataSet } from 'vis-data/standalone/esm/vis-data.js' import { RDFParser } from './Parser.js' import { URIUtils } from '../utils/URIUtils.js' import { eventBus, EVENTS } from 'evb' import { Prefixes } from 'n3' import rdf from '@rdfjs/data-model' import PrefixMap from '@rdfjs/prefix-map' export class GraphVisualizer { constructor(container, logger) { this.container = container this.logger = logger this.parser = new RDFParser(logger) this.network = null this.nodes = new DataSet([]) this.edges = new DataSet([]) this.nodeSelectCallbacks = [] this.options = { defaultPrefixes: ['rdf', 'rdfs', 'owl'], labelMaxLength: 20, hidden: true, freeze: false } // Define consistent colors for different node types this.colors = { subject: { background: '#97B0F8', border: '#4466cc', highlight: { background: '#b3c6fa', border: '#5577dd' } }, object: { background: '#D5DDF6', border: '#97B0F8', highlight: { background: '#e8edfb', border: '#b3c6fa' } }, literal: { background: '#fff6a5', border: '#ffcc00', highlight: { background: '#fffbc2', border: '#ffdd33' } }, class: { background: '#ffb366', border: '#ff8000', highlight: { background: '#ffc28a', border: '#ff9933' } }, property: { background: '#ffff99', border: '#e6e600', highlight: { background: '#ffffb3', border: '#ffff33' } }, cluster: { background: '#ffce99', border: '#ff9c33', highlight: { background: '#ffe6cc', border: '#ffb566' } } } this.clusterIndex = 0 this.clusters = [] this.clusterLevel = 0 this.prefixes = {} this.triples = [] // Listen for Turtle model sync events eventBus.on(EVENTS.MODEL_SYNCED, (content) => { this.updateGraph(content) }) } initialize() { const data = { nodes: this.nodes, edges: this.edges } const options = { layout: { randomSeed: undefined, improvedLayout: true, clusterThreshold: 150, hierarchical: { enabled: false, levelSeparation: 150, nodeSpacing: 100, treeSpacing: 200, blockShifting: true, edgeMinimization: true, parentCentralization: true, direction: 'UD', sortMethod: 'hubsize', shakeTowards: 'leaves' } }, physics: { enabled: !this.options.freeze, barnesHut: { theta: 0.5, gravitationalConstant: -2000, centralGravity: 0.3, springLength: 95, springConstant: 0.04, damping: 0.09, avoidOverlap: 0 }, forceAtlas2Based: { theta: 0.5, gravitationalConstant: -50, centralGravity: 0.01, springConstant: 0.08, springLength: 100, damping: 0.4, avoidOverlap: 0 }, repulsion: { centralGravity: 0.2, springLength: 200, springConstant: 0.05, nodeDistance: 100, damping: 0.09 }, hierarchicalRepulsion: { centralGravity: 0.0, springLength: 100, springConstant: 0.01, nodeDistance: 120, damping: 0.09, avoidOverlap: 0 }, maxVelocity: 50, minVelocity: 0.1, solver: 'barnesHut', stabilization: { enabled: true, iterations: 1000, updateInterval: 100, onlyDynamicEdges: false, fit: true }, timestep: 0.5, adaptiveTimestep: true, wind: { x: 0, y: 0 } }, edges: { smooth: { type: 'continuous' }, font: { size: 12, face: 'Arial', background: 'rgba(255, 255, 255, 0.8)', strokeWidth: 0 }, color: { color: '#0077aa', highlight: '#0099cc' }, width: 1.5, selectionWidth: 2.5 }, nodes: { shape: 'ellipse', size: 10, font: { size: 12, face: 'Arial', strokeWidth: 0 }, borderWidth: 1.5, shadow: { enabled: false } }, groups: { subject: { shape: 'ellipse', color: this.colors.subject }, object: { shape: 'ellipse', color: this.colors.object }, literal: { shape: 'box', borderRadius: 8, color: this.colors.literal }, class: { shape: 'box', borderRadius: 8, color: this.colors.class }, property: { shape: 'box', borderRadius: 8, color: this.colors.property } } } this.network = new Network(this.container, data, options) // Add a delay to let the container resize properly before fitting setTimeout(() => { this.network.fit({ animation: { duration: 1000, easingFunction: 'easeOutQuint' }, scale: 0.75 // Set a more reasonable initial zoom level }) }, 300) this.network.on('click', this._handleNetworkClick.bind(this)) this.logger.debug('Graph visualizer initialized') } async updateGraph(content) { try { const { triples, prefixes } = await this.parser.parseTriples(content) // Convert parsed prefixes to PrefixMap entries const prefixEntries = Object.entries(prefixes).map(([prefix, ns]) => [prefix, rdf.namedNode(ns)]) this.prefixMap = new PrefixMap(prefixEntries, { factory: rdf }) this.prefixes = { ...prefixes } this._inferPrefixes(triples) this.triples = triples this.clusterIndex = 0 this.clusters = [] this.clusterLevel = 0 this._createVisualization() if (this.options.hidden) { this._toggleHideDefaults() } } catch (error) { this.logger.error('Failed to update graph', error) } } // Try to infer common prefixes from the data _inferPrefixes(triples) { const uriCounts = new Map() // Count URI namespaces for (const triple of triples) { this._countNamespace(triple.subject, uriCounts) this._countNamespace(triple.predicate, uriCounts) this._countNamespace(triple.object, uriCounts) } // Add common namespaces we should know about if (!this.prefixes['ex'] && !this.prefixes['example']) { const exNs = 'http://example.org/' if ([...uriCounts.keys()].some(ns => ns.includes('example.org'))) { this.prefixes['ex'] = exNs } } // Look for other common namespaces that might be missing a prefix const commonNamespaces = [...uriCounts.entries()] .filter(([ns, count]) => count >= 2 && !this._hasPrefix(ns)) .sort((a, b) => b[1] - a[1]) // Create prefixes for common namespaces for (let i = 0; i < commonNamespaces.length; i++) { const ns = commonNamespaces[i][0] if (ns.includes('example.org')) { this.prefixes['ex'] = ns } else if (ns.includes('schema.org')) { this.prefixes['schema'] = ns } else { // Extract a reasonable prefix from the namespace const prefixMatch = /\/\/([^./]+)/.exec(ns) || /\/([^./]+)/.exec(ns) if (prefixMatch) { const suggestedPrefix = prefixMatch[1].toLowerCase() if (!this.prefixes[suggestedPrefix]) { this.prefixes[suggestedPrefix] = ns } } } } } _countNamespace(term, uriCounts) { if (!term || URIUtils.isLiteral(term)) return const uri = term.value || term if (typeof uri !== 'string') return const parts = URIUtils.splitNamespace(uri) if (parts.namespace) { uriCounts.set(parts.namespace, (uriCounts.get(parts.namespace) || 0) + 1) } } _hasPrefix(namespace) { return Object.values(this.prefixes).some(ns => ns === namespace) } _createVisualization() { this.nodes.clear() this.edges.clear() const classes = new Set() const properties = new Set() // Enhanced handling of RDF type triples const rdfType = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type' const propertyTypes = [ 'http://www.w3.org/1999/02/22-rdf-syntax-ns#Property', 'http://www.w3.org/2000/01/rdf-schema#Property', 'http://www.w3.org/2002/07/owl#ObjectProperty', 'http://www.w3.org/2002/07/owl#DatatypeProperty' ] const classTypes = [ 'http://www.w3.org/2000/01/rdf-schema#Class', 'http://www.w3.org/2002/07/owl#Class' ] // First pass: identify classes and properties for (const triple of this.triples) { const subject = triple.subject const predicate = triple.predicate const object = triple.object // Check for class and property type declarations if (predicate.value === rdfType) { if (propertyTypes.includes(object.value)) { properties.add(subject.value) } else if (classTypes.includes(object.value)) { classes.add(subject.value) } } // Add properties directly - this allows us to identify them even without explicit typing if (predicate.value !== rdfType) { properties.add(predicate.value) } } // Second pass: create nodes and edges for (const triple of this.triples) { const subject = triple.subject const predicate = triple.predicate const object = triple.object // Create subject node if it doesn't exist if (!this.nodes.get(subject.value)) { let nodeType if (URIUtils.isLiteral(subject)) { nodeType = 'literal' } else if (classes.has(subject.value)) { nodeType = 'class' } else if (properties.has(subject.value)) { nodeType = 'property' } else { nodeType = 'subject' } this.nodes.add(this._createNode(subject.value, nodeType)) } // Create property node (for visualization debugging) // Commented out since we don't normally show predicates as nodes // if (!this.nodes.get(predicate.value) && predicate.value !== rdfType) { // this.nodes.add(this._createNode(predicate.value, 'property')) // } // Create object node if it doesn't exist if (!this.nodes.get(object.value)) { let nodeType if (URIUtils.isLiteral(object)) { nodeType = 'literal' } else if (classes.has(object.value)) { nodeType = 'class' } else if (properties.has(object.value)) { nodeType = 'property' } else { nodeType = 'object' } this.nodes.add(this._createNode(object.value, nodeType)) } // Create edge with proper label this._createEdge(subject.value, predicate.value, object.value) } // Automatic clustering for large graphs if (this.triples.length > 500) { this._makeClusters() } if (this.triples.length > 1000) { this._makeClusters() } } _createNode(id, type) { let label let originalId = id // Handle term objects by extracting their value if (id && typeof id === 'object' && id.value) { id = id.value } if (URIUtils.isLiteral(id)) { // For literals, use the literal value but truncate if needed // Strip quotes from literals for display if (typeof id === 'string' && id.startsWith('"') && id.endsWith('"')) { label = id.substring(1, id.length - 1) } else { label = id } if (label.length > this.options.labelMaxLength) { label = label.substring(0, this.options.labelMaxLength - 3) + '...' } } else { // Use PrefixMap for prefix abbreviation, fallback to local name let nn = typeof id === 'string' ? rdf.namedNode(id) : id const shrunk = this.prefixMap.shrink(nn) if (shrunk && shrunk.value && shrunk.value.includes(':') && !shrunk.value.includes('://')) { label = shrunk.value // CURIE } else { // fallback: extract local name const uri = nn.value || nn const hash = uri.lastIndexOf('#') const slash = uri.lastIndexOf('/') const pos = Math.max(hash, slash) label = uri.substring(pos + 1) } // If the local name is still too long, truncate it if (label.length > this.options.labelMaxLength) { label = label.substring(0, this.options.labelMaxLength - 3) + '...' } } const node = { id: originalId, label, title: id // Full URI on hover } if (type === 'literal') { node.shape = 'box' node.borderRadius = 8 node.font = { color: '#a31515', background: '#fff' } node.widthConstraint = { minimum: 50, maximum: 200 } node.color = this.colors.literal } else if (type === 'property') { node.shape = 'ellipse' node.color = this.colors.property } else if (type === 'class') { node.shape = 'ellipse' node.color = this.colors.class } else if (type === 'subject') { node.shape = 'ellipse' node.color = this.colors.subject } else if (type === 'object') { node.shape = 'ellipse' node.color = this.colors.object } return node } _createEdge(subject, predicate, object) { let edgeLabel = '' let predicateValue = predicate // Handle term objects by extracting their value if (predicate && typeof predicate === 'object' && predicate.value) { predicateValue = predicate.value } if (predicateValue === 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type') { edgeLabel = 'a' } else { // Use PrefixMap for prefix abbreviation, fallback to local name let nn = typeof predicateValue === 'string' ? rdf.namedNode(predicateValue) : predicateValue const shrunk = this.prefixMap.shrink(nn) if (shrunk && shrunk.value && shrunk.value.includes(':') && !shrunk.value.includes('://')) { edgeLabel = shrunk.value // CURIE } else { // fallback: extract local name const uri = nn.value || nn const hash = uri.lastIndexOf('#') const slash = uri.lastIndexOf('/') const pos = Math.max(hash, slash) edgeLabel = uri.substring(pos + 1) } } this.edges.add({ from: subject, to: object, label: edgeLabel, title: predicateValue, // Full URI on hover type: 'predicate', arrows: 'to', predicateUri: predicateValue, font: { size: 10, align: 'middle', face: 'sans-serif', background: 'rgba(255, 255, 255, 0.8)', }, color: { color: '#0077aa', highlight: '#0099cc', hover: '#0088bb' }, smooth: { type: 'continuous', roundness: 0.2 } }) } _handleNetworkClick(params) { if (params.nodes.length === 1) { const nodeId = params.nodes[0] // Check if it's a cluster if (this.network.isCluster(nodeId)) { // Open the cluster this.network.openCluster(nodeId) // Remove from our clusters list const index = this.clusters.findIndex(c => c.id === nodeId) if (index !== -1) { this.clusters.splice(index, 1) } } else { // Notify node selection this._notifyNodeSelection(nodeId) } } else { // No nodes selected - clear selection this._notifyNodeSelection(null) } } _notifyNodeSelection(nodeId) { this.nodeSelectCallbacks.forEach(callback => callback(nodeId)) } _makeClusters() { this.clusterLevel++ const clusterOptions = { processProperties: (clusterOptions, childNodes) => { this.clusterIndex++ // Calculate total children count let childrenCount = 0 for (const node of childNodes) { childrenCount += node.childrenCount || 1 } // Try to find a good label for the cluster based on connected nodes let subjectNode = null for (const node of childNodes) { const connectedNodes = this.network.getConnectedNodes(node.id) if (connectedNodes.length === 1) { subjectNode = this.nodes.get(connectedNodes[0]) || this.clusters.find(c => c.id === connectedNodes[0]) if (subjectNode) break } } const clusterId = `cluster:${this.clusterIndex}` const clusterLabel = subjectNode ? `${this._getClusterLabel(subjectNode)}\n(${childrenCount} nodes)` : `Cluster ${this.clusterIndex}\n(${childrenCount} nodes)` // Set cluster properties clusterOptions.childrenCount = childrenCount clusterOptions.size = childrenCount * 4 + 15 clusterOptions.label = clusterLabel clusterOptions.id = clusterId clusterOptions.mass = 0.5 * childrenCount clusterOptions.color = this.colors.cluster // Track this cluster this.clusters.push({ id: clusterId, label: clusterLabel, clusterLevel: this.clusterLevel }) return clusterOptions }, clusterNodeProperties: { borderWidth: 2, shape: 'dot', color: this.colors.cluster, font: { size: 12, face: 'Arial', multi: 'html', bold: true } } } this.network.clusterOutliers(clusterOptions) // Update current level after clustering if (this.clusters.length > 0) { this.clusterLevel = this.clusters[this.clusters.length - 1].clusterLevel } } _getClusterLabel(node) { const label = node.title != null ? node.title : node.label if (!label) return 'Cluster' // If label has a newline, take only the first line const newlineIndex = label.indexOf('\n') if (newlineIndex !== -1) { return label.substring(0, newlineIndex) } return label } _openClusters() { let declustered = false const newClusters = [] for (const cluster of this.clusters) { if (cluster.clusterLevel >= this.clusterLevel) { this.network.openCluster(cluster.id) declustered = true } else { newClusters.push(cluster) } } this.clusters = newClusters if (declustered) { this.clusterLevel-- } else if (this.clusterLevel > 0) { this.clusterLevel-- this._openClusters() } } _toggleHideDefaults() { // Remember current level const currentLevel = this.clusterLevel // Open all clusters to modify individual nodes while (this.clusterLevel > 0) { this._openClusters() } // Toggle visibility for standard vocabulary nodes this.nodes.forEach(node => { const shortened = URIUtils.shrinkUri(node.id, this.prefixes) const prefixPart = shortened.split(':')[0] if (this.options.defaultPrefixes.includes(prefixPart)) { node.hidden = this.options.hidden // Also hide connected edges const connectedEdges = this.network.getConnectedEdges(node.id) connectedEdges.forEach(edgeId => { const edge = this.edges.get(edgeId) if (edge) { edge.hidden = this.options.hidden this.edges.update(edge) } }) this.nodes.update(node) } }) // Notify network data has changed this.network.body.emitter.emit('_dataChanged') // Recreate clusters as needed this.clusterLevel = 0 for (let i = 0; i < currentLevel; i++) { this._makeClusters() } } toggleFreeze() { this.options.freeze = !this.options.freeze this.network.stopSimulation() this.network.physics.options.enabled = !this.options.freeze this.network.startSimulation() } toggleHideDefaults() { this.options.hidden = !this.options.hidden this._toggleHideDefaults() } makeClusters() { this._makeClusters() } openClusters() { this._openClusters() } onNodeSelect(callback) { this.nodeSelectCallbacks.push(callback) } offNodeSelect(callback) { const index = this.nodeSelectCallbacks.indexOf(callback) if (index !== -1) { this.nodeSelectCallbacks.splice(index, 1) } } getNodes() { return this.nodes.get() } getEdges() { return this.edges } /** * Resizes and fits the network visualization to the container * Helpful when the container size changes (like when tabs are switched) */ resizeAndFit() { if (this.network) { this.network.redraw() this.network.fit({ animation: { duration: 500, easingFunction: 'easeOutQuint' }, scale: 0.75 // Set a consistent zoom level }) } } /** * Set the size of all nodes in the network * @param {number} size - The size to set for all nodes */ setNodeSize(size) { if (!this.network || !this.nodes) return // Store the global node size setting for future nodes this.nodeSize = size // Update all existing nodes const allNodes = this.nodes.get() const updates = [] for (const node of allNodes) { // Create a deep copy of the node to avoid reference issues const nodeCopy = {...node} // Set the size directly on the node object nodeCopy.size = size // For box-shaped nodes, also set width and height if (node.shape === 'box' || node.shape === 'box') { nodeCopy.width = size * 2.5 nodeCopy.height = size * 1.5 } updates.push(nodeCopy) } // Update all nodes in a single batch operation if (updates.length > 0) { this.nodes.update(updates) // Redraw the network to apply changes setTimeout(() => { this.network.redraw() }, 50) } return true } }