@matdata/yasgui-graph-plugin 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,7 +17,8 @@ A YASGUI plugin for visualizing SPARQL CONSTRUCT and DESCRIBE query results as i
17
17
  - **� Compact Mode**: Hide literal and class nodes; show rdf:type and datatype properties in enhanced tooltips instead
18
18
  - **🔍 Navigation**: Mouse wheel zoom, drag to pan, "Zoom to Fit" button
19
19
  - **✋ Drag & Drop**: Reorganize nodes by dragging them to new positions (nodes stay pinned after manual drag)
20
- - **💬 Rich Tooltips**: Modern HTML tooltips with node type, value, namespace, and datatype information
20
+ - **� Node Expansion**: Double-click any URI node to fetch and merge related triples via DESCRIBE queries (see [Expand Nodes with Double Click](#-expand-nodes-with-double-click))
21
+ - **�💬 Rich Tooltips**: Modern HTML tooltips with node type, value, namespace, and datatype information
21
22
  - **🌓 Theme Support**: Automatic light/dark mode detection and dynamic color switching
22
23
  - **⚡ Performance**: Handles up to 1,000 nodes with <2s render time
23
24
  - **♿ Accessible**: WCAG AA color contrast, keyboard navigation support
@@ -105,6 +106,7 @@ After running the query, click the **"Graph"** tab to see the visualization.
105
106
 
106
107
  ### Interaction
107
108
  - **Drag Nodes**: Click and drag any node to reposition it (nodes are automatically pinned in place after dragging)
109
+ - **Expand Nodes**: 🆕 Double-click any blue URI node to fetch and merge additional RDF triples for that resource (see [Node Expansion](#expand-nodes-with-double-click) below)
108
110
  - **Tooltips**: Hover over nodes/edges to see rich HTML tooltips with type, value, namespace, and datatype information
109
111
 
110
112
  ### Understanding Colors
@@ -240,7 +242,100 @@ class CustomGraphPlugin extends GraphPlugin {
240
242
  Yasgui.Yasr.registerPlugin('customGraph', CustomGraphPlugin);
241
243
  ```
242
244
 
243
- ## 🔧 Development
245
+ ## Expand Nodes with Double Click
246
+
247
+ The graph plugin supports **interactive node expansion** via double-clicking. This allows you to progressively explore RDF graphs by fetching additional triples for any URI node without redrawing the entire graph.
248
+
249
+ ### How It Works
250
+
251
+ 1. **Double-click a blue URI node** in the graph
252
+ 2. The node's border turns **orange and thickens** (loading state)
253
+ 3. A `DESCRIBE <uri>` query is sent to the SPARQL endpoint
254
+ 4. **New triples are merged** into the existing graph
255
+ 5. Node's border returns to normal width with **thicker border (3px) to indicate expansion**
256
+ 6. Graph layout and zoom level are **preserved**
257
+
258
+ ### Visual Feedback
259
+
260
+ | State | Border | Meaning |
261
+ |-------|--------|---------|
262
+ | **Default** | 2px | Node has not been expanded |
263
+ | **Loading** | 4px, orange | DESCRIBE query in progress |
264
+ | **Expanded** | 3px, normal color | Successfully expanded |
265
+
266
+ ### Supported Node Types
267
+
268
+ | Node Type | Can Expand? | Reason |
269
+ |-----------|-------|----|
270
+ | 🔵 **URI nodes** | ✅ Yes | DESCRIBE works on URIs |
271
+ | 🟢 **Literals** | ❌ No | Cannot run DESCRIBE on literal values |
272
+ | ⚪ **Blank nodes** | ❌ No | Blank nodes have no resolvable identity |
273
+
274
+ ### Example: Exploring a Knowledge Graph
275
+
276
+ **Initial Query**:
277
+ ```sparql
278
+ PREFIX ex: <http://example.org/>
279
+ PREFIX foaf: <http://xmlns.com/foaf/0.1/>
280
+
281
+ CONSTRUCT {
282
+ ex:alice foaf:knows ex:bob .
283
+ ex:alice foaf:name "Alice" .
284
+ ex:bob foaf:name "Bob" .
285
+ }
286
+ WHERE {}
287
+ ```
288
+
289
+ **Initial Graph**: 3 nodes (Alice, "Alice", Bob, "Bob"), 2 edges
290
+
291
+ **User Action**: Double-click the `ex:bob` node
292
+
293
+ **What Happens**:
294
+ - System executes: `DESCRIBE <http://example.org/bob>`
295
+ - Endpoint returns all triples about Bob (from your SPARQL endpoint)
296
+ - New nodes and edges appear in the graph
297
+ - Graph layout shifts smoothly to accommodate new nodes
298
+ - `ex:bob` node gets a thicker border
299
+
300
+ **Result**: You can now see Bob's relationships, properties, and connections without losing your current view
301
+
302
+ ### Requirements
303
+
304
+ The node expansion feature requires:
305
+
306
+ 1. **SPARQL 1.1 DESCRIBE support**: Your endpoint must support DESCRIBE queries
307
+ 2. **Query execution callback**: YASR must provide `yasr.executeQuery()` for background queries
308
+ 3. **RDF response format**: Endpoint must return results in RDF (JSON-LD, Turtle, N-Triples, etc.)
309
+
310
+ ### Limitations & Behavior
311
+
312
+ - Only new triples are added (existing triples are skipped if already in graph)
313
+ - Expansion is **one-level deep** - only triples directly about the URI are added
314
+ - For very large result sets (1000+ triples from DESCRIBE), performance may be affected
315
+ - Blank nodes returned by DESCRIBE may not connect properly if disconnected from existing nodes
316
+
317
+ ### Troubleshooting Expansion
318
+
319
+ **"Nothing happens when I double-click"**
320
+ - Ensure the node is blue (URI node, not literal or blank node)
321
+ - Check browser console for warnings about `yasr.executeQuery`
322
+ - Verify your SPARQL endpoint supports DESCRIBE queries
323
+
324
+ **"Graph becomes slow after many expansions"**
325
+ - Disable physics simulation in Settings panel for faster UI response
326
+ - Consider limiting query results with WHERE clause constraints
327
+ - Each expansion adds more triples to the visualization
328
+
329
+ **"New nodes don't appear where I expect"**
330
+ - The force-directed layout will position new nodes to minimize overlaps
331
+ - Disable Physics in Settings to lock positions if desired
332
+ - Manually drag new nodes to preferred positions
333
+
334
+ ### Demo
335
+
336
+ See [demo/expand.html](./demo/expand.html) for a working example with mock DESCRIBE responses and detailed logging.
337
+
338
+ ## �🔧 Development
244
339
 
245
340
  ### Build
246
341
 
@@ -272,6 +367,7 @@ npm run format # Prettier format
272
367
  ## 📚 Documentation
273
368
 
274
369
  - **[Quickstart Guide](./specs/001-construct-graph-viz/quickstart.md)** - Installation, usage, troubleshooting
370
+ - **[Node Expansion Feature](./specs/001-construct-graph-viz/EXPAND_FEATURE.md)** - Complete guide to double-click expansion (FR-001 through FR-009)
275
371
  - **[Data Model](./specs/001-construct-graph-viz/data-model.md)** - Entity definitions and relationships
276
372
  - **[Contracts](./specs/001-construct-graph-viz/contracts/)** - API specifications for YASR plugin and vis-network integration
277
373
  - **[Specification](./specs/001-construct-graph-viz/spec.md)** - Complete feature specification
@@ -312,19 +408,18 @@ Contributions welcome! Please follow the project constitution (`.specify/memory/
312
408
  **Current Version**: 0.1.0 (MVP)
313
409
 
314
410
  **Implemented Features** (v0.1.0):
315
- - ✅ Basic graph visualization (US1)
316
- - ✅ Navigation controls (US2)
317
- - ✅ Color-coded nodes
318
- - ✅ Prefix abbreviation
319
- - ✅ Blank node support
320
- - ✅ Performance optimization
321
-
322
- **Planned Features** (Future):
323
- - Enhanced tooltips with datatype display (US4)
324
- - Manual testing across all browsers (US3 verification)
325
- - Large graph optimization (>1k nodes)
326
- - Custom color schemes
327
- - ⏳ Layout algorithm selection
411
+ - ✅ **Basic graph visualization** (US1) - CONSTRUCT/DESCRIBE results as interactive graphs
412
+ - ✅ **Navigation controls** (US2) - Zoom, pan, "Fit to View" button
413
+ - ✅ **Color-coded nodes** - URIs, literals, blank nodes, rdf:type objects
414
+ - ✅ **Prefix abbreviation** - Display prefixed URIs instead of full URLs
415
+ - ✅ **Blank node support** - Handle anonymous RDF nodes
416
+ - ✅ **Drag & repositioning** - Manually adjust node positions
417
+ - ✅ **Rich tooltips** - Hover for detailed node/edge information
418
+ - ✅ **Theme support** - Light/dark mode detection and switching
419
+ - **Settings panel** - Configurable display options with localStorage persistence
420
+ - **Node icons & images** - Display images via schema:image property
421
+ - **Compact mode** - Hide literals and classes for cleaner visualization
422
+ - **Double-click expansion** (US5) - Fetch and merge related triples via DESCRIBE queries (see [Expand Nodes with Double Click](#-expand-nodes-with-double-click))
328
423
 
329
424
  ## 🐛 Troubleshooting
330
425
 
@@ -142,6 +142,40 @@ function getDefaultNetworkOptions(themeColors, settings) {
142
142
  }
143
143
 
144
144
  // src/parsers.ts
145
+ async function parseBackgroundQueryResponse(response) {
146
+ var _a, _b;
147
+ if (!response) return [];
148
+ try {
149
+ let data2;
150
+ if (typeof response.json === "function") {
151
+ data2 = await response.json();
152
+ } else if (typeof response.data === "string") {
153
+ data2 = JSON.parse(response.data);
154
+ } else if (typeof response === "object") {
155
+ data2 = response;
156
+ } else {
157
+ return [];
158
+ }
159
+ const bindings = (_b = (_a = data2 == null ? void 0 : data2.results) == null ? void 0 : _a.bindings) != null ? _b : [];
160
+ const triples = [];
161
+ for (const binding of bindings) {
162
+ if (!binding.subject || !binding.predicate || !binding.object || typeof binding.subject.value !== "string" || typeof binding.predicate.value !== "string" || typeof binding.object.value !== "string") continue;
163
+ triples.push({
164
+ subject: binding.subject.value,
165
+ predicate: binding.predicate.value,
166
+ object: {
167
+ value: binding.object.value,
168
+ type: binding.object.type || "uri",
169
+ datatype: binding.object.datatype,
170
+ lang: binding.object["xml:lang"]
171
+ }
172
+ });
173
+ }
174
+ return triples;
175
+ } catch (e) {
176
+ return [];
177
+ }
178
+ }
145
179
  function parseConstructResults(yasrResults) {
146
180
  const triples = [];
147
181
  if (!yasrResults || !yasrResults.getBindings) {
@@ -30014,10 +30048,16 @@ function saveSettings(settings) {
30014
30048
  }
30015
30049
 
30016
30050
  // src/GraphPlugin.ts
30051
+ var LOADING_BORDER_WIDTH = 4;
30052
+ var LOADING_BORDER_COLOR = "#ffa500";
30053
+ var EXPANDED_BORDER_WIDTH = 3;
30054
+ var DEFAULT_BORDER_WIDTH = 2;
30017
30055
  var GraphPlugin = class {
30018
30056
  constructor(yasr) {
30019
30057
  this.settingsPanelOpen = false;
30020
30058
  this.clickOutsideHandler = null;
30059
+ this.expansionAbortController = null;
30060
+ this.uriToNodeId = /* @__PURE__ */ new Map();
30021
30061
  this.yasr = yasr;
30022
30062
  this.network = null;
30023
30063
  this.currentTheme = getCurrentTheme();
@@ -30041,6 +30081,12 @@ var GraphPlugin = class {
30041
30081
  static get label() {
30042
30082
  return "Graph";
30043
30083
  }
30084
+ /**
30085
+ * Help/documentation URL
30086
+ */
30087
+ static get helpReference() {
30088
+ return "https://yasgui-doc.matdata.eu/docs/user-guide#graph-plugin";
30089
+ }
30044
30090
  /**
30045
30091
  * Check if plugin can handle the current results
30046
30092
  * @returns True if results are from CONSTRUCT or DESCRIBE query
@@ -30062,6 +30108,12 @@ var GraphPlugin = class {
30062
30108
  */
30063
30109
  draw() {
30064
30110
  const wasPanelOpen = this.settingsPanelOpen;
30111
+ if (this.expansionAbortController) {
30112
+ this.expansionAbortController.abort();
30113
+ this.expansionAbortController = null;
30114
+ }
30115
+ this.expandedNodes = /* @__PURE__ */ new Set();
30116
+ this.uriToNodeId = /* @__PURE__ */ new Map();
30065
30117
  this.yasr.resultsEl.innerHTML = "";
30066
30118
  try {
30067
30119
  this.triples = parseConstructResults(this.yasr.results);
@@ -30118,6 +30170,13 @@ var GraphPlugin = class {
30118
30170
  this.nodesDataSet.update(updates);
30119
30171
  }
30120
30172
  });
30173
+ this.setupNodeExpansion();
30174
+ this.uriToNodeId = /* @__PURE__ */ new Map();
30175
+ this.nodesDataSet.get().forEach((node) => {
30176
+ if (node.uri) {
30177
+ this.uriToNodeId.set(node.uri, node.id);
30178
+ }
30179
+ });
30121
30180
  if (!this.themeObserver) {
30122
30181
  this.themeObserver = watchThemeChanges((newTheme) => {
30123
30182
  this.applyTheme(newTheme);
@@ -30500,7 +30559,119 @@ var GraphPlugin = class {
30500
30559
  return panel;
30501
30560
  }
30502
30561
  /**
30503
- * Get icon for plugin tab
30562
+ * Setup double-click handler for node expansion
30563
+ */
30564
+ setupNodeExpansion() {
30565
+ if (!this.network) return;
30566
+ this.network.on("doubleClick", (params) => {
30567
+ if (params.nodes.length === 0) return;
30568
+ const nodeId = params.nodes[0];
30569
+ const node = this.nodesDataSet.get(nodeId);
30570
+ if (node && node.uri && !node.uri.startsWith("_:")) {
30571
+ this.expandNode(node.uri);
30572
+ }
30573
+ });
30574
+ }
30575
+ /**
30576
+ * Expand a node by executing a DESCRIBE query for the given URI and
30577
+ * merging the returned triples into the existing graph.
30578
+ * @param uri - URI of the node to expand
30579
+ */
30580
+ async expandNode(uri) {
30581
+ if (!this.yasr.executeQuery) {
30582
+ console.warn("yasgui-graph-plugin: background query execution not available (yasr.executeQuery is not configured)");
30583
+ return;
30584
+ }
30585
+ if (!this.triples || !this.prefixMap) return;
30586
+ if (this.expansionAbortController) {
30587
+ this.expansionAbortController.abort();
30588
+ }
30589
+ const controller = new AbortController();
30590
+ this.expansionAbortController = controller;
30591
+ const nodeId = this.uriToNodeId.get(uri);
30592
+ let originalColor = void 0;
30593
+ let originalBorderWidth = void 0;
30594
+ if (nodeId !== void 0) {
30595
+ const node = this.nodesDataSet.get(nodeId);
30596
+ if (node) {
30597
+ originalColor = node.color;
30598
+ originalBorderWidth = node.borderWidth;
30599
+ }
30600
+ this.nodesDataSet.update({
30601
+ id: nodeId,
30602
+ borderWidth: LOADING_BORDER_WIDTH,
30603
+ color: typeof originalColor === "object" && originalColor !== null ? { ...originalColor, border: LOADING_BORDER_COLOR } : { border: LOADING_BORDER_COLOR, background: originalColor != null ? originalColor : void 0 }
30604
+ });
30605
+ }
30606
+ const restoreNode = (borderWidth) => {
30607
+ if (nodeId !== void 0) {
30608
+ this.nodesDataSet.update({ id: nodeId, borderWidth, color: originalColor });
30609
+ }
30610
+ };
30611
+ try {
30612
+ const response = await this.yasr.executeQuery(`DESCRIBE <${uri}>`, {
30613
+ acceptHeader: "application/sparql-results+json",
30614
+ signal: controller.signal
30615
+ });
30616
+ if (controller.signal.aborted) {
30617
+ restoreNode(originalBorderWidth != null ? originalBorderWidth : DEFAULT_BORDER_WIDTH);
30618
+ return;
30619
+ }
30620
+ const newTriples = await parseBackgroundQueryResponse(response);
30621
+ if (controller.signal.aborted) {
30622
+ restoreNode(originalBorderWidth != null ? originalBorderWidth : DEFAULT_BORDER_WIDTH);
30623
+ return;
30624
+ }
30625
+ const existingKeys = new Set(
30626
+ this.triples.map((t) => `${t.subject}|${t.predicate}|${t.object.value}`)
30627
+ );
30628
+ const uniqueNew = newTriples.filter(
30629
+ (t) => !existingKeys.has(`${t.subject}|${t.predicate}|${t.object.value}`)
30630
+ );
30631
+ if (uniqueNew.length > 0) {
30632
+ this.triples = [...this.triples, ...uniqueNew];
30633
+ this.mergeNewTriples();
30634
+ }
30635
+ restoreNode(EXPANDED_BORDER_WIDTH);
30636
+ } catch (error) {
30637
+ if ((error == null ? void 0 : error.name) === "AbortError") {
30638
+ restoreNode(originalBorderWidth != null ? originalBorderWidth : DEFAULT_BORDER_WIDTH);
30639
+ return;
30640
+ }
30641
+ console.error("yasgui-graph-plugin: error expanding node", uri, error);
30642
+ restoreNode(originalBorderWidth != null ? originalBorderWidth : DEFAULT_BORDER_WIDTH);
30643
+ }
30644
+ }
30645
+ /**
30646
+ * Incrementally add new triples to the vis-network DataSets without a full redraw.
30647
+ * New nodes and edges are added; existing ones are left untouched.
30648
+ * Expects `this.triples` to already include the new triples.
30649
+ */
30650
+ mergeNewTriples() {
30651
+ if (!this.triples || !this.prefixMap || !this.nodesDataSet || !this.edgesDataSet) return;
30652
+ const themeColors = getThemeNodeColors(this.currentTheme);
30653
+ const { nodes, edges } = triplesToGraph(this.triples, this.prefixMap, themeColors, this.settings);
30654
+ const existingValues = new Set(this.nodesDataSet.get().map((n) => n.fullValue));
30655
+ const nodesToAdd = nodes.filter((n) => !existingValues.has(n.fullValue));
30656
+ const existingEdgeKeys = new Set(
30657
+ this.edgesDataSet.get().map((e) => `${e.from}|${e.predicate}|${e.to}`)
30658
+ );
30659
+ const edgesToAdd = edges.filter(
30660
+ (e) => !existingEdgeKeys.has(`${e.from}|${e.predicate}|${e.to}`)
30661
+ );
30662
+ if (nodesToAdd.length > 0) {
30663
+ this.nodesDataSet.add(nodesToAdd);
30664
+ nodesToAdd.forEach((n) => {
30665
+ if (n.uri != null) {
30666
+ this.uriToNodeId.set(n.uri, n.id);
30667
+ }
30668
+ });
30669
+ }
30670
+ if (edgesToAdd.length > 0) {
30671
+ this.edgesDataSet.add(edgesToAdd);
30672
+ }
30673
+ }
30674
+ /**
30504
30675
  * @returns Icon element
30505
30676
  */
30506
30677
  getIcon() {
@@ -30522,6 +30693,10 @@ var GraphPlugin = class {
30522
30693
  */
30523
30694
  destroy() {
30524
30695
  this.removeClickOutsideHandler();
30696
+ if (this.expansionAbortController) {
30697
+ this.expansionAbortController.abort();
30698
+ this.expansionAbortController = null;
30699
+ }
30525
30700
  if (this.themeObserver) {
30526
30701
  this.themeObserver.disconnect();
30527
30702
  this.themeObserver = null;