From 445769c445db48e4dc1611327b8c369f25b63f6e Mon Sep 17 00:00:00 2001 From: Kingsley Idehen Date: Mon, 22 Dec 2025 13:33:51 -0500 Subject: [PATCH] feat: add Enhanced Graph visualization plugin with D3.js force-directed layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a new internal Yasr plugin for interactive RDF graph visualization with the following features: - D3.js v7 force-directed graph with physics simulation - Interactive controls: zoom, pan, drag nodes with sticky behavior - Draggable control panel with physics parameters and filtering - Node grouping by rdf:type with automatic color coding - Predicate display: toggle between FontAwesome icons and CURIE labels - Tooltip system: toggle between full IRIs and compact CURIEs - Faceted filtering by node groups with visual legend - Real-time physics parameter adjustment (charge strength, link distance) - Theme support (light/dark) with automatic switching - JSON-LD metadata injection and SVG download functionality - Configuration persistence via localStorage - Responsive design with mobile support Plugin registered at priority 11 to auto-select for CONSTRUCT/DESCRIBE queries. All preferences (panel position, visibility, display modes) persist across sessions. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- package-lock.json | 285 +++++++ packages/yasr/package.json | 1 + packages/yasr/src/index.ts | 2 + .../plugins/enhanced-graph/ControlPanel.ts | 773 ++++++++++++++++++ .../plugins/enhanced-graph/GraphRenderer.ts | 635 ++++++++++++++ .../plugins/enhanced-graph/iconMappings.ts | 111 +++ .../src/plugins/enhanced-graph/index.scss | 532 ++++++++++++ .../yasr/src/plugins/enhanced-graph/index.ts | 413 ++++++++++ .../enhanced-graph/metadataGenerator.ts | 143 ++++ .../plugins/enhanced-graph/nodeGrouping.ts | 248 ++++++ .../yasr/src/plugins/enhanced-graph/types.ts | 80 ++ .../yasr/src/plugins/enhanced-graph/utils.ts | 119 +++ 12 files changed, 3342 insertions(+) create mode 100644 packages/yasr/src/plugins/enhanced-graph/ControlPanel.ts create mode 100644 packages/yasr/src/plugins/enhanced-graph/GraphRenderer.ts create mode 100644 packages/yasr/src/plugins/enhanced-graph/iconMappings.ts create mode 100644 packages/yasr/src/plugins/enhanced-graph/index.scss create mode 100644 packages/yasr/src/plugins/enhanced-graph/index.ts create mode 100644 packages/yasr/src/plugins/enhanced-graph/metadataGenerator.ts create mode 100644 packages/yasr/src/plugins/enhanced-graph/nodeGrouping.ts create mode 100644 packages/yasr/src/plugins/enhanced-graph/types.ts create mode 100644 packages/yasr/src/plugins/enhanced-graph/utils.ts diff --git a/package-lock.json b/package-lock.json index d15ff7c5..b082cf0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2203,6 +2203,290 @@ "@types/tern": "*" } }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -8991,6 +9275,7 @@ }, "devDependencies": { "@types/codemirror": "0.0.100", + "@types/d3": "^7.4.0", "@types/jquery": "^3.5.32", "@types/lodash-es": "^4.17.3", "@types/n3": "^1.1.5", diff --git a/packages/yasr/package.json b/packages/yasr/package.json index 9a6cac20..eac75bad 100644 --- a/packages/yasr/package.json +++ b/packages/yasr/package.json @@ -41,6 +41,7 @@ }, "devDependencies": { "@types/codemirror": "0.0.100", + "@types/d3": "^7.4.0", "@types/jquery": "^3.5.32", "@types/lodash-es": "^4.17.3", "@types/n3": "^1.1.5", diff --git a/packages/yasr/src/index.ts b/packages/yasr/src/index.ts index cd8a9b4c..ffc9f8ce 100644 --- a/packages/yasr/src/index.ts +++ b/packages/yasr/src/index.ts @@ -676,10 +676,12 @@ export function registerPlugin(name: string, plugin: typeof Plugin, enable = tru import * as YasrPluginBoolean from "./plugins/boolean"; import * as YasrPluginResponse from "./plugins/response"; import * as YasrPluginError from "./plugins/error"; +import * as YasrPluginEnhancedGraph from "./plugins/enhanced-graph"; Yasr.registerPlugin("boolean", YasrPluginBoolean.default as any); Yasr.registerPlugin("response", YasrPluginResponse.default as any); Yasr.registerPlugin("error", YasrPluginError.default as any); +Yasr.registerPlugin("enhanced-graph", YasrPluginEnhancedGraph.default as any); export type { Plugin, DownloadInfo } from "./plugins"; diff --git a/packages/yasr/src/plugins/enhanced-graph/ControlPanel.ts b/packages/yasr/src/plugins/enhanced-graph/ControlPanel.ts new file mode 100644 index 00000000..09538c34 --- /dev/null +++ b/packages/yasr/src/plugins/enhanced-graph/ControlPanel.ts @@ -0,0 +1,773 @@ +/** + * ControlPanel - Draggable control panel for graph settings + */ + +import { PhysicsParams, NodeGroup } from "./types"; +import { addClass, removeClass } from "@matdata/yasgui-utils"; + +export interface ControlPanelConfig { + onPhysicsChange: (params: Partial) => void; + onFilterChange: (hiddenGroups: Set) => void; + onPredicateDisplayChange: (showLabels: boolean) => void; + onTooltipCurieChange: (useCuries: boolean) => void; + initialPhysics: PhysicsParams; + nodeGroups: Map; + showPredicateLabels: boolean; + useCompactTooltips: boolean; + defaultOpen?: boolean; + initialPosition?: { x: number; y: number }; +} + +export class ControlPanel { + private container: HTMLDivElement; + private panel: HTMLDivElement; + private toggleButton: HTMLDivElement; + private isVisible: boolean; + private position: { x: number; y: number }; + private isDragging: boolean = false; + private dragOffset: { x: number; y: number } = { x: 0, y: 0 }; + private config: ControlPanelConfig; + private hiddenGroups: Set = new Set(); + private currentPhysics: PhysicsParams; + private showPredicateLabels: boolean; + private useCompactTooltips: boolean; + + constructor(parentContainer: HTMLElement, config: ControlPanelConfig) { + this.config = config; + this.currentPhysics = { ...config.initialPhysics }; + this.showPredicateLabels = config.showPredicateLabels; + this.useCompactTooltips = config.useCompactTooltips; + this.isVisible = config.defaultOpen || false; + + // Default position: relative to container, not window + this.position = config.initialPosition || { x: 60, y: 60 }; + + // Create container for all UI elements + this.container = document.createElement("div"); + this.container.className = "enhanced-graph-controls"; + parentContainer.appendChild(this.container); + + // Create toggle button + this.toggleButton = this.createToggleButton(); + this.container.appendChild(this.toggleButton); + + // Create control panel + this.panel = this.createPanel(); + this.container.appendChild(this.panel); + + // Setup dragging behavior + this.attachDragBehavior(); + + // Update visibility + this.updateVisibility(); + } + + /** + * Create the gear icon toggle button + */ + private createToggleButton(): HTMLDivElement { + const button = document.createElement("div"); + button.className = "control-toggle"; + button.title = "Toggle graph settings"; + button.setAttribute("role", "button"); + button.setAttribute("aria-label", "Toggle graph settings"); + + // Add gear icon (⚙) + button.innerHTML = ` + + + + `; + + button.addEventListener("click", () => this.toggle()); + + return button; + } + + /** + * Create the main control panel + */ + private createPanel(): HTMLDivElement { + const panel = document.createElement("div"); + panel.className = "control-panel"; + + // Header (draggable) + const header = document.createElement("div"); + header.className = "panel-header"; + header.innerHTML = ` +

Graph Controls

+ + `; + panel.appendChild(header); + + // Close button functionality + header.querySelector(".close-btn")?.addEventListener("click", (e) => { + e.stopPropagation(); + this.hide(); + }); + + // Content container + const content = document.createElement("div"); + content.className = "panel-content"; + + // Display options section + const displaySection = this.createDisplaySection(); + content.appendChild(displaySection); + + // Physics section + const physicsSection = this.createPhysicsSection(); + content.appendChild(physicsSection); + + // Filters section + const filtersSection = this.createFiltersSection(); + content.appendChild(filtersSection); + + // Legend section + const legendSection = this.createLegendSection(); + content.appendChild(legendSection); + + panel.appendChild(content); + + return panel; + } + + /** + * Create display options section + */ + private createDisplaySection(): HTMLElement { + const section = document.createElement("div"); + section.className = "panel-section"; + + const title = document.createElement("h5"); + title.textContent = "Display Options"; + section.appendChild(title); + + // Predicate display toggle + const toggleContainer = document.createElement("div"); + toggleContainer.className = "display-toggle"; + toggleContainer.style.display = "flex"; + toggleContainer.style.alignItems = "center"; + toggleContainer.style.justifyContent = "space-between"; + toggleContainer.style.marginBottom = "12px"; + + const label = document.createElement("label"); + label.textContent = "Predicate Display"; + label.style.fontSize = "12px"; + label.style.color = "#666"; + + const toggleSwitch = document.createElement("div"); + toggleSwitch.className = "toggle-switch"; + toggleSwitch.style.display = "flex"; + toggleSwitch.style.gap = "8px"; + toggleSwitch.style.alignItems = "center"; + + // Icons button + const iconsBtn = document.createElement("button"); + iconsBtn.className = "toggle-btn" + (this.showPredicateLabels ? "" : " active"); + iconsBtn.textContent = "Icons"; + iconsBtn.style.fontSize = "11px"; + iconsBtn.style.padding = "4px 12px"; + iconsBtn.style.border = "1px solid #ccc"; + iconsBtn.style.background = this.showPredicateLabels ? "#f0f0f0" : "#337ab7"; + iconsBtn.style.color = this.showPredicateLabels ? "#666" : "white"; + iconsBtn.style.borderRadius = "3px"; + iconsBtn.style.cursor = "pointer"; + iconsBtn.addEventListener("click", () => { + if (!this.showPredicateLabels) return; // Already showing icons + this.showPredicateLabels = false; + this.updateToggleButtons(iconsBtn, labelsBtn); + this.config.onPredicateDisplayChange(false); + this.savePredicateDisplayPreference(); + }); + + // Labels button + const labelsBtn = document.createElement("button"); + labelsBtn.className = "toggle-btn" + (this.showPredicateLabels ? " active" : ""); + labelsBtn.textContent = "Labels"; + labelsBtn.style.fontSize = "11px"; + labelsBtn.style.padding = "4px 12px"; + labelsBtn.style.border = "1px solid #ccc"; + labelsBtn.style.background = this.showPredicateLabels ? "#337ab7" : "#f0f0f0"; + labelsBtn.style.color = this.showPredicateLabels ? "white" : "#666"; + labelsBtn.style.borderRadius = "3px"; + labelsBtn.style.cursor = "pointer"; + labelsBtn.addEventListener("click", () => { + if (this.showPredicateLabels) return; // Already showing labels + this.showPredicateLabels = true; + this.updateToggleButtons(iconsBtn, labelsBtn); + this.config.onPredicateDisplayChange(true); + this.savePredicateDisplayPreference(); + }); + + toggleSwitch.appendChild(iconsBtn); + toggleSwitch.appendChild(labelsBtn); + + toggleContainer.appendChild(label); + toggleContainer.appendChild(toggleSwitch); + section.appendChild(toggleContainer); + + // Tooltip format toggle + const tooltipContainer = document.createElement("div"); + tooltipContainer.className = "display-toggle"; + tooltipContainer.style.display = "flex"; + tooltipContainer.style.alignItems = "center"; + tooltipContainer.style.justifyContent = "space-between"; + tooltipContainer.style.marginTop = "12px"; + + const tooltipLabel = document.createElement("label"); + tooltipLabel.textContent = "Tooltip IRIs"; + tooltipLabel.style.fontSize = "12px"; + tooltipLabel.style.color = "#666"; + + const tooltipToggle = document.createElement("div"); + tooltipToggle.className = "toggle-switch"; + tooltipToggle.style.display = "flex"; + tooltipToggle.style.gap = "8px"; + tooltipToggle.style.alignItems = "center"; + + // Full IRIs button + const fullBtn = document.createElement("button"); + fullBtn.className = "toggle-btn" + (this.useCompactTooltips ? "" : " active"); + fullBtn.textContent = "Full"; + fullBtn.style.fontSize = "11px"; + fullBtn.style.padding = "4px 12px"; + fullBtn.style.border = "1px solid #ccc"; + fullBtn.style.background = this.useCompactTooltips ? "#f0f0f0" : "#337ab7"; + fullBtn.style.color = this.useCompactTooltips ? "#666" : "white"; + fullBtn.style.borderRadius = "3px"; + fullBtn.style.cursor = "pointer"; + fullBtn.addEventListener("click", () => { + if (!this.useCompactTooltips) return; // Already showing full + this.useCompactTooltips = false; + this.updateTooltipToggleButtons(fullBtn, curieBtn); + this.config.onTooltipCurieChange(false); + this.saveTooltipPreference(); + }); + + // CURIEs button + const curieBtn = document.createElement("button"); + curieBtn.className = "toggle-btn" + (this.useCompactTooltips ? " active" : ""); + curieBtn.textContent = "CURIE"; + curieBtn.style.fontSize = "11px"; + curieBtn.style.padding = "4px 12px"; + curieBtn.style.border = "1px solid #ccc"; + curieBtn.style.background = this.useCompactTooltips ? "#337ab7" : "#f0f0f0"; + curieBtn.style.color = this.useCompactTooltips ? "white" : "#666"; + curieBtn.style.borderRadius = "3px"; + curieBtn.style.cursor = "pointer"; + curieBtn.addEventListener("click", () => { + if (this.useCompactTooltips) return; // Already showing CURIEs + this.useCompactTooltips = true; + this.updateTooltipToggleButtons(fullBtn, curieBtn); + this.config.onTooltipCurieChange(true); + this.saveTooltipPreference(); + }); + + tooltipToggle.appendChild(fullBtn); + tooltipToggle.appendChild(curieBtn); + + tooltipContainer.appendChild(tooltipLabel); + tooltipContainer.appendChild(tooltipToggle); + section.appendChild(tooltipContainer); + + return section; + } + + /** + * Update tooltip toggle button styles + */ + private updateTooltipToggleButtons(fullBtn: HTMLButtonElement, curieBtn: HTMLButtonElement): void { + if (this.useCompactTooltips) { + fullBtn.style.background = "#f0f0f0"; + fullBtn.style.color = "#666"; + curieBtn.style.background = "#337ab7"; + curieBtn.style.color = "white"; + } else { + fullBtn.style.background = "#337ab7"; + fullBtn.style.color = "white"; + curieBtn.style.background = "#f0f0f0"; + curieBtn.style.color = "#666"; + } + } + + /** + * Save tooltip preference + */ + private saveTooltipPreference(): void { + try { + localStorage.setItem("yasgui-enhanced-graph-compact-tooltips", JSON.stringify(this.useCompactTooltips)); + } catch (e) { + // Ignore storage errors + } + } + + /** + * Load tooltip preference + */ + public static loadTooltipPreference(): boolean { + try { + const saved = localStorage.getItem("yasgui-enhanced-graph-compact-tooltips"); + if (saved) { + return JSON.parse(saved); + } + } catch (e) { + // Ignore storage errors + } + return false; // Default to full IRIs + } + + /** + * Update toggle button styles + */ + private updateToggleButtons(iconsBtn: HTMLButtonElement, labelsBtn: HTMLButtonElement): void { + if (this.showPredicateLabels) { + iconsBtn.style.background = "#f0f0f0"; + iconsBtn.style.color = "#666"; + labelsBtn.style.background = "#337ab7"; + labelsBtn.style.color = "white"; + } else { + iconsBtn.style.background = "#337ab7"; + iconsBtn.style.color = "white"; + labelsBtn.style.background = "#f0f0f0"; + labelsBtn.style.color = "#666"; + } + } + + /** + * Save predicate display preference + */ + private savePredicateDisplayPreference(): void { + try { + localStorage.setItem("yasgui-enhanced-graph-show-labels", JSON.stringify(this.showPredicateLabels)); + } catch (e) { + // Ignore storage errors + } + } + + /** + * Load predicate display preference + */ + public static loadPredicateDisplayPreference(): boolean { + try { + const saved = localStorage.getItem("yasgui-enhanced-graph-show-labels"); + if (saved) { + return JSON.parse(saved); + } + } catch (e) { + // Ignore storage errors + } + return false; // Default to icons + } + + /** + * Create physics controls section + */ + private createPhysicsSection(): HTMLElement { + const section = document.createElement("div"); + section.className = "panel-section"; + + const title = document.createElement("h5"); + title.textContent = "Physics"; + section.appendChild(title); + + // Charge Strength slider + const chargeControl = this.createSlider( + "Charge Strength", + "chargeStrength", + this.currentPhysics.chargeStrength, + -150, + -30, + 5, + (value) => { + this.currentPhysics.chargeStrength = value; + this.config.onPhysicsChange({ chargeStrength: value }); + }, + ); + section.appendChild(chargeControl); + + // Link Distance slider + const distanceControl = this.createSlider( + "Link Distance", + "linkDistance", + this.currentPhysics.linkDistance, + 30, + 200, + 10, + (value) => { + this.currentPhysics.linkDistance = value; + this.config.onPhysicsChange({ linkDistance: value }); + }, + ); + section.appendChild(distanceControl); + + return section; + } + + /** + * Create a slider control + */ + private createSlider( + label: string, + id: string, + initialValue: number, + min: number, + max: number, + step: number, + onChange: (value: number) => void, + ): HTMLElement { + const control = document.createElement("div"); + control.className = "slider-control"; + + const labelEl = document.createElement("label"); + labelEl.innerHTML = ` + ${label} + ${initialValue} + `; + + const slider = document.createElement("input"); + slider.type = "range"; + slider.id = id; + slider.min = min.toString(); + slider.max = max.toString(); + slider.step = step.toString(); + slider.value = initialValue.toString(); + + // Debounce the onChange to avoid too many updates + let timeout: number | null = null; + slider.addEventListener("input", (e) => { + const value = parseInt((e.target as HTMLInputElement).value); + document.getElementById(`${id}-value`)!.textContent = value.toString(); + + // Debounce updates for smooth performance + if (timeout) clearTimeout(timeout); + timeout = window.setTimeout(() => { + onChange(value); + }, 50); + }); + + control.appendChild(labelEl); + control.appendChild(slider); + + return control; + } + + /** + * Create filters section + */ + private createFiltersSection(): HTMLElement { + const section = document.createElement("div"); + section.className = "panel-section"; + + const title = document.createElement("h5"); + title.textContent = "Filter by Type"; + section.appendChild(title); + + // Create checkbox list + const checkboxList = document.createElement("div"); + checkboxList.className = "checkbox-list"; + + // Add "Select All" / "Deselect All" shortcuts + const selectAllBtn = document.createElement("button"); + selectAllBtn.className = "yasr_btn filter-action-btn"; + selectAllBtn.textContent = "Select All"; + selectAllBtn.style.fontSize = "11px"; + selectAllBtn.style.padding = "4px 8px"; + selectAllBtn.style.marginBottom = "8px"; + selectAllBtn.addEventListener("click", () => { + this.hiddenGroups.clear(); + this.updateFilterCheckboxes(); + this.config.onFilterChange(this.hiddenGroups); + }); + + const deselectAllBtn = document.createElement("button"); + deselectAllBtn.className = "yasr_btn filter-action-btn"; + deselectAllBtn.textContent = "Deselect All"; + deselectAllBtn.style.fontSize = "11px"; + deselectAllBtn.style.padding = "4px 8px"; + deselectAllBtn.style.marginBottom = "8px"; + deselectAllBtn.style.marginLeft = "8px"; + deselectAllBtn.addEventListener("click", () => { + this.config.nodeGroups.forEach((_, groupId) => { + this.hiddenGroups.add(groupId); + }); + this.updateFilterCheckboxes(); + this.config.onFilterChange(this.hiddenGroups); + }); + + const buttonContainer = document.createElement("div"); + buttonContainer.appendChild(selectAllBtn); + buttonContainer.appendChild(deselectAllBtn); + section.appendChild(buttonContainer); + + // Sort groups by count (descending) + const sortedGroups = Array.from(this.config.nodeGroups.entries()).sort((a, b) => b[1].count - a[1].count); + + sortedGroups.forEach(([groupId, group]) => { + const label = document.createElement("label"); + label.style.display = "flex"; + label.style.alignItems = "center"; + label.style.cursor = "pointer"; + + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.checked = !this.hiddenGroups.has(groupId); + checkbox.dataset.groupId = groupId; + checkbox.addEventListener("change", (e) => { + const target = e.target as HTMLInputElement; + const id = target.dataset.groupId!; + if (target.checked) { + this.hiddenGroups.delete(id); + } else { + this.hiddenGroups.add(id); + } + this.config.onFilterChange(this.hiddenGroups); + }); + + const colorIndicator = document.createElement("span"); + colorIndicator.className = "color-indicator"; + colorIndicator.style.backgroundColor = group.color; + + const text = document.createTextNode(` ${group.label} (${group.count})`); + + label.appendChild(checkbox); + label.appendChild(colorIndicator); + label.appendChild(text); + checkboxList.appendChild(label); + }); + + section.appendChild(checkboxList); + + return section; + } + + /** + * Update filter checkboxes state + */ + private updateFilterCheckboxes(): void { + const checkboxes = this.panel.querySelectorAll('.checkbox-list input[type="checkbox"]'); + checkboxes.forEach((checkbox) => { + const cb = checkbox as HTMLInputElement; + const groupId = cb.dataset.groupId!; + cb.checked = !this.hiddenGroups.has(groupId); + }); + } + + /** + * Create legend section + */ + private createLegendSection(): HTMLElement { + const section = document.createElement("div"); + section.className = "panel-section"; + + const title = document.createElement("h5"); + title.textContent = "Legend"; + section.appendChild(title); + + const legendList = document.createElement("div"); + legendList.className = "legend-list"; + + // Sort groups by count (descending) + const sortedGroups = Array.from(this.config.nodeGroups.entries()).sort((a, b) => b[1].count - a[1].count); + + sortedGroups.forEach(([_, group]) => { + const item = document.createElement("div"); + item.className = "legend-item"; + + const colorIndicator = document.createElement("span"); + colorIndicator.className = "color-indicator"; + colorIndicator.style.backgroundColor = group.color; + + const label = document.createTextNode(`${group.label} (${group.count})`); + + item.appendChild(colorIndicator); + item.appendChild(label); + legendList.appendChild(item); + }); + + section.appendChild(legendList); + + return section; + } + + /** + * Attach dragging behavior to panel header + */ + private attachDragBehavior(): void { + const header = this.panel.querySelector(".panel-header") as HTMLElement; + + header.addEventListener("mousedown", (e: MouseEvent) => { + // Don't start drag if clicking close button + if ((e.target as HTMLElement).classList.contains("close-btn")) return; + + this.isDragging = true; + this.dragOffset = { + x: e.clientX - this.position.x, + y: e.clientY - this.position.y, + }; + + header.style.cursor = "grabbing"; + e.preventDefault(); + }); + + document.addEventListener("mousemove", (e: MouseEvent) => { + if (!this.isDragging) return; + + // Calculate new position + let newX = e.clientX - this.dragOffset.x; + let newY = e.clientY - this.dragOffset.y; + + // Constrain to viewport + const panelRect = this.panel.getBoundingClientRect(); + const maxX = window.innerWidth - panelRect.width; + const maxY = window.innerHeight - panelRect.height; + + newX = Math.max(0, Math.min(newX, maxX)); + newY = Math.max(0, Math.min(newY, maxY)); + + this.position = { x: newX, y: newY }; + this.updatePanelPosition(); + }); + + document.addEventListener("mouseup", () => { + if (this.isDragging) { + this.isDragging = false; + header.style.cursor = "move"; + this.savePosition(); + } + }); + + // Set initial cursor + header.style.cursor = "move"; + } + + /** + * Update panel position + */ + private updatePanelPosition(): void { + this.panel.style.left = `${this.position.x}px`; + this.panel.style.top = `${this.position.y}px`; + } + + /** + * Update visibility + */ + private updateVisibility(): void { + if (this.isVisible) { + removeClass(this.panel, "hidden"); + this.updatePanelPosition(); + } else { + addClass(this.panel, "hidden"); + } + } + + /** + * Toggle panel visibility + */ + public toggle(): void { + this.isVisible = !this.isVisible; + this.updateVisibility(); + this.saveState(); + } + + /** + * Show panel + */ + public show(): void { + this.isVisible = true; + this.updateVisibility(); + this.saveState(); + } + + /** + * Hide panel + */ + public hide(): void { + this.isVisible = false; + this.updateVisibility(); + this.saveState(); + } + + /** + * Save position to localStorage + */ + private savePosition(): void { + try { + localStorage.setItem("yasgui-enhanced-graph-panel-position", JSON.stringify(this.position)); + } catch (e) { + // Ignore storage errors + } + } + + /** + * Save state to localStorage + */ + private saveState(): void { + try { + localStorage.setItem("yasgui-enhanced-graph-panel-visible", JSON.stringify(this.isVisible)); + } catch (e) { + // Ignore storage errors + } + } + + /** + * Load position from localStorage + */ + public static loadPosition(): { x: number; y: number } | undefined { + try { + const saved = localStorage.getItem("yasgui-enhanced-graph-panel-position"); + if (saved) { + return JSON.parse(saved); + } + } catch (e) { + // Ignore storage errors + } + return undefined; + } + + /** + * Load visibility state from localStorage + */ + public static loadVisibility(): boolean { + try { + const saved = localStorage.getItem("yasgui-enhanced-graph-panel-visible"); + if (saved) { + return JSON.parse(saved); + } + } catch (e) { + // Ignore storage errors + } + return false; + } + + /** + * Update node groups (when data changes) + */ + public updateGroups(nodeGroups: Map): void { + // Re-render filters and legend sections + const content = this.panel.querySelector(".panel-content"); + if (!content) return; + + // Find and replace the filters and legend sections + const oldFilters = content.querySelector(".panel-section:nth-child(2)"); + const oldLegend = content.querySelector(".panel-section:nth-child(3)"); + + // Update config + this.config.nodeGroups = nodeGroups; + + // Re-create sections + const newFilters = this.createFiltersSection(); + const newLegend = this.createLegendSection(); + + if (oldFilters) oldFilters.replaceWith(newFilters); + if (oldLegend) oldLegend.replaceWith(newLegend); + } + + /** + * Cleanup + */ + public destroy(): void { + if (this.container && this.container.parentNode) { + this.container.parentNode.removeChild(this.container); + } + } +} diff --git a/packages/yasr/src/plugins/enhanced-graph/GraphRenderer.ts b/packages/yasr/src/plugins/enhanced-graph/GraphRenderer.ts new file mode 100644 index 00000000..2c73026f --- /dev/null +++ b/packages/yasr/src/plugins/enhanced-graph/GraphRenderer.ts @@ -0,0 +1,635 @@ +/** + * GraphRenderer - D3.js-based force-directed graph visualization + */ + +import { GraphData, GraphNode, GraphLink, PhysicsParams, NodeGroup } from "./types"; +import { isValidIri } from "./utils"; +import { getIconForPredicate, getIconDefinition } from "./iconMappings"; +import { drawFontAwesomeIconAsSvg } from "@matdata/yasgui-utils"; + +export class GraphRenderer { + private container: HTMLElement; + private svg: any; // D3 selection + private g: any; // Main group for zoom/pan + private simulation: any; // D3 force simulation + private nodes: GraphNode[]; + private links: GraphLink[]; + private nodeElements: any; + private linkElements: any; + private linkIconsGroup: any; + private tooltip: HTMLDivElement; + private width: number; + private height: number; + private isDarkTheme: boolean; + private nodeGroups: Map; + private iconMappings?: Record; + private showPredicateLabels: boolean = false; + private useCompactTooltips: boolean = false; + + constructor( + container: HTMLElement, + data: GraphData, + physicsParams: PhysicsParams, + nodeGroups: Map, + iconMappings?: Record, + showPredicateLabels: boolean = false, + useCompactTooltips: boolean = false, + ) { + this.container = container; + this.nodes = data.nodes; + this.links = data.links; + this.nodeGroups = nodeGroups; + this.iconMappings = iconMappings; + this.showPredicateLabels = showPredicateLabels; + this.useCompactTooltips = useCompactTooltips; + this.tooltip = this.createTooltip(); + + // Detect theme + this.isDarkTheme = document.documentElement.getAttribute("data-theme") === "dark"; + + // Get dimensions - use offsetWidth/Height if getBoundingClientRect gives 0 + const rect = container.getBoundingClientRect(); + this.width = rect.width || container.offsetWidth || 800; + this.height = rect.height || container.offsetHeight || 600; + + console.log(`Enhanced Graph: Container dimensions: ${this.width}x${this.height}`); + console.log(`Enhanced Graph: Rendering ${this.nodes.length} nodes and ${this.links.length} links`); + + // Initialize D3 visualization + this.initializeSVG(); + this.setupSimulation(physicsParams); + this.render(); + } + + /** + * Create SVG container + */ + private initializeSVG(): void { + const d3 = (window as any).d3; + + if (!d3) { + console.error("Enhanced Graph: D3.js is not loaded!"); + return; + } + + // Create SVG element + this.svg = d3 + .select(this.container) + .append("svg") + .attr("width", "100%") + .attr("height", "100%") + .attr("viewBox", `0 0 ${this.width} ${this.height}`) + .attr("preserveAspectRatio", "xMidYMid meet"); + + // Define arrowhead markers + this.svg + .append("defs") + .append("marker") + .attr("id", "arrowhead") + .attr("viewBox", "0 -5 10 10") + .attr("refX", 20) + .attr("refY", 0) + .attr("markerWidth", 6) + .attr("markerHeight", 6) + .attr("orient", "auto") + .append("path") + .attr("d", "M0,-5L10,0L0,5") + .attr("fill", this.isDarkTheme ? "#999" : "#999"); + + // Create main group for zoom/pan + this.g = this.svg.append("g"); + + // Setup zoom behavior + const zoom = d3 + .zoom() + .scaleExtent([0.1, 10]) + .on("zoom", (event: any) => { + this.g.attr("transform", event.transform); + }); + + this.svg.call(zoom); + } + + /** + * Setup D3 force simulation + */ + private setupSimulation(physicsParams: PhysicsParams): void { + const d3 = (window as any).d3; + + this.simulation = d3 + .forceSimulation(this.nodes) + .force( + "link", + d3 + .forceLink(this.links) + .id((d: GraphNode) => d.id) + .distance(physicsParams.linkDistance), + ) + .force("charge", d3.forceManyBody().strength(physicsParams.chargeStrength)) + .force("center", d3.forceCenter(this.width / 2, this.height / 2)) + .force("collision", d3.forceCollide().radius(20)) + .on("tick", () => this.ticked()); + } + + /** + * Render nodes and links + */ + private render(): void { + const d3 = (window as any).d3; + + // Render links + this.linkElements = this.g + .append("g") + .attr("class", "links") + .selectAll("line") + .data(this.links) + .enter() + .append("line") + .attr("class", "link") + .attr("stroke", this.isDarkTheme ? "#666" : "#999") + .attr("stroke-opacity", 0.6) + .attr("stroke-width", 1.5) + .attr("marker-end", "url(#arrowhead)"); + + // Add link hover effect + this.linkElements + .on("mouseenter", (event: any, d: GraphLink) => { + d3.select(event.currentTarget).attr("stroke-opacity", 1).attr("stroke-width", 2.5); + const displayValue = this.useCompactTooltips ? d.predicateLabel : d.predicate; + this.showTooltip(event, `Predicate: ${displayValue}`); + }) + .on("mouseleave", (event: any) => { + d3.select(event.currentTarget).attr("stroke-opacity", 0.6).attr("stroke-width", 1.5); + this.hideTooltip(); + }); + + // Render nodes + this.nodeElements = this.g + .append("g") + .attr("class", "nodes") + .selectAll("circle") + .data(this.nodes) + .enter() + .append("circle") + .attr("class", "node") + .attr("r", (d: GraphNode) => this.getNodeRadius(d)) + .attr("fill", (d: GraphNode) => this.getNodeColor(d)) + .attr("stroke", this.isDarkTheme ? "#333" : "#fff") + .attr("stroke-width", 2) + .call(this.dragBehavior()); + + // Add node interactions + this.nodeElements + .on("mouseenter", (event: any, d: GraphNode) => { + d3.select(event.currentTarget).attr("stroke-width", 3); + const displayValue = this.useCompactTooltips ? d.label : d.fullIri; + const tooltipContent = ` +
${displayValue}
+ ${d.type ? `
Type: ${d.type}
` : ""} + ${d.degree ? `
Connections: ${d.degree}
` : ""} + `; + this.showTooltip(event, tooltipContent); + }) + .on("mouseleave", (event: any, d: GraphNode) => { + d3.select(event.currentTarget).attr("stroke-width", d.fx !== undefined ? 3 : 2); + this.hideTooltip(); + }) + .on("click", (event: any, d: GraphNode) => { + // Make IRIs clickable + if (d.type === "uri" && isValidIri(d.fullIri)) { + window.open(d.fullIri, "_blank", "noopener,noreferrer"); + } + }) + .on("dblclick", (event: any, d: GraphNode) => { + // Double-click to release fixed position + event.stopPropagation(); + this.releaseNode(d); + }); + + // Add node labels (only for nodes with degree > threshold to reduce clutter) + const labelThreshold = this.nodes.length > 50 ? 3 : 0; + const labels = this.g + .append("g") + .attr("class", "labels") + .selectAll("text") + .data(this.nodes.filter((d: GraphNode) => (d.degree || 0) > labelThreshold)) + .enter() + .append("text") + .attr("class", "node-label") + .attr("text-anchor", "middle") + .attr("dy", -15) + .attr("font-size", "11px") + .attr("fill", this.isDarkTheme ? "#e0e0e0" : "#333") + .attr("pointer-events", "none") + .text((d: GraphNode) => (d.label.length > 20 ? d.label.substring(0, 18) + "..." : d.label)); + + // Add predicate icons on links + this.renderPredicateIcons(); + } + + /** + * Render predicate icons or labels on link midpoints + */ + private renderPredicateIcons(): void { + const d3 = (window as any).d3; + + // Create a group for link icons/labels + this.linkIconsGroup = this.g.append("g").attr("class", "link-icons"); + + // Create icon/label groups as D3 data-bound elements + const iconGroups = this.linkIconsGroup + .selectAll(".predicate-icon") + .data(this.links) + .enter() + .append("g") + .attr("class", "predicate-icon"); + + if (this.showPredicateLabels) { + // Render text labels (CURIEs) + this.renderPredicateLabels(iconGroups); + } else { + // Render FontAwesome icons + this.renderPredicateIconsOnly(iconGroups); + } + + // Add hover and click interactions to icon groups + iconGroups + .on("mouseenter", (event: any, d: GraphLink) => { + const displayValue = this.useCompactTooltips ? d.predicateLabel : d.predicate; + this.showTooltip(event, `Predicate: ${displayValue}`); + }) + .on("mouseleave", () => { + this.hideTooltip(); + }) + .on("click", (event: any, d: GraphLink) => { + if (isValidIri(d.predicate)) { + window.open(d.predicate, "_blank", "noopener,noreferrer"); + } + }); + + // Set initial positions (will be updated by ticked()) + this.updateIconPositions(); + } + + /** + * Render icon-only display + */ + private renderPredicateIconsOnly(iconGroups: any): void { + // Add circle backgrounds + iconGroups + .append("circle") + .attr("r", 10) + .attr("fill", this.isDarkTheme ? "#2d2d2d" : "white") + .attr("stroke", this.isDarkTheme ? "#555" : "#ccc") + .attr("stroke-width", 1.5) + .style("cursor", "pointer"); + + // For each link, create an icon + iconGroups.each((link: GraphLink, index: number, nodes: any[]) => { + const d3 = (window as any).d3; + const iconGroup = d3.select(nodes[index]); + const iconName = getIconForPredicate(link.predicate, this.iconMappings); + const iconDef = getIconDefinition(iconName); + + if (iconDef) { + // Use SVG path for the icon + const svgString = drawFontAwesomeIconAsSvg(iconDef); + const parser = new DOMParser(); + const svgDoc = parser.parseFromString(svgString, "image/svg+xml"); + const pathElement = svgDoc.querySelector("path"); + + if (pathElement) { + const pathData = pathElement.getAttribute("d"); + const viewBox = svgDoc.querySelector("svg")?.getAttribute("viewBox")?.split(" ") || ["0", "0", "512", "512"]; + const vbWidth = parseFloat(viewBox[2]); + const vbHeight = parseFloat(viewBox[3]); + + // Center the icon and scale it to fit in ~16px circle + const scale = 16 / Math.max(vbWidth, vbHeight); + const translateX = -(vbWidth * scale) / 2; + const translateY = -(vbHeight * scale) / 2; + + iconGroup + .append("path") + .attr("d", pathData) + .attr("transform", `translate(${translateX}, ${translateY}) scale(${scale})`) + .attr("fill", this.isDarkTheme ? "#aaa" : "#666") + .style("pointer-events", "none"); + } + } + }); + } + + /** + * Render label-only display (CURIEs) + */ + private renderPredicateLabels(iconGroups: any): void { + // Add rounded rectangle backgrounds + iconGroups + .append("rect") + .attr("rx", 3) + .attr("ry", 3) + .attr("fill", this.isDarkTheme ? "#2d2d2d" : "white") + .attr("stroke", this.isDarkTheme ? "#555" : "#ccc") + .attr("stroke-width", 1.5) + .style("cursor", "pointer"); + + // Add text labels + iconGroups + .append("text") + .attr("class", "predicate-label") + .attr("text-anchor", "middle") + .attr("dominant-baseline", "middle") + .attr("font-size", "10px") + .attr("font-family", "Inter, -apple-system, sans-serif") + .attr("fill", this.isDarkTheme ? "#e0e0e0" : "#333") + .style("pointer-events", "none") + .text((d: GraphLink) => d.predicateLabel) + .each(function (d: GraphLink) { + const d3 = (window as any).d3; + // Adjust rectangle to fit text + const bbox = (this as SVGTextElement).getBBox(); + d3.select((this as SVGTextElement).parentNode) + .select("rect") + .attr("x", bbox.x - 4) + .attr("y", bbox.y - 2) + .attr("width", bbox.width + 8) + .attr("height", bbox.height + 4); + }); + } + + /** + * Switch between icons and labels + */ + public setPredicateDisplay(showLabels: boolean): void { + if (this.showPredicateLabels === showLabels) return; // No change + + this.showPredicateLabels = showLabels; + + // Remove existing icons/labels group + if (this.linkIconsGroup) { + this.linkIconsGroup.remove(); + } + + // Re-render with new display mode + this.renderPredicateIcons(); + } + + /** + * Set tooltip format (compact CURIEs vs full IRIs) + */ + public setTooltipFormat(useCuries: boolean): void { + this.useCompactTooltips = useCuries; + // Tooltips will update automatically on next hover + } + + /** + * Update icon positions to link midpoints + */ + private updateIconPositions(): void { + if (!this.linkIconsGroup) return; + + this.linkIconsGroup.selectAll(".predicate-icon").attr("transform", (d: any) => { + // After D3 force simulation processes links, source and target are node objects with x, y + if (d.source && d.target && d.source.x !== undefined && d.target.x !== undefined) { + const midX = (d.source.x + d.target.x) / 2; + const midY = (d.source.y + d.target.y) / 2; + return `translate(${midX},${midY})`; + } + return "translate(0,0)"; + }); + } + + /** + * Drag behavior with sticky nodes + */ + private dragBehavior(): any { + const d3 = (window as any).d3; + + return d3 + .drag() + .on("start", (event: any, d: GraphNode) => { + if (!event.active) this.simulation.alphaTarget(0.3).restart(); + d.fx = d.x; + d.fy = d.y; + }) + .on("drag", (event: any, d: GraphNode) => { + d.fx = event.x; + d.fy = event.y; + }) + .on("end", (event: any, d: GraphNode) => { + if (!event.active) this.simulation.alphaTarget(0); + // Node remains fixed (sticky behavior) + // Update stroke to indicate fixed state + d3.select(event.sourceEvent.target).attr("stroke", "#ff6b6b").attr("stroke-width", 3); + }); + } + + /** + * Release a fixed node + */ + private releaseNode(node: GraphNode): void { + const d3 = (window as any).d3; + node.fx = null; + node.fy = null; + this.simulation.alpha(0.3).restart(); + + // Reset stroke color + this.nodeElements + .filter((d: GraphNode) => d.id === node.id) + .attr("stroke", this.isDarkTheme ? "#333" : "#fff") + .attr("stroke-width", 2); + } + + /** + * Update positions on each tick + */ + private ticked(): void { + const d3 = (window as any).d3; + + // Update link positions + this.linkElements + .attr("x1", (d: any) => d.source.x) + .attr("y1", (d: any) => d.source.y) + .attr("x2", (d: any) => d.target.x) + .attr("y2", (d: any) => d.target.y); + + // Update node positions + this.nodeElements.attr("cx", (d: GraphNode) => d.x).attr("cy", (d: GraphNode) => d.y); + + // Update label positions + this.g + .selectAll(".node-label") + .attr("x", (d: GraphNode) => d.x) + .attr("y", (d: GraphNode) => d.y); + + // Update icon positions (at link midpoints) + this.updateIconPositions(); + } + + /** + * Get node radius based on degree + */ + private getNodeRadius(node: GraphNode): number { + if (node.type === "literal") return 6; + + const degree = node.degree || 0; + const minRadius = 8; + const maxRadius = 20; + + // Scale radius based on degree + const radius = minRadius + Math.min(degree * 1.5, maxRadius - minRadius); + return radius; + } + + /** + * Get node color based on group + */ + private getNodeColor(node: GraphNode): string { + const group = this.nodeGroups.get(node.group); + if (group) { + return group.color; + } + + // Fallback colors + if (node.type === "literal") return this.isDarkTheme ? "#7f7f7f" : "#999"; + if (node.type === "bnode") return this.isDarkTheme ? "#9467bd" : "#b19cd9"; + return this.isDarkTheme ? "#1f77b4" : "#4292c6"; + } + + /** + * Create tooltip element + */ + private createTooltip(): HTMLDivElement { + const tooltip = document.createElement("div"); + tooltip.className = "graph-tooltip"; + tooltip.style.display = "none"; + document.body.appendChild(tooltip); + return tooltip; + } + + /** + * Show tooltip + */ + private showTooltip(event: any, content: string): void { + this.tooltip.innerHTML = content; + this.tooltip.style.display = "block"; + this.tooltip.style.left = event.pageX + 10 + "px"; + this.tooltip.style.top = event.pageY + 10 + "px"; + } + + /** + * Hide tooltip + */ + private hideTooltip(): void { + this.tooltip.style.display = "none"; + } + + /** + * Update physics parameters in real-time + */ + public updatePhysics(params: Partial): void { + const d3 = (window as any).d3; + + if (params.chargeStrength !== undefined) { + this.simulation.force("charge", d3.forceManyBody().strength(params.chargeStrength)); + } + + if (params.linkDistance !== undefined) { + this.simulation.force( + "link", + d3 + .forceLink(this.links) + .id((d: GraphNode) => d.id) + .distance(params.linkDistance), + ); + } + + // Restart simulation with new parameters + this.simulation.alpha(0.3).restart(); + } + + /** + * Apply theme changes + */ + public applyTheme(isDark: boolean): void { + const d3 = (window as any).d3; + this.isDarkTheme = isDark; + + // Update colors + this.nodeElements.attr("fill", (d: GraphNode) => this.getNodeColor(d)).attr("stroke", isDark ? "#333" : "#fff"); + + this.linkElements.attr("stroke", isDark ? "#666" : "#999"); + + this.g.selectAll(".node-label").attr("fill", isDark ? "#e0e0e0" : "#333"); + + // Update arrowhead + this.svg.select("#arrowhead path").attr("fill", "#999"); + } + + /** + * Get SVG element (for download in Phase 5) + */ + public getSvgElement(): SVGElement { + return this.svg.node(); + } + + /** + * Apply filters to show/hide node groups + */ + public applyFilters(hiddenGroups: Set): void { + const d3 = (window as any).d3; + + // Filter nodes + this.nodeElements + .style("opacity", (d: GraphNode) => (hiddenGroups.has(d.group) ? 0 : 1)) + .style("pointer-events", (d: GraphNode) => (hiddenGroups.has(d.group) ? "none" : "all")); + + // Filter node labels + this.g.selectAll(".node-label").style("opacity", (d: GraphNode) => (hiddenGroups.has(d.group) ? 0 : 1)); + + // Filter links (hide if either source or target is hidden) + this.linkElements + .style("opacity", (d: any) => { + const sourceHidden = hiddenGroups.has(d.source.group); + const targetHidden = hiddenGroups.has(d.target.group); + return sourceHidden || targetHidden ? 0 : 0.6; + }) + .style("pointer-events", (d: any) => { + const sourceHidden = hiddenGroups.has(d.source.group); + const targetHidden = hiddenGroups.has(d.target.group); + return sourceHidden || targetHidden ? "none" : "all"; + }); + + // Filter link icons + if (this.linkIconsGroup) { + this.linkIconsGroup + .selectAll(".predicate-icon") + .style("opacity", (d: any) => { + const sourceHidden = hiddenGroups.has(d.source.group); + const targetHidden = hiddenGroups.has(d.target.group); + return sourceHidden || targetHidden ? 0 : 1; + }) + .style("pointer-events", (d: any) => { + const sourceHidden = hiddenGroups.has(d.source.group); + const targetHidden = hiddenGroups.has(d.target.group); + return sourceHidden || targetHidden ? "none" : "all"; + }); + } + } + + /** + * Cleanup + */ + public destroy(): void { + if (this.simulation) { + this.simulation.stop(); + } + if (this.tooltip && this.tooltip.parentNode) { + this.tooltip.parentNode.removeChild(this.tooltip); + } + if (this.svg) { + this.svg.remove(); + } + } +} diff --git a/packages/yasr/src/plugins/enhanced-graph/iconMappings.ts b/packages/yasr/src/plugins/enhanced-graph/iconMappings.ts new file mode 100644 index 00000000..37000638 --- /dev/null +++ b/packages/yasr/src/plugins/enhanced-graph/iconMappings.ts @@ -0,0 +1,111 @@ +/** + * Default mappings from RDF predicate IRIs to FontAwesome icon names + * Users can override these via plugin configuration + */ + +// FontAwesome icon imports +import * as faTag from "@fortawesome/free-solid-svg-icons/faTag"; +import * as faFont from "@fortawesome/free-solid-svg-icons/faFont"; +import * as faComment from "@fortawesome/free-solid-svg-icons/faComment"; +import * as faUsers from "@fortawesome/free-solid-svg-icons/faUsers"; +import * as faIdCard from "@fortawesome/free-solid-svg-icons/faIdCard"; +import * as faHome from "@fortawesome/free-solid-svg-icons/faHome"; +import * as faUser from "@fortawesome/free-solid-svg-icons/faUser"; +import * as faCalendar from "@fortawesome/free-solid-svg-icons/faCalendar"; +import * as faHeading from "@fortawesome/free-solid-svg-icons/faHeading"; +import * as faUserEdit from "@fortawesome/free-solid-svg-icons/faUserEdit"; +import * as faLink from "@fortawesome/free-solid-svg-icons/faLink"; +import * as faArrowRight from "@fortawesome/free-solid-svg-icons/faArrowRight"; +import * as faBook from "@fortawesome/free-solid-svg-icons/faBook"; +import * as faGlobe from "@fortawesome/free-solid-svg-icons/faGlobe"; +import * as faEnvelope from "@fortawesome/free-solid-svg-icons/faEnvelope"; +import * as faImage from "@fortawesome/free-solid-svg-icons/faImage"; + +/** + * Default predicate-to-icon mappings + */ +export const DEFAULT_ICON_MAP: Record = { + // RDF/RDFS + "http://www.w3.org/1999/02/22-rdf-syntax-ns#type": "faTag", + "http://www.w3.org/2000/01/rdf-schema#label": "faFont", + "http://www.w3.org/2000/01/rdf-schema#comment": "faComment", + "http://www.w3.org/2000/01/rdf-schema#seeAlso": "faLink", + "http://www.w3.org/2000/01/rdf-schema#isDefinedBy": "faBook", + + // FOAF (Friend of a Friend) + "http://xmlns.com/foaf/0.1/knows": "faUsers", + "http://xmlns.com/foaf/0.1/name": "faIdCard", + "http://xmlns.com/foaf/0.1/homepage": "faHome", + "http://xmlns.com/foaf/0.1/mbox": "faEnvelope", + "http://xmlns.com/foaf/0.1/depiction": "faImage", + "http://xmlns.com/foaf/0.1/page": "faGlobe", + + // Dublin Core + "http://purl.org/dc/terms/creator": "faUser", + "http://purl.org/dc/terms/created": "faCalendar", + "http://purl.org/dc/terms/title": "faHeading", + "http://purl.org/dc/terms/description": "faComment", + "http://purl.org/dc/elements/1.1/creator": "faUser", + "http://purl.org/dc/elements/1.1/date": "faCalendar", + "http://purl.org/dc/elements/1.1/title": "faHeading", + + // Schema.org + "http://schema.org/author": "faUserEdit", + "http://schema.org/url": "faLink", + "http://schema.org/name": "faIdCard", + "http://schema.org/email": "faEnvelope", + "http://schema.org/image": "faImage", + + // OWL + "http://www.w3.org/2002/07/owl#sameAs": "faLink", + + // Default fallback + default: "faArrowRight", +}; + +/** + * FontAwesome icon definitions map + */ +export const ICON_DEFINITIONS: Record = { + faTag, + faFont, + faComment, + faUsers, + faIdCard, + faHome, + faUser, + faCalendar, + faHeading, + faUserEdit, + faLink, + faArrowRight, + faBook, + faGlobe, + faEnvelope, + faImage, +}; + +/** + * Get the FontAwesome icon for a predicate IRI + */ +export function getIconForPredicate(predicateIri: string, customMappings?: Record): string { + // Check custom mappings first + if (customMappings && customMappings[predicateIri]) { + return customMappings[predicateIri]; + } + + // Check default mappings + if (DEFAULT_ICON_MAP[predicateIri]) { + return DEFAULT_ICON_MAP[predicateIri]; + } + + // Return default fallback + return DEFAULT_ICON_MAP.default; +} + +/** + * Get the FontAwesome icon definition object + */ +export function getIconDefinition(iconName: string): any { + return ICON_DEFINITIONS[iconName] || ICON_DEFINITIONS.faArrowRight; +} diff --git a/packages/yasr/src/plugins/enhanced-graph/index.scss b/packages/yasr/src/plugins/enhanced-graph/index.scss new file mode 100644 index 00000000..6130a05c --- /dev/null +++ b/packages/yasr/src/plugins/enhanced-graph/index.scss @@ -0,0 +1,532 @@ +/** + * Enhanced Graph Plugin Styles + */ + +// Tooltip (appended to document.body, so needs to be at top level) +.graph-tooltip { + position: absolute; + padding: 8px 12px; + background: rgba(0, 0, 0, 0.85); + color: white; + border-radius: 4px; + font-size: 12px; + font-family: + "Inter", + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Roboto, + sans-serif; + pointer-events: none; + z-index: 10000; // Very high to ensure it's above everything + max-width: 400px; + word-wrap: break-word; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + + .tooltip-label { + font-weight: 600; + margin-bottom: 4px; + } + + .tooltip-iri { + font-size: 10px; + opacity: 0.8; + font-family: monospace; + } +} + +// Dark theme tooltip (also at top level) +[data-theme="dark"] { + .graph-tooltip { + background: rgba(50, 50, 50, 0.95); + color: #e0e0e0; + } +} + +.yasr { + .yasr_results { + &.enhanced-graph { + position: relative; + height: 100%; + display: flex; + flex-direction: column; + + // Main graph container + .enhanced-graph-container { + position: relative; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + } + + // SVG canvas (Phase 2) + svg { + width: 100%; + height: 100%; + flex: 1; + background: transparent; + + // Node styles + .node { + cursor: pointer; + stroke: #fff; + stroke-width: 2px; + + &:hover { + stroke-width: 3px; + } + + &.fixed { + stroke: #ff6b6b; + } + } + + // Link styles + .link { + stroke: #999; + stroke-opacity: 0.6; + stroke-width: 1.5px; + + &:hover { + stroke-opacity: 1; + stroke-width: 2.5px; + } + } + + // Arrowhead marker + marker { + fill: #999; + } + + // Node labels + .node-label { + font-size: 12px; + pointer-events: none; + user-select: none; + } + + // Predicate icons + .predicate-icon { + cursor: pointer; + + &:hover { + opacity: 0.8; + } + } + } + + // Placeholder info (Phase 1) + .graph-info { + padding: 40px; + text-align: center; + font-family: + "Inter", + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Roboto, + sans-serif; + + h3 { + margin-bottom: 20px; + font-size: 24px; + color: #333; + } + + p { + margin: 10px 0; + font-size: 14px; + color: #666; + } + + .coming-soon { + margin-top: 20px; + font-style: italic; + color: #999; + } + } + + .no-data { + padding: 40px; + text-align: center; + font-size: 16px; + color: #999; + } + + // Footer + .graph-footer { + position: absolute; + bottom: 8px; + right: 10px; + font-size: 10px; + color: #999; + z-index: 100; + + a { + color: #337ab7; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + } + + // Control panel (Phase 4) + .enhanced-graph-controls { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; // Allow clicks to pass through to graph + z-index: 1000; // Ensure controls are above graph + + > * { + pointer-events: all; // But enable clicks on controls + } + } + + .control-panel { + position: absolute; // Changed to absolute + width: 280px; + background: white; + border: 1px solid #d1d1d1; + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 1001; + top: 60px; + right: 10px; + font-family: + "Inter", + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Roboto, + sans-serif; + + &.hidden { + display: none; + } + + .panel-header { + padding: 12px 16px; + background: #f5f5f5; + border-bottom: 1px solid #d1d1d1; + border-radius: 6px 6px 0 0; + cursor: move; + display: flex; + justify-content: space-between; + align-items: center; + + h4 { + margin: 0; + font-size: 14px; + font-weight: 600; + color: #333; + } + + .close-btn { + background: none; + border: none; + cursor: pointer; + font-size: 18px; + color: #666; + padding: 0; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + color: #333; + } + } + } + + .panel-content { + padding: 16px; + max-height: 500px; + overflow-y: auto; + } + + .panel-section { + margin-bottom: 20px; + + &:last-child { + margin-bottom: 0; + } + + h5 { + margin: 0 0 10px 0; + font-size: 13px; + font-weight: 600; + color: #555; + } + + .slider-control { + margin-bottom: 12px; + + label { + display: flex; + justify-content: space-between; + font-size: 12px; + color: #666; + margin-bottom: 6px; + + .value { + font-weight: 600; + color: #337ab7; + } + } + + input[type="range"] { + width: 100%; + height: 4px; + border-radius: 2px; + background: #d3d3d3; + outline: none; + appearance: none; + + &::-webkit-slider-thumb { + appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: #337ab7; + cursor: pointer; + + &:hover { + background: #2868a0; + } + } + + &::-moz-range-thumb { + width: 16px; + height: 16px; + border-radius: 50%; + background: #337ab7; + cursor: pointer; + border: none; + + &:hover { + background: #2868a0; + } + } + } + } + + .filter-action-btn { + font-size: 11px; + padding: 4px 8px; + margin-bottom: 8px; + background: #f0f0f0; + border: 1px solid #ccc; + border-radius: 3px; + cursor: pointer; + transition: background 0.2s; + + &:hover { + background: #e0e0e0; + } + } + + .checkbox-list { + display: flex; + flex-direction: column; + gap: 8px; + + label { + display: flex; + align-items: center; + font-size: 12px; + color: #666; + cursor: pointer; + + input[type="checkbox"] { + margin-right: 8px; + } + + .color-indicator { + width: 12px; + height: 12px; + border-radius: 50%; + margin-right: 8px; + border: 1px solid #ccc; + } + } + } + + .legend-list { + display: flex; + flex-direction: column; + gap: 6px; + + .legend-item { + display: flex; + align-items: center; + font-size: 12px; + color: #666; + + .color-indicator { + width: 12px; + height: 12px; + border-radius: 50%; + margin-right: 8px; + border: 1px solid #ccc; + } + } + } + } + } + + // Control toggle button + .control-toggle { + position: absolute; // Changed from fixed to absolute + top: 10px; + right: 10px; + width: 40px; + height: 40px; + background: white; + border: 1px solid #d1d1d1; + border-radius: 50%; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + z-index: 1001; + transition: all 0.2s ease; + + &:hover { + background: #f5f5f5; + box-shadow: 0 3px 12px rgba(0, 0, 0, 0.15); + } + + svg { + width: 20px; + height: 20px; + fill: #666; + } + } + } + } +} + +// Dark theme support +[data-theme="dark"] { + .yasr .yasr_results.enhanced-graph { + .graph-info { + h3 { + color: #e0e0e0; + } + + p { + color: #aaa; + } + + .coming-soon { + color: #777; + } + } + + .no-data { + color: #aaa; + } + + .graph-footer { + color: #777; + + a { + color: #6cb3ff; + } + } + + svg { + .node { + stroke: #333; + } + + .node-label { + fill: #e0e0e0; + } + } + + .control-panel { + background: #2d2d2d; + border-color: #555; + color: #e0e0e0; + + .panel-header { + background: #3a3a3a; + border-color: #555; + + h4 { + color: #e0e0e0; + } + + .close-btn { + color: #aaa; + + &:hover { + color: #e0e0e0; + } + } + } + + .panel-section { + h5 { + color: #bbb; + } + + label { + color: #aaa; + } + + .checkbox-list label { + color: #aaa; + } + + .legend-list .legend-item { + color: #aaa; + } + } + } + + .control-toggle { + background: #2d2d2d; + border-color: #555; + + &:hover { + background: #3a3a3a; + } + + svg { + fill: #aaa; + } + } + } +} + +// Responsive design +@media (max-width: 768px) { + .yasr .yasr_results.enhanced-graph { + .control-panel { + width: 90%; + max-width: 280px; + right: 5%; + } + + .control-toggle { + width: 36px; + height: 36px; + + svg { + width: 18px; + height: 18px; + } + } + } +} diff --git a/packages/yasr/src/plugins/enhanced-graph/index.ts b/packages/yasr/src/plugins/enhanced-graph/index.ts new file mode 100644 index 00000000..cc96f0cf --- /dev/null +++ b/packages/yasr/src/plugins/enhanced-graph/index.ts @@ -0,0 +1,413 @@ +/** + * Enhanced Graph Visualization Plugin for Yasgui + * Provides interactive D3.js-based force-directed graph visualization for RDF data + */ + +import { Plugin, DownloadInfo } from "../"; +import Yasr from "../../"; +import "./index.scss"; +import { PluginConfig, GraphData, GraphNode, GraphLink, Prefixes, PhysicsParams, NodeGroup } from "./types"; +import { shortenIri, getTermLabel, isUri, isLiteral } from "./utils"; +import { drawSvgStringAsElement, drawFontAwesomeIconAsSvg } from "@matdata/yasgui-utils"; +import * as faProjectDiagram from "@fortawesome/free-solid-svg-icons/faProjectDiagram"; +import * as N3 from "n3"; +import { DeepReadonly } from "ts-essentials"; +import { GraphRenderer } from "./GraphRenderer"; +import { groupNodes } from "./nodeGrouping"; +import { ControlPanel } from "./ControlPanel"; +import { injectAllMetadata, removeMetadata } from "./metadataGenerator"; + +export default class EnhancedGraph implements Plugin { + private yasr: Yasr; + public label = "Enhanced Graph"; + public priority = 11; // Higher than default Graph plugin + public helpReference = "https://docs.triply.cc/yasgui/#enhanced-graph"; + private config: DeepReadonly; + private graphData: GraphData | null = null; + private graphRenderer: GraphRenderer | null = null; + private nodeGroups: Map | null = null; + private quads: N3.Quad[] | null = null; + private controlPanel: ControlPanel | null = null; + private themeObserver: MutationObserver | null = null; + + constructor(yasr: Yasr) { + this.yasr = yasr; + this.config = EnhancedGraph.defaults; + + // Merge with any user-provided dynamic config + if (yasr.config.plugins["enhanced-graph"] && yasr.config.plugins["enhanced-graph"].dynamicConfig) { + this.config = { + ...this.config, + ...yasr.config.plugins["enhanced-graph"].dynamicConfig, + }; + } + } + + /** + * Check if this plugin can handle the current results + */ + canHandleResults(): boolean { + if (!this.yasr.results) return false; + + // Check if we have RDF statements (CONSTRUCT/DESCRIBE queries) + const statements = this.yasr.results.getStatements(); + if (!statements || statements.length === 0) return false; + + // Count unique nodes + const nodeIds = new Set(); + statements.forEach((quad) => { + nodeIds.add(quad.subject.value); + nodeIds.add(quad.object.value); + }); + + const nodeCount = nodeIds.size; + + // Warn if graph is large + if (nodeCount > (this.config.maxNodes || 500)) { + console.warn( + `Enhanced Graph: Graph has ${nodeCount} nodes, which exceeds the recommended limit of ${ + this.config.maxNodes || 500 + }. Performance may be affected.`, + ); + } + + return true; + } + + /** + * Get the icon for the plugin selector + */ + getIcon(): Element { + return drawSvgStringAsElement(drawFontAwesomeIconAsSvg(faProjectDiagram)); + } + + /** + * Initialize the plugin (load D3.js from CDN) + */ + async initialize(): Promise { + // Check if D3.js is already loaded + if (!(window as any).d3) { + console.log("Enhanced Graph: Loading D3.js from CDN..."); + + // Load D3.js v7 from CDN + await this.loadScript("https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"); + + if (!(window as any).d3) { + throw new Error("Failed to load D3.js library"); + } + + console.log("Enhanced Graph: D3.js loaded successfully"); + } + } + + /** + * Helper to load external scripts + */ + private loadScript(src: string): Promise { + return new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = src; + script.onload = () => resolve(); + script.onerror = () => reject(new Error(`Failed to load script: ${src}`)); + document.head.appendChild(script); + }); + } + + /** + * Main drawing function + */ + async draw(persistentConfig?: Partial): Promise { + // Merge configs + const config: DeepReadonly = { + ...this.config, + ...persistentConfig, + }; + + // Get RDF statements + this.quads = this.yasr.results?.getStatements() || null; + if (!this.quads || this.quads.length === 0) { + this.yasr.resultsEl.innerHTML = "
No RDF data to visualize
"; + return; + } + + // Get prefixes for URI shortening + const prefixes = this.yasr.getPrefixes() || {}; + + // Transform quads to graph data + this.graphData = this.quadsToGraph(this.quads, prefixes); + + // Group nodes based on strategy + this.nodeGroups = groupNodes(this.graphData.nodes, this.quads, prefixes, config.nodeGrouping.strategy); + + // Clear the results element + this.yasr.resultsEl.innerHTML = ""; + + // Add class to results element for styling + this.yasr.resultsEl.classList.add("enhanced-graph"); + + // Create container + const container = document.createElement("div"); + container.className = "enhanced-graph-container"; + + // Set container to full height - critical for SVG rendering + container.style.width = "100%"; + container.style.height = "100%"; + container.style.position = "relative"; + container.style.minHeight = "600px"; // Ensure minimum height + + this.yasr.resultsEl.appendChild(container); + + // Wait for next frame to ensure container has dimensions + requestAnimationFrame(() => { + // Create graph renderer + const physicsParams: PhysicsParams = { + chargeStrength: config.physics.chargeStrength, + linkDistance: config.physics.linkDistance, + }; + + // Load saved predicate display preference + const savedShowLabels = ControlPanel.loadPredicateDisplayPreference(); + + // Load saved tooltip preference + const savedCompactTooltips = ControlPanel.loadTooltipPreference(); + + console.log("Enhanced Graph: Creating GraphRenderer..."); + + try { + this.graphRenderer = new GraphRenderer( + container, + this.graphData!, + physicsParams, + this.nodeGroups!, + config.iconMappings, + config.showPredicateLabels !== undefined ? config.showPredicateLabels : savedShowLabels, + savedCompactTooltips, + ); + + // Create control panel after successful graph creation + const savedPosition = ControlPanel.loadPosition(); + const savedVisibility = ControlPanel.loadVisibility(); + + this.controlPanel = new ControlPanel(container, { + onPhysicsChange: (params) => { + if (this.graphRenderer) { + this.graphRenderer.updatePhysics(params); + } + }, + onFilterChange: (hiddenGroups) => { + if (this.graphRenderer) { + this.graphRenderer.applyFilters(hiddenGroups); + } + }, + onPredicateDisplayChange: (showLabels) => { + if (this.graphRenderer) { + this.graphRenderer.setPredicateDisplay(showLabels); + } + }, + onTooltipCurieChange: (useCuries) => { + if (this.graphRenderer) { + this.graphRenderer.setTooltipFormat(useCuries); + } + }, + initialPhysics: physicsParams, + nodeGroups: this.nodeGroups!, + showPredicateLabels: config.showPredicateLabels !== undefined ? config.showPredicateLabels : savedShowLabels, + useCompactTooltips: savedCompactTooltips, + defaultOpen: config.controlPanel?.defaultOpen || savedVisibility, + initialPosition: config.controlPanel?.position || savedPosition, + }); + + console.log("Enhanced Graph: Initialization complete!"); + } catch (error) { + console.error("Enhanced Graph: Error creating renderer:", error); + container.innerHTML = `
Error rendering graph: ${error}
`; + } + }); + + // Inject metadata if enabled + if (config.enableMetadata !== false) { + injectAllMetadata(this.graphData); + } + + // Add footer if enabled + if (config.showFooter !== false) { + const footer = this.createFooter(); + container.appendChild(footer); + } + + // Setup theme observer for runtime theme switching + this.setupThemeObserver(); + } + + /** + * Setup MutationObserver to watch for theme changes + */ + private setupThemeObserver(): void { + this.themeObserver = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.type === "attributes" && mutation.attributeName === "data-theme") { + const isDark = document.documentElement.getAttribute("data-theme") === "dark"; + if (this.graphRenderer) { + this.graphRenderer.applyTheme(isDark); + } + } + }); + }); + + this.themeObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-theme"], + }); + } + + /** + * Transform N3 quads into graph data structure + */ + private quadsToGraph(quads: N3.Quad[], prefixes: Prefixes): GraphData { + const nodesMap = new Map(); + const links: GraphLink[] = []; + const nodeDegree = new Map(); + + // Process each quad + quads.forEach((quad) => { + const subjectId = quad.subject.value; + const objectId = quad.object.value; + const predicateIri = quad.predicate.value; + + // Add subject node + if (!nodesMap.has(subjectId)) { + nodesMap.set(subjectId, { + id: subjectId, + label: getTermLabel(quad.subject, prefixes), + fullIri: subjectId, + group: "default", // Will be set in Phase 3 + type: isUri(quad.subject) ? "uri" : isLiteral(quad.subject) ? "literal" : "bnode", + }); + } + + // Add object node + if (!nodesMap.has(objectId)) { + nodesMap.set(objectId, { + id: objectId, + label: getTermLabel(quad.object, prefixes), + fullIri: objectId, + group: "default", // Will be set in Phase 3 + type: isUri(quad.object) ? "uri" : isLiteral(quad.object) ? "literal" : "bnode", + }); + } + + // Update degree counts + nodeDegree.set(subjectId, (nodeDegree.get(subjectId) || 0) + 1); + nodeDegree.set(objectId, (nodeDegree.get(objectId) || 0) + 1); + + // Add link + links.push({ + source: subjectId, + target: objectId, + predicate: predicateIri, + predicateLabel: shortenIri(predicateIri, prefixes), + }); + }); + + // Set degree on nodes + const nodes = Array.from(nodesMap.values()); + nodes.forEach((node) => { + node.degree = nodeDegree.get(node.id) || 0; + }); + + return { nodes, links }; + } + + /** + * Create footer element + */ + private createFooter(): HTMLElement { + const footer = document.createElement("div"); + footer.className = "graph-footer"; + footer.innerHTML = ` + Generated using OPAL + and deployed using Virtuoso + `; + return footer; + } + + /** + * Cleanup when plugin is deselected + */ + destroy(): void { + // Remove injected metadata + removeMetadata(); + + // Disconnect theme observer + if (this.themeObserver) { + this.themeObserver.disconnect(); + this.themeObserver = null; + } + + if (this.graphRenderer) { + this.graphRenderer.destroy(); + this.graphRenderer = null; + } + if (this.controlPanel) { + this.controlPanel.destroy(); + this.controlPanel = null; + } + this.graphData = null; + this.nodeGroups = null; + this.quads = null; + } + + /** + * Download current graph as SVG + */ + download(filename?: string): DownloadInfo | undefined { + if (!this.graphData || !this.graphRenderer) return undefined; + + return { + getData: () => { + const svgElement = this.graphRenderer!.getSvgElement(); + const serializer = new XMLSerializer(); + let svgString = serializer.serializeToString(svgElement); + + // Add XML declaration and namespace + svgString = '\n' + svgString; + + // Add title and description for accessibility + const titleAndDesc = ` + RDF Graph Visualization + Interactive force-directed graph with ${this.graphData!.nodes.length} nodes and ${ + this.graphData!.links.length + } edges. Generated by Yasgui Enhanced Graph Plugin. +`; + svgString = svgString.replace(" { + return [ + { + rel: "alternate", + type: "application/rss+xml", + title: "RSS Feed", + }, + { + rel: "alternate", + type: "application/atom+xml", + title: "Atom Feed", + }, + { + rel: "alternate", + type: "application/ld+json", + title: "JSON-LD", + }, + { + rel: "alternate", + type: "text/turtle", + title: "Turtle (RDF)", + }, + ]; +} + +/** + * Inject JSON-LD script into document head + */ +export function injectJSONLD(jsonld: object): void { + // Remove any existing Enhanced Graph metadata + const existing = document.head.querySelector('script[data-enhanced-graph-metadata="true"]'); + if (existing) { + existing.remove(); + } + + // Create and inject new script tag + const script = document.createElement("script"); + script.type = "application/ld+json"; + script.setAttribute("data-enhanced-graph-metadata", "true"); + script.textContent = JSON.stringify(jsonld, null, 2); + document.head.appendChild(script); +} + +/** + * Inject POSH link tags into document head + */ +export function injectPOSHLinks(links: Array<{ rel: string; type: string; title: string }>): void { + // Remove any existing Enhanced Graph POSH links + const existing = document.head.querySelectorAll('link[data-enhanced-graph-posh="true"]'); + existing.forEach((el) => el.remove()); + + // Create and inject new link tags + links.forEach((linkData) => { + const link = document.createElement("link"); + link.rel = linkData.rel; + link.type = linkData.type; + link.title = linkData.title; + link.setAttribute("data-enhanced-graph-posh", "true"); + // Note: href would typically point to actual alternate representations + // For now, we're just adding the metadata structure + document.head.appendChild(link); + }); +} + +/** + * Remove all injected metadata + */ +export function removeMetadata(): void { + // Remove JSON-LD + const jsonldScript = document.head.querySelector('script[data-enhanced-graph-metadata="true"]'); + if (jsonldScript) { + jsonldScript.remove(); + } + + // Remove POSH links + const poshLinks = document.head.querySelectorAll('link[data-enhanced-graph-posh="true"]'); + poshLinks.forEach((el) => el.remove()); +} + +/** + * Main function to inject all metadata + */ +export function injectAllMetadata(graphData: GraphData): void { + const jsonld = generateJSONLD(graphData); + const poshLinks = generatePOSHLinks(); + + injectJSONLD(jsonld); + injectPOSHLinks(poshLinks); +} diff --git a/packages/yasr/src/plugins/enhanced-graph/nodeGrouping.ts b/packages/yasr/src/plugins/enhanced-graph/nodeGrouping.ts new file mode 100644 index 00000000..32a7affe --- /dev/null +++ b/packages/yasr/src/plugins/enhanced-graph/nodeGrouping.ts @@ -0,0 +1,248 @@ +/** + * Node Grouping Logic + * Analyzes RDF data to intelligently group nodes by type, namespace, or role + */ + +import * as N3 from "n3"; +import { GraphNode, NodeGroup, GroupingStrategy, Prefixes } from "./types"; +import { getNamespace, shortenIri, generateColorPalette } from "./utils"; + +/** + * Main function to group nodes based on strategy + */ +export function groupNodes( + nodes: GraphNode[], + quads: N3.Quad[], + prefixes: Prefixes, + strategy: GroupingStrategy = "type", +): Map { + switch (strategy) { + case "type": + return groupByType(nodes, quads, prefixes); + case "namespace": + return groupByNamespace(nodes, prefixes); + case "role": + return groupByRole(nodes, quads); + default: + return groupByType(nodes, quads, prefixes); + } +} + +/** + * Group nodes by their rdf:type + */ +function groupByType(nodes: GraphNode[], quads: N3.Quad[], prefixes: Prefixes): Map { + const groups = new Map(); + const nodeTypes = new Map(); // nodeId -> type + + // Find all rdf:type statements + const rdfType = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"; + + quads.forEach((quad) => { + if (quad.predicate.value === rdfType) { + const subjectId = quad.subject.value; + const typeId = quad.object.value; + nodeTypes.set(subjectId, typeId); + } + }); + + // Count nodes by type + const typeCounts = new Map(); + nodeTypes.forEach((type) => { + typeCounts.set(type, (typeCounts.get(type) || 0) + 1); + }); + + // Create groups for types + const colors = generateColorPalette(typeCounts.size + 2); // +2 for literals and untyped + let colorIndex = 0; + + typeCounts.forEach((count, typeId) => { + const groupLabel = shortenIri(typeId, prefixes); + groups.set(typeId, { + id: typeId, + label: groupLabel, + color: colors[colorIndex++], + count: count, + }); + }); + + // Assign groups to nodes + nodes.forEach((node) => { + if (node.type === "literal") { + node.group = "literal"; + if (!groups.has("literal")) { + groups.set("literal", { + id: "literal", + label: "Literals", + color: colors[colorIndex] || "#7f7f7f", + count: 0, + }); + } + groups.get("literal")!.count++; + } else if (nodeTypes.has(node.id)) { + node.group = nodeTypes.get(node.id)!; + } else { + // Fallback: try namespace-based grouping + const namespace = getNamespace(node.id); + if (namespace) { + node.group = namespace; + if (!groups.has(namespace)) { + const namespaceLabel = shortenIri(namespace, prefixes) || "Other"; + groups.set(namespace, { + id: namespace, + label: namespaceLabel, + color: colors[(colorIndex + 1) % colors.length], + count: 0, + }); + colorIndex++; + } + groups.get(namespace)!.count++; + } else { + node.group = "untyped"; + if (!groups.has("untyped")) { + groups.set("untyped", { + id: "untyped", + label: "Untyped", + color: colors[colors.length - 1] || "#bcbd22", + count: 0, + }); + } + groups.get("untyped")!.count++; + } + } + }); + + return groups; +} + +/** + * Group nodes by their namespace + */ +function groupByNamespace(nodes: GraphNode[], prefixes: Prefixes): Map { + const groups = new Map(); + const namespaceCounts = new Map(); + + // Count nodes by namespace + nodes.forEach((node) => { + if (node.type === "literal") { + namespaceCounts.set("literal", (namespaceCounts.get("literal") || 0) + 1); + } else { + const namespace = getNamespace(node.id); + if (namespace) { + namespaceCounts.set(namespace, (namespaceCounts.get(namespace) || 0) + 1); + } else { + namespaceCounts.set("other", (namespaceCounts.get("other") || 0) + 1); + } + } + }); + + // Generate colors + const colors = generateColorPalette(namespaceCounts.size); + let colorIndex = 0; + + // Create groups + namespaceCounts.forEach((count, namespace) => { + const label = namespace === "literal" ? "Literals" : shortenIri(namespace, prefixes) || namespace; + groups.set(namespace, { + id: namespace, + label: label, + color: colors[colorIndex++], + count: count, + }); + }); + + // Assign groups to nodes + nodes.forEach((node) => { + if (node.type === "literal") { + node.group = "literal"; + } else { + const namespace = getNamespace(node.id); + node.group = namespace || "other"; + } + }); + + return groups; +} + +/** + * Group nodes by their role (subject-only, object-only, or both) + */ +function groupByRole(nodes: GraphNode[], quads: N3.Quad[]): Map { + const groups = new Map(); + const subjects = new Set(); + const objects = new Set(); + + // Identify subjects and objects + quads.forEach((quad) => { + subjects.add(quad.subject.value); + objects.add(quad.object.value); + }); + + // Count nodes by role + let subjectOnlyCount = 0; + let objectOnlyCount = 0; + let bothCount = 0; + let literalCount = 0; + + nodes.forEach((node) => { + if (node.type === "literal") { + node.group = "literal"; + literalCount++; + } else if (subjects.has(node.id) && objects.has(node.id)) { + node.group = "both"; + bothCount++; + } else if (subjects.has(node.id)) { + node.group = "subject-only"; + subjectOnlyCount++; + } else { + node.group = "object-only"; + objectOnlyCount++; + } + }); + + // Create groups with distinct colors + groups.set("both", { + id: "both", + label: "Subject & Object", + color: "#1f77b4", // Blue + count: bothCount, + }); + + groups.set("subject-only", { + id: "subject-only", + label: "Subject Only", + color: "#2ca02c", // Green + count: subjectOnlyCount, + }); + + groups.set("object-only", { + id: "object-only", + label: "Object Only", + color: "#ff7f0e", // Orange + count: objectOnlyCount, + }); + + if (literalCount > 0) { + groups.set("literal", { + id: "literal", + label: "Literals", + color: "#7f7f7f", // Gray + count: literalCount, + }); + } + + return groups; +} + +/** + * Apply node colors based on groups + */ +export function applyNodeColors(nodes: GraphNode[], groups: Map): void { + nodes.forEach((node) => { + const group = groups.get(node.group); + if (group) { + // Color will be read from group in GraphRenderer + // This function is for future extensibility + } + }); +} diff --git a/packages/yasr/src/plugins/enhanced-graph/types.ts b/packages/yasr/src/plugins/enhanced-graph/types.ts new file mode 100644 index 00000000..0a99ba1b --- /dev/null +++ b/packages/yasr/src/plugins/enhanced-graph/types.ts @@ -0,0 +1,80 @@ +import type * as N3 from "n3"; + +// D3.js types (loaded from CDN) +declare global { + interface Window { + d3: typeof import("d3"); + } +} + +// Graph data structures +export interface GraphNode { + id: string; // Full IRI or literal value + label: string; // Shortened label (using prefixes) + fullIri: string; // Full IRI for tooltips/links + group: string; // Group name for coloring + type: "uri" | "literal" | "bnode"; + degree?: number; // Number of connections (for sizing) + fx?: number | null; // Fixed x position (for sticky behavior) + fy?: number | null; // Fixed y position (for sticky behavior) + x?: number; // Current x position (D3 managed) + y?: number; // Current y position (D3 managed) +} + +export interface GraphLink { + source: string | GraphNode; // Node ID or reference + target: string | GraphNode; // Node ID or reference + predicate: string; // Predicate IRI + predicateLabel: string; // Shortened predicate label + icon?: string; // FontAwesome icon name +} + +export interface GraphData { + nodes: GraphNode[]; + links: GraphLink[]; +} + +// Node grouping +export interface NodeGroup { + id: string; + label: string; + color: string; + count: number; +} + +export type GroupingStrategy = "type" | "namespace" | "role" | "custom"; + +// Plugin configuration +export interface PluginConfig { + physics: { + chargeStrength: number; // Default: -100 + linkDistance: number; // Default: 80 + collisionRadius?: number; // Default: 15 + }; + nodeGrouping: { + strategy: GroupingStrategy; + customGroupFn?: (node: GraphNode, quads: N3.Quad[]) => string; + colorScheme?: string[]; + }; + iconMappings?: Record; // Predicate URI -> FontAwesome icon name + showPredicateLabels?: boolean; // Show text instead of icons + iconSize?: number; // Default: 16 + maxNodes?: number; // Default: 500 (warn above this) + enableMetadata?: boolean; // Default: true + showFooter?: boolean; // Default: true + controlPanel?: { + defaultOpen?: boolean; + position?: { x: number; y: number }; + }; +} + +// Physics parameters for control panel +export interface PhysicsParams { + chargeStrength: number; + linkDistance: number; +} + +// Prefixes map +export interface Prefixes { + [prefix: string]: string; +} diff --git a/packages/yasr/src/plugins/enhanced-graph/utils.ts b/packages/yasr/src/plugins/enhanced-graph/utils.ts new file mode 100644 index 00000000..eb16ac62 --- /dev/null +++ b/packages/yasr/src/plugins/enhanced-graph/utils.ts @@ -0,0 +1,119 @@ +import { Prefixes } from "./types"; +import * as N3 from "n3"; + +/** + * Shorten an IRI using available prefixes + */ +export function shortenIri(iri: string, prefixes: Prefixes): string { + for (const prefix in prefixes) { + const namespace = prefixes[prefix]; + if (iri.startsWith(namespace)) { + return iri.replace(namespace, `${prefix}:`); + } + } + + // If no prefix match, try to extract a readable part + const lastSlash = iri.lastIndexOf("/"); + const lastHash = iri.lastIndexOf("#"); + const separator = Math.max(lastSlash, lastHash); + + if (separator > 0 && separator < iri.length - 1) { + return iri.substring(separator + 1); + } + + return iri; +} + +/** + * Check if a term is a literal + */ +export function isLiteral(term: N3.Term): boolean { + return term.termType === "Literal"; +} + +/** + * Check if a term is a URI + */ +export function isUri(term: N3.Term): boolean { + return term.termType === "NamedNode"; +} + +/** + * Check if a term is a blank node + */ +export function isBlankNode(term: N3.Term): boolean { + return term.termType === "BlankNode"; +} + +/** + * Get a readable label for a term + */ +export function getTermLabel(term: N3.Term, prefixes: Prefixes): string { + if (isLiteral(term)) { + // For literals, return the value (possibly truncated) + const value = term.value; + return value.length > 50 ? value.substring(0, 47) + "..." : value; + } else if (isUri(term)) { + return shortenIri(term.value, prefixes); + } else if (isBlankNode(term)) { + return term.value; // e.g., "_:b0" + } + return term.value; +} + +/** + * Generate a color palette + */ +export function generateColorPalette(count: number): string[] { + // Using a predefined set of visually distinct colors + const baseColors = [ + "#1f77b4", // Blue + "#ff7f0e", // Orange + "#2ca02c", // Green + "#d62728", // Red + "#9467bd", // Purple + "#8c564b", // Brown + "#e377c2", // Pink + "#7f7f7f", // Gray + "#bcbd22", // Olive + "#17becf", // Cyan + ]; + + if (count <= baseColors.length) { + return baseColors.slice(0, count); + } + + // If we need more colors, repeat with variations + const colors: string[] = []; + for (let i = 0; i < count; i++) { + colors.push(baseColors[i % baseColors.length]); + } + return colors; +} + +/** + * Extract namespace from an IRI + */ +export function getNamespace(iri: string): string { + const lastSlash = iri.lastIndexOf("/"); + const lastHash = iri.lastIndexOf("#"); + const separator = Math.max(lastSlash, lastHash); + + if (separator > 0) { + return iri.substring(0, separator + 1); + } + + return iri; +} + +/** + * Validate if a string is a valid IRI + */ +export function isValidIri(str: string): boolean { + try { + new URL(str); + return true; + } catch { + return false; + } +}