@oml/markdown 0.7.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.
Files changed (112) hide show
  1. package/README.md +39 -0
  2. package/out/index.d.ts +2 -0
  3. package/out/index.js +4 -0
  4. package/out/index.js.map +1 -0
  5. package/out/md/index.d.ts +6 -0
  6. package/out/md/index.js +8 -0
  7. package/out/md/index.js.map +1 -0
  8. package/out/md/md-execution.d.ts +33 -0
  9. package/out/md/md-execution.js +3 -0
  10. package/out/md/md-execution.js.map +1 -0
  11. package/out/md/md-executor.d.ts +21 -0
  12. package/out/md/md-executor.js +498 -0
  13. package/out/md/md-executor.js.map +1 -0
  14. package/out/md/md-frontmatter.d.ts +4 -0
  15. package/out/md/md-frontmatter.js +48 -0
  16. package/out/md/md-frontmatter.js.map +1 -0
  17. package/out/md/md-registry.d.ts +7 -0
  18. package/out/md/md-registry.js +19 -0
  19. package/out/md/md-registry.js.map +1 -0
  20. package/out/md/md-runtime.d.ts +10 -0
  21. package/out/md/md-runtime.js +166 -0
  22. package/out/md/md-runtime.js.map +1 -0
  23. package/out/md/md-types.d.ts +40 -0
  24. package/out/md/md-types.js +3 -0
  25. package/out/md/md-types.js.map +1 -0
  26. package/out/md/md-yaml.d.ts +1 -0
  27. package/out/md/md-yaml.js +15 -0
  28. package/out/md/md-yaml.js.map +1 -0
  29. package/out/renderers/chart-renderer.d.ts +6 -0
  30. package/out/renderers/chart-renderer.js +392 -0
  31. package/out/renderers/chart-renderer.js.map +1 -0
  32. package/out/renderers/diagram-renderer.d.ts +7 -0
  33. package/out/renderers/diagram-renderer.js +2354 -0
  34. package/out/renderers/diagram-renderer.js.map +1 -0
  35. package/out/renderers/graph-renderer.d.ts +6 -0
  36. package/out/renderers/graph-renderer.js +1384 -0
  37. package/out/renderers/graph-renderer.js.map +1 -0
  38. package/out/renderers/index.d.ts +14 -0
  39. package/out/renderers/index.js +16 -0
  40. package/out/renderers/index.js.map +1 -0
  41. package/out/renderers/list-renderer.d.ts +6 -0
  42. package/out/renderers/list-renderer.js +252 -0
  43. package/out/renderers/list-renderer.js.map +1 -0
  44. package/out/renderers/matrix-renderer.d.ts +14 -0
  45. package/out/renderers/matrix-renderer.js +498 -0
  46. package/out/renderers/matrix-renderer.js.map +1 -0
  47. package/out/renderers/message-renderer.d.ts +6 -0
  48. package/out/renderers/message-renderer.js +14 -0
  49. package/out/renderers/message-renderer.js.map +1 -0
  50. package/out/renderers/registry.d.ts +9 -0
  51. package/out/renderers/registry.js +41 -0
  52. package/out/renderers/registry.js.map +1 -0
  53. package/out/renderers/renderer.d.ts +28 -0
  54. package/out/renderers/renderer.js +61 -0
  55. package/out/renderers/renderer.js.map +1 -0
  56. package/out/renderers/table-editor-renderer.d.ts +4 -0
  57. package/out/renderers/table-editor-renderer.js +9 -0
  58. package/out/renderers/table-editor-renderer.js.map +1 -0
  59. package/out/renderers/table-renderer.d.ts +95 -0
  60. package/out/renderers/table-renderer.js +1571 -0
  61. package/out/renderers/table-renderer.js.map +1 -0
  62. package/out/renderers/text-renderer.d.ts +7 -0
  63. package/out/renderers/text-renderer.js +219 -0
  64. package/out/renderers/text-renderer.js.map +1 -0
  65. package/out/renderers/tree-renderer.d.ts +4 -0
  66. package/out/renderers/tree-renderer.js +9 -0
  67. package/out/renderers/tree-renderer.js.map +1 -0
  68. package/out/renderers/types.d.ts +18 -0
  69. package/out/renderers/types.js +3 -0
  70. package/out/renderers/types.js.map +1 -0
  71. package/out/renderers/wikilink-utils.d.ts +6 -0
  72. package/out/renderers/wikilink-utils.js +100 -0
  73. package/out/renderers/wikilink-utils.js.map +1 -0
  74. package/out/static/browser-runtime.bundle.js +74155 -0
  75. package/out/static/browser-runtime.bundle.js.map +7 -0
  76. package/out/static/browser-runtime.d.ts +1 -0
  77. package/out/static/browser-runtime.js +218 -0
  78. package/out/static/browser-runtime.js.map +1 -0
  79. package/out/static/index.d.ts +1 -0
  80. package/out/static/index.js +3 -0
  81. package/out/static/index.js.map +1 -0
  82. package/out/static/runtime-assets.d.ts +2 -0
  83. package/out/static/runtime-assets.js +174 -0
  84. package/out/static/runtime-assets.js.map +1 -0
  85. package/package.json +74 -0
  86. package/src/index.ts +4 -0
  87. package/src/md/index.ts +8 -0
  88. package/src/md/md-execution.ts +51 -0
  89. package/src/md/md-executor.ts +598 -0
  90. package/src/md/md-frontmatter.ts +53 -0
  91. package/src/md/md-registry.ts +22 -0
  92. package/src/md/md-runtime.ts +191 -0
  93. package/src/md/md-types.ts +48 -0
  94. package/src/md/md-yaml.ts +17 -0
  95. package/src/renderers/chart-renderer.ts +473 -0
  96. package/src/renderers/diagram-renderer.ts +2520 -0
  97. package/src/renderers/graph-renderer.ts +1653 -0
  98. package/src/renderers/index.ts +16 -0
  99. package/src/renderers/list-renderer.ts +289 -0
  100. package/src/renderers/matrix-renderer.ts +616 -0
  101. package/src/renderers/message-renderer.ts +18 -0
  102. package/src/renderers/registry.ts +45 -0
  103. package/src/renderers/renderer.ts +84 -0
  104. package/src/renderers/table-editor-renderer.ts +8 -0
  105. package/src/renderers/table-renderer.ts +1868 -0
  106. package/src/renderers/text-renderer.ts +252 -0
  107. package/src/renderers/tree-renderer.ts +7 -0
  108. package/src/renderers/types.ts +22 -0
  109. package/src/renderers/wikilink-utils.ts +108 -0
  110. package/src/static/browser-runtime.ts +249 -0
  111. package/src/static/index.ts +3 -0
  112. package/src/static/runtime-assets.ts +175 -0
@@ -0,0 +1,1653 @@
1
+ // Copyright (c) 2026 Modelware. All rights reserved.
2
+
3
+ import { CanvasMarkdownBlockRenderer } from './renderer.js';
4
+ import type { MdBlockExecutionResult } from './types.js';
5
+
6
+ // --- CSS variable constants for base styles (auto-update on VS Code theme) ---
7
+ const CSS_NODE_FILL = 'var(--vscode-button-secondaryBackground, var(--vscode-button-background, #5a5a8a))';
8
+ const CSS_NODE_STROKE = 'var(--vscode-button-border, var(--vscode-editorWidget-border, #6b7280))';
9
+ const CSS_NODE_TEXT = 'var(--vscode-editor-foreground, var(--oml-static-foreground, #24292f))';
10
+ const CSS_LIT_FILL = 'var(--vscode-badge-background, #5a5a8a)';
11
+ const CSS_LIT_STROKE = 'var(--vscode-editorWidget-border, #6b7280)';
12
+ const CSS_EDGE_STROKE = 'var(--vscode-editorWidget-border, #6b7280)';
13
+ const CSS_EDGE_TEXT = CSS_EDGE_STROKE;
14
+ const CSS_EDGE_BG = 'var(--vscode-editor-background, var(--oml-static-background, #ffffff))';
15
+ const CSS_FONT_FAMILY = 'var(--vscode-editor-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif)';
16
+
17
+ /** Extra height beneath the circle/rect shape to accommodate the below-node label. */
18
+ const NODE_LABEL_HEIGHT = 22;
19
+
20
+ // --- Module-level library caches ---
21
+ let x6GraphCtor: (new (options: unknown) => any) | undefined;
22
+ let dagreLib: any;
23
+
24
+ // --- Types ---
25
+
26
+ type GraphThemePalette = {
27
+ nodeFill: string;
28
+ nodeStroke: string;
29
+ nodeText: string;
30
+ literalFill: string;
31
+ literalStroke: string;
32
+ literalText: string;
33
+ edgeStroke: string;
34
+ edgeText: string;
35
+ edgeLabelBg: string;
36
+ edgeLabelText: string;
37
+ selectedStroke: string;
38
+ fontFamily: string;
39
+ foreground: string;
40
+ background: string;
41
+ muted: string;
42
+ link: string;
43
+ accent: string;
44
+ accentForeground: string;
45
+ border: string;
46
+ };
47
+
48
+ type NodeData = {
49
+ id: string;
50
+ label: string;
51
+ value: string;
52
+ degree: number;
53
+ literal: boolean;
54
+ group: string;
55
+ };
56
+
57
+ type EdgeData = {
58
+ id: string;
59
+ source: string;
60
+ target: string;
61
+ value: string;
62
+ label: string;
63
+ };
64
+
65
+ type ForceLayoutOptions = {
66
+ repulsion: number;
67
+ linkDistance: number;
68
+ springStrength: number;
69
+ gravity: number;
70
+ damping: number;
71
+ maxSpeed: number;
72
+ intraGroupAttraction: number;
73
+ interGroupRepulsionFactor: number;
74
+ axisPreference: 'horizontal' | 'vertical' | 'none';
75
+ axisStrength: number;
76
+ };
77
+
78
+ type ResolvedLayout = {
79
+ mode: 'force' | 'radial' | 'dag' | 'grid';
80
+ running: boolean;
81
+ fit: boolean;
82
+ padding: number;
83
+ force: ForceLayoutOptions;
84
+ radial: {
85
+ startAngle: number;
86
+ clockwise: boolean;
87
+ sortBy: 'degree' | 'label';
88
+ };
89
+ dag: {
90
+ rankDir: 'TB' | 'LR';
91
+ nodeSep: number;
92
+ rankSep: number;
93
+ acyclic: boolean;
94
+ };
95
+ grid: {
96
+ rows?: number;
97
+ cols?: number;
98
+ sortBy: 'degree' | 'label';
99
+ condense: boolean;
100
+ };
101
+ };
102
+
103
+ type GraphStyleRule = {
104
+ kind: 'node' | 'edge';
105
+ condition?: string;
106
+ style: Record<string, string>;
107
+ group?: string;
108
+ };
109
+
110
+ type SelectorNodeContext = {
111
+ id: string;
112
+ label: string;
113
+ value: string;
114
+ type: string[];
115
+ degree: number;
116
+ literal: boolean;
117
+ group: string;
118
+ };
119
+
120
+ type SelectorEdgeContext = {
121
+ value: string;
122
+ source: SelectorNodeContext;
123
+ target: SelectorNodeContext;
124
+ };
125
+
126
+ type NodeContext = {
127
+ value: string;
128
+ incoming: SelectorEdgeContext[];
129
+ outgoing: SelectorEdgeContext[];
130
+ neighbors: SelectorNodeContext[];
131
+ };
132
+
133
+ type EdgeContext = {
134
+ value: string;
135
+ source: SelectorNodeContext;
136
+ target: SelectorNodeContext;
137
+ };
138
+
139
+ type CsvDataset = {
140
+ columns: string[];
141
+ rows: string[][];
142
+ downloadCsv: (content: string) => void;
143
+ };
144
+
145
+ const RDF_TYPE_IRI = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type';
146
+
147
+ // --- Renderer ---
148
+
149
+ export class GraphMarkdownBlockRenderer extends CanvasMarkdownBlockRenderer {
150
+ protected readonly canvasKinds = ['graph'] as const;
151
+
152
+ render(result: MdBlockExecutionResult): HTMLElement {
153
+ const container = this.createResultContainer(result.status);
154
+ const payload = result.payload;
155
+ if (!payload) {
156
+ container.appendChild(this.createMessageContainer('Graph renderer requires tabular payload.'));
157
+ return container;
158
+ }
159
+
160
+ const subjectIndex = payload.columns.indexOf('subject');
161
+ const predicateIndex = payload.columns.indexOf('predicate');
162
+ const objectIndex = payload.columns.indexOf('object');
163
+ if (subjectIndex < 0 || predicateIndex < 0 || objectIndex < 0) {
164
+ container.appendChild(this.createMessageContainer(
165
+ "Graph renderer requires columns named 'subject', 'predicate', and 'object'."
166
+ ));
167
+ return container;
168
+ }
169
+
170
+ const graphRoot = document.createElement('div');
171
+ graphRoot.className = 'graph-canvas-root';
172
+ graphRoot.style.height = resolveCanvasHeight(result.options);
173
+ graphRoot.style.minHeight = resolveCanvasMinHeight(result.options);
174
+ container.appendChild(graphRoot);
175
+
176
+ const nodeMap = new Map<string, NodeData>();
177
+ const edgeList: EdgeData[] = [];
178
+ const seenEdges = new Set<string>();
179
+
180
+ for (const row of payload.rows) {
181
+ const subject = row[subjectIndex] ?? '';
182
+ const predicate = row[predicateIndex] ?? '';
183
+ const object = row[objectIndex] ?? '';
184
+ if (!subject || !predicate || !object) {
185
+ continue;
186
+ }
187
+
188
+ ensureNode(nodeMap, subject);
189
+ ensureNode(nodeMap, object);
190
+
191
+ const source = toNodeId(subject);
192
+ const target = toNodeId(object);
193
+ const edgeKey = `${source}|${toNodeId(predicate)}|${target}`;
194
+ if (seenEdges.has(edgeKey)) {
195
+ continue;
196
+ }
197
+ seenEdges.add(edgeKey);
198
+
199
+ nodeMap.get(source)!.degree += 1;
200
+ nodeMap.get(target)!.degree += 1;
201
+
202
+ edgeList.push({
203
+ id: `${edgeKey}|${edgeList.length}`,
204
+ source,
205
+ target,
206
+ value: predicate,
207
+ label: shortLabel(predicate),
208
+ });
209
+ }
210
+
211
+ if (nodeMap.size === 0) {
212
+ container.appendChild(this.createMessageContainer('No graph data returned.'));
213
+ return container;
214
+ }
215
+
216
+ const degrees = [...nodeMap.values()].map((n) => n.degree);
217
+ const minDegree = Math.min(...degrees);
218
+ const maxDegree = Math.max(...degrees);
219
+ const layout = resolveLayoutOptions(result.options);
220
+ const stylesheet = compileGraphStylesheet(result.options);
221
+ const nodeDataList = [...nodeMap.values()];
222
+ const csvDataset: CsvDataset = {
223
+ columns: payload.columns.slice(),
224
+ rows: payload.rows.map((row) => row.slice()),
225
+ downloadCsv: (content: string) => this.requestTextFileDownload(content, 'graph', 'csv'),
226
+ };
227
+
228
+ initializeGraphWhenReady(
229
+ graphRoot,
230
+ nodeDataList,
231
+ edgeList,
232
+ minDegree,
233
+ maxDegree,
234
+ container,
235
+ layout,
236
+ stylesheet,
237
+ csvDataset,
238
+ );
239
+
240
+ return container;
241
+ }
242
+ }
243
+
244
+ // --- Graph initialization ---
245
+
246
+ function initializeGraphWhenReady(
247
+ graphRoot: HTMLElement,
248
+ nodeDataList: NodeData[],
249
+ edgeDataList: EdgeData[],
250
+ minDegree: number,
251
+ maxDegree: number,
252
+ messageContainer: HTMLElement,
253
+ layoutOptions: ResolvedLayout,
254
+ stylesheet: ReadonlyArray<GraphStyleRule>,
255
+ csvDataset: CsvDataset,
256
+ ): void {
257
+ const maxAttempts = 20;
258
+ let attempts = 0;
259
+
260
+ const tryInit = (): void => {
261
+ attempts += 1;
262
+ if (!graphRoot.isConnected || graphRoot.clientWidth === 0 || graphRoot.clientHeight === 0) {
263
+ if (attempts < maxAttempts) {
264
+ requestAnimationFrame(tryInit);
265
+ }
266
+ return;
267
+ }
268
+ void doGraphInit(
269
+ graphRoot,
270
+ nodeDataList,
271
+ edgeDataList,
272
+ minDegree,
273
+ maxDegree,
274
+ layoutOptions,
275
+ stylesheet,
276
+ csvDataset,
277
+ ).catch((error) => {
278
+ const detail = error instanceof Error ? error.message : String(error);
279
+ const message = document.createElement('div');
280
+ message.className = 'oml-md-result-message';
281
+ message.textContent = `Graph rendering failed: ${detail}`;
282
+ messageContainer.appendChild(message);
283
+ });
284
+ };
285
+
286
+ requestAnimationFrame(tryInit);
287
+ }
288
+
289
+ async function doGraphInit(
290
+ graphRoot: HTMLElement,
291
+ nodeDataList: NodeData[],
292
+ edgeDataList: EdgeData[],
293
+ minDegree: number,
294
+ maxDegree: number,
295
+ layoutOptions: ResolvedLayout,
296
+ stylesheet: ReadonlyArray<GraphStyleRule>,
297
+ csvDataset: CsvDataset,
298
+ ): Promise<void> {
299
+ const GraphCtor = await loadX6GraphCtor();
300
+ const graphView: any = new GraphCtor({
301
+ container: graphRoot,
302
+ autoResize: false,
303
+ grid: false,
304
+ panning: { enabled: true },
305
+ mousewheel: {
306
+ enabled: true,
307
+ minScale: 0.3,
308
+ maxScale: 3,
309
+ factor: 1.1,
310
+ },
311
+ connecting: {
312
+ allowBlank: false,
313
+ allowNode: false,
314
+ allowPort: false,
315
+ allowEdge: false,
316
+ allowLoop: false,
317
+ },
318
+ interacting: {
319
+ nodeMovable: true,
320
+ edgeMovable: false,
321
+ vertexMovable: false,
322
+ arrowheadMovable: false,
323
+ labelMovable: false,
324
+ },
325
+ background: { color: 'var(--vscode-editor-background)' },
326
+ });
327
+
328
+ // Track which nodes are currently being dragged (force engine skips them).
329
+ const grabbedNodes = new Set<string>();
330
+ graphView.on('node:mousedown', ({ node }: { node: any }) => {
331
+ grabbedNodes.add(String(node.id));
332
+ });
333
+ const onDocMouseUp = (): void => {
334
+ grabbedNodes.clear();
335
+ };
336
+ document.addEventListener('mouseup', onDocMouseUp, { passive: true });
337
+ graphRoot.addEventListener('DOMNodeRemovedFromDocument', () => {
338
+ document.removeEventListener('mouseup', onDocMouseUp);
339
+ }, { once: true });
340
+
341
+ // Add all nodes and edges.
342
+ for (const data of nodeDataList) {
343
+ const size = computeNodeSize(data.degree, minDegree, maxDegree);
344
+ graphView.addNode(buildNodeDef(data, size));
345
+ }
346
+ for (const data of edgeDataList) {
347
+ graphView.addEdge(buildEdgeDef(data));
348
+ }
349
+
350
+ // Resize the X6 canvas whenever the host element resizes.
351
+ const resizeObserver = new ResizeObserver(() => {
352
+ if (!graphRoot.isConnected) {
353
+ return;
354
+ }
355
+ graphView.resize(graphRoot.clientWidth, graphRoot.clientHeight);
356
+ });
357
+ resizeObserver.observe(graphRoot);
358
+ // Also watch the result container so that when the page grows wider the
359
+ // graphRoot width (and therefore the X6 SVG) is updated to match.
360
+ const resultContainer = graphRoot.closest('.oml-md-result');
361
+ let resultResizeObserver: ResizeObserver | undefined;
362
+ if (resultContainer instanceof HTMLElement) {
363
+ resultResizeObserver = new ResizeObserver(() => {
364
+ const nextWidth = Math.max(0, Math.floor(resultContainer.clientWidth));
365
+ graphRoot.style.width = `${nextWidth}px`;
366
+ graphView.resize(graphRoot.clientWidth, graphRoot.clientHeight);
367
+ });
368
+ resultResizeObserver.observe(resultContainer);
369
+ }
370
+ graphRoot.addEventListener('DOMNodeRemovedFromDocument', () => {
371
+ resizeObserver.disconnect();
372
+ resultResizeObserver?.disconnect();
373
+ }, { once: true });
374
+
375
+ // Apply user stylesheet rules with resolved theme.* tokens.
376
+ // Base colors use CSS variables and auto-update; only theme.* token resolutions
377
+ // need explicit re-application when the VS Code theme changes.
378
+ const applyCurrentTheme = (): void => {
379
+ if (!graphRoot.isConnected) {
380
+ return;
381
+ }
382
+ const theme = resolveThemePalette();
383
+ // Reset to CSS-variable base attrs so stale user-rule colors are cleared.
384
+ for (const node of graphView.getNodes() as any[]) {
385
+ const nodeData: NodeData = node.getData() as NodeData;
386
+ node.setAttrs(buildNodeBaseAttrs(nodeData.literal));
387
+ }
388
+ for (const edge of graphView.getEdges() as any[]) {
389
+ edge.setAttrs(buildEdgeBaseAttrs());
390
+ resetEdgeLabelColors(edge);
391
+ }
392
+ applyGraphStylesheet(graphView, stylesheet, theme);
393
+ };
394
+
395
+ applyCurrentTheme();
396
+
397
+ const themeObserver = new MutationObserver(() => {
398
+ applyCurrentTheme();
399
+ });
400
+ themeObserver.observe(document.body, {
401
+ attributes: true,
402
+ attributeFilter: ['class', 'style', 'data-vscode-theme-kind'],
403
+ });
404
+ themeObserver.observe(document.documentElement, {
405
+ attributes: true,
406
+ attributeFilter: ['class', 'style', 'data-vscode-theme-kind'],
407
+ });
408
+ const colorScheme = typeof window.matchMedia === 'function'
409
+ ? window.matchMedia('(prefers-color-scheme: dark)')
410
+ : undefined;
411
+ const onColorSchemeChange = (): void => applyCurrentTheme();
412
+ colorScheme?.addEventListener('change', onColorSchemeChange);
413
+ graphRoot.addEventListener('DOMNodeRemovedFromDocument', () => {
414
+ themeObserver.disconnect();
415
+ colorScheme?.removeEventListener('change', onColorSchemeChange);
416
+ }, { once: true });
417
+
418
+ // Position all nodes using the chosen layout algorithm.
419
+ await runLayout(graphView, layoutOptions);
420
+ if (layoutOptions.mode === 'force' && layoutOptions.running) {
421
+ startContinuousForceEngine(graphView, graphRoot, layoutOptions.force, grabbedNodes);
422
+ }
423
+
424
+ installGraphToolbar(graphRoot, graphView, csvDataset);
425
+
426
+ // Manual canvas resize handle.
427
+ const parsedMinHeight = parseInt(graphRoot.style.minHeight, 10);
428
+ const canvasMinHeight = Number.isFinite(parsedMinHeight) ? Math.max(120, parsedMinHeight) : 220;
429
+ const resizeHandle = document.createElement('div');
430
+ resizeHandle.className = 'canvas-resize-handle';
431
+ graphRoot.appendChild(resizeHandle);
432
+ let canvasResize: { pointerId: number; startY: number; startHeight: number } | undefined;
433
+ const onResizePointerDown = (event: PointerEvent): void => {
434
+ event.preventDefault();
435
+ event.stopPropagation();
436
+ if (typeof event.stopImmediatePropagation === 'function') event.stopImmediatePropagation();
437
+ canvasResize = { pointerId: event.pointerId, startY: event.clientY, startHeight: graphRoot.clientHeight };
438
+ resizeHandle.setPointerCapture(event.pointerId);
439
+ };
440
+ const onResizePointerMove = (event: PointerEvent): void => {
441
+ if (!canvasResize || event.pointerId !== canvasResize.pointerId) return;
442
+ const delta = event.clientY - canvasResize.startY;
443
+ const newHeight = Math.max(canvasMinHeight, canvasResize.startHeight + delta);
444
+ graphRoot.style.height = `${Math.ceil(newHeight)}px`;
445
+ };
446
+ const onResizePointerEnd = (event: PointerEvent): void => {
447
+ if (!canvasResize || event.pointerId !== canvasResize.pointerId) return;
448
+ canvasResize = undefined;
449
+ };
450
+ resizeHandle.addEventListener('pointerdown', onResizePointerDown);
451
+ resizeHandle.addEventListener('pointermove', onResizePointerMove);
452
+ resizeHandle.addEventListener('pointerup', onResizePointerEnd);
453
+ resizeHandle.addEventListener('pointercancel', onResizePointerEnd);
454
+ }
455
+
456
+ // --- X6 / dagre library loaders ---
457
+
458
+ async function loadX6GraphCtor(): Promise<new (options: unknown) => any> {
459
+ if (x6GraphCtor) {
460
+ return x6GraphCtor;
461
+ }
462
+ const mod = await import('@antv/x6');
463
+ const ctor = (mod as any).Graph;
464
+ if (typeof ctor !== 'function') {
465
+ throw new Error('X6 Graph constructor is unavailable in @antv/x6');
466
+ }
467
+ x6GraphCtor = ctor as new (options: unknown) => any;
468
+ return x6GraphCtor;
469
+ }
470
+
471
+ async function loadDagreLib(): Promise<any> {
472
+ if (dagreLib) {
473
+ return dagreLib;
474
+ }
475
+ const mod = await import('@dagrejs/dagre');
476
+ dagreLib = (mod as any).default ?? mod;
477
+ return dagreLib;
478
+ }
479
+
480
+ // --- Node / edge definition builders ---
481
+
482
+ function computeNodeSize(degree: number, minDegree: number, maxDegree: number): number {
483
+ if (minDegree === maxDegree) {
484
+ return 36;
485
+ }
486
+ // Mirrors Cytoscape mapData(degree, 0, 10, 18, 52).
487
+ const t = Math.max(0, Math.min(1, degree / 10));
488
+ return Math.round(18 + t * (52 - 18));
489
+ }
490
+
491
+ /** Base attrs for a node using CSS variables so color auto-updates with VS Code theme. */
492
+ function buildNodeBaseAttrs(literal: boolean): Record<string, unknown> {
493
+ return {
494
+ body: {
495
+ fill: literal ? CSS_LIT_FILL : CSS_NODE_FILL,
496
+ stroke: literal ? CSS_LIT_STROKE : CSS_NODE_STROKE,
497
+ strokeWidth: 1,
498
+ },
499
+ label: {
500
+ fill: CSS_NODE_TEXT,
501
+ fontSize: 11,
502
+ fontFamily: CSS_FONT_FAMILY,
503
+ },
504
+ };
505
+ }
506
+
507
+ /** Base attrs for an edge using CSS variables. */
508
+ function buildEdgeBaseAttrs(): Record<string, unknown> {
509
+ return {
510
+ line: {
511
+ stroke: CSS_EDGE_STROKE,
512
+ strokeWidth: 1.4,
513
+ targetMarker: {
514
+ name: 'classic',
515
+ size: 6,
516
+ fill: CSS_EDGE_STROKE,
517
+ stroke: CSS_EDGE_STROKE,
518
+ },
519
+ },
520
+ };
521
+ }
522
+
523
+ /** Resets edge label colors to CSS variable defaults (called on theme change). */
524
+ function resetEdgeLabelColors(edge: any): void {
525
+ try {
526
+ edge.prop('labels/0/attrs/label/fill', CSS_EDGE_TEXT);
527
+ edge.prop('labels/0/attrs/body/fill', CSS_EDGE_BG);
528
+ } catch {
529
+ // ignore - edge may have no labels
530
+ }
531
+ }
532
+
533
+ function buildNodeDef(data: NodeData, size: number): Record<string, unknown> {
534
+ const isLiteral = data.literal;
535
+ const tagName = isLiteral ? 'rect' : 'circle';
536
+ const bodyAttrs: Record<string, unknown> = isLiteral
537
+ ? { width: size, height: size, rx: 4, ry: 4,
538
+ fill: CSS_LIT_FILL, stroke: CSS_LIT_STROKE, strokeWidth: 1 }
539
+ : { r: size / 2, cx: size / 2, cy: size / 2,
540
+ fill: CSS_NODE_FILL, stroke: CSS_NODE_STROKE, strokeWidth: 1 };
541
+
542
+ return {
543
+ id: data.id,
544
+ markup: [
545
+ { tagName, selector: 'body' },
546
+ { tagName: 'text', selector: 'label' },
547
+ ],
548
+ x: 0,
549
+ y: 0,
550
+ width: size,
551
+ height: size + NODE_LABEL_HEIGHT, // extra room below shape for label
552
+ attrs: {
553
+ body: bodyAttrs,
554
+ label: {
555
+ text: data.label,
556
+ fill: CSS_NODE_TEXT,
557
+ fontSize: 11,
558
+ fontFamily: CSS_FONT_FAMILY,
559
+ textAnchor: 'middle',
560
+ textVerticalAnchor: 'top',
561
+ refX: '50%',
562
+ refY: size + 4, // below the visible shape
563
+ },
564
+ },
565
+ data: {
566
+ label: data.label,
567
+ value: data.value,
568
+ degree: data.degree,
569
+ literal: data.literal,
570
+ group: '',
571
+ },
572
+ zIndex: 1,
573
+ };
574
+ }
575
+
576
+ function buildEdgeDef(data: EdgeData): Record<string, unknown> {
577
+ return {
578
+ id: data.id,
579
+ source: { cell: data.source },
580
+ target: { cell: data.target },
581
+ router: { name: 'normal' },
582
+ connector: { name: 'rounded', args: { radius: 6 } },
583
+ attrs: {
584
+ line: {
585
+ stroke: CSS_EDGE_STROKE,
586
+ strokeWidth: 1.4,
587
+ targetMarker: {
588
+ name: 'classic',
589
+ size: 6,
590
+ fill: CSS_EDGE_STROKE,
591
+ stroke: CSS_EDGE_STROKE,
592
+ },
593
+ },
594
+ },
595
+ labels: [
596
+ {
597
+ position: 0.5,
598
+ markup: [
599
+ { tagName: 'rect', selector: 'body' },
600
+ { tagName: 'text', selector: 'label' },
601
+ ],
602
+ attrs: {
603
+ body: {
604
+ ref: 'label',
605
+ refX: -2,
606
+ refY: -2,
607
+ refWidth: '100%',
608
+ refHeight: '100%',
609
+ refWidth2: 4,
610
+ refHeight2: 4,
611
+ fill: CSS_EDGE_BG,
612
+ rx: 2,
613
+ ry: 2,
614
+ stroke: 'none',
615
+ },
616
+ label: {
617
+ text: data.label,
618
+ fill: CSS_EDGE_TEXT,
619
+ fontSize: 11,
620
+ fontFamily: CSS_FONT_FAMILY,
621
+ textAnchor: 'middle',
622
+ textVerticalAnchor: 'middle',
623
+ },
624
+ },
625
+ },
626
+ ],
627
+ data: {
628
+ label: data.label,
629
+ value: data.value,
630
+ },
631
+ zIndex: 0,
632
+ };
633
+ }
634
+
635
+ // --- Layout ---
636
+
637
+ async function runLayout(graphView: any, options: ResolvedLayout): Promise<void> {
638
+ const nodes = graphView.getNodes() as any[];
639
+ if (nodes.length === 0) {
640
+ return;
641
+ }
642
+
643
+ if (options.mode === 'force') {
644
+ // Place nodes in a circle; the continuous force engine will handle physics.
645
+ // Radius is sized so the initial circle fits comfortably at 1:1 scale —
646
+ // node spacing ~60 px (≈1.5× default node diameter) → r ≈ N × 10 px.
647
+ const r = Math.max(50, nodes.length * 10);
648
+ nodes.forEach((node: any, i: number) => {
649
+ const angle = (2 * Math.PI * i) / nodes.length;
650
+ node.setPosition(Math.round(r * Math.cos(angle)), Math.round(r * Math.sin(angle)));
651
+ });
652
+ if (options.fit) {
653
+ fitGraphContent(graphView, options.padding);
654
+ }
655
+ return;
656
+ }
657
+
658
+ if (options.mode === 'radial') {
659
+ const { startAngle, clockwise, sortBy } = options.radial;
660
+ const sorted = [...nodes].sort((a: any, b: any) => {
661
+ if (sortBy === 'label') {
662
+ return String((a.getData() as NodeData)?.label ?? '').localeCompare(String((b.getData() as NodeData)?.label ?? ''));
663
+ }
664
+ return Number((b.getData() as NodeData)?.degree ?? 0) - Number((a.getData() as NodeData)?.degree ?? 0);
665
+ });
666
+ const ringSpacing = 80;
667
+ const nodeSpacing = 55;
668
+ const rings: any[][] = [];
669
+ const remaining = [...sorted];
670
+ let ringIndex = 0;
671
+ while (remaining.length > 0) {
672
+ const capacity = ringIndex === 0
673
+ ? 1
674
+ : Math.max(6, Math.floor((2 * Math.PI * (ringIndex + 1) * ringSpacing) / nodeSpacing));
675
+ rings.push(remaining.splice(0, capacity));
676
+ ringIndex += 1;
677
+ }
678
+ for (let r = 0; r < rings.length; r += 1) {
679
+ const ring = rings[r];
680
+ const radius = (r + 1) * ringSpacing;
681
+ ring.forEach((node: any, i: number) => {
682
+ const angle = startAngle + (clockwise ? 1 : -1) * (2 * Math.PI * i) / ring.length;
683
+ node.setPosition(Math.round(radius * Math.cos(angle)), Math.round(radius * Math.sin(angle)));
684
+ });
685
+ }
686
+ if (options.fit) {
687
+ fitGraphContent(graphView, options.padding);
688
+ }
689
+ return;
690
+ }
691
+
692
+ if (options.mode === 'dag') {
693
+ const dagre = await loadDagreLib();
694
+ const g = new dagre.graphlib.Graph({ multigraph: true });
695
+ g.setGraph({
696
+ rankdir: options.dag.rankDir,
697
+ nodesep: options.dag.nodeSep,
698
+ ranksep: options.dag.rankSep,
699
+ });
700
+ g.setDefaultEdgeLabel(() => ({}));
701
+ for (const node of nodes) {
702
+ const { width, height } = node.size();
703
+ g.setNode(node.id, { width: Math.round(width) + 10, height: Math.round(height) + 10 });
704
+ }
705
+ for (const edge of graphView.getEdges() as any[]) {
706
+ const srcId = edge.getSourceCellId();
707
+ const tgtId = edge.getTargetCellId();
708
+ if (srcId && tgtId && srcId !== tgtId) {
709
+ g.setEdge(srcId, tgtId, {}, edge.id);
710
+ }
711
+ }
712
+ dagre.layout(g);
713
+ for (const nodeId of g.nodes() as string[]) {
714
+ const n = g.node(nodeId) as { x: number; y: number; width: number; height: number } | undefined;
715
+ if (!n) {
716
+ continue;
717
+ }
718
+ const cell = graphView.getCellById(nodeId);
719
+ if (cell) {
720
+ cell.setPosition(Math.round(n.x - n.width / 2), Math.round(n.y - n.height / 2));
721
+ }
722
+ }
723
+ if (options.fit) {
724
+ fitGraphContent(graphView, options.padding);
725
+ }
726
+ return;
727
+ }
728
+
729
+ // Grid mode
730
+ const { rows: targetRows, cols: targetCols, sortBy } = options.grid;
731
+ const sorted = [...nodes].sort((a: any, b: any) => {
732
+ if (sortBy === 'label') {
733
+ return String((a.getData() as NodeData)?.label ?? '').localeCompare(String((b.getData() as NodeData)?.label ?? ''));
734
+ }
735
+ return Number((b.getData() as NodeData)?.degree ?? 0) - Number((a.getData() as NodeData)?.degree ?? 0);
736
+ });
737
+ const count = sorted.length;
738
+ const cols = targetCols ?? (targetRows ? Math.ceil(count / targetRows) : Math.ceil(Math.sqrt(count)));
739
+ sorted.forEach((node: any, i: number) => {
740
+ node.setPosition((i % cols) * 80, Math.floor(i / cols) * 80);
741
+ });
742
+ if (options.fit) {
743
+ fitGraphContent(graphView, options.padding);
744
+ }
745
+ }
746
+
747
+ function fitGraphContent(graphView: any, padding: number): void {
748
+ requestAnimationFrame(() => {
749
+ try {
750
+ if (typeof graphView.fitContent === 'function') {
751
+ graphView.fitContent({ padding, allowNewOrigin: 'any', maxScale: 1 });
752
+ } else if (typeof graphView.zoomToFit === 'function') {
753
+ graphView.zoomToFit({ padding, maxScale: 1 });
754
+ }
755
+ } catch {
756
+ // ignore
757
+ }
758
+ });
759
+ }
760
+
761
+ // --- Continuous force engine ---
762
+
763
+ function startContinuousForceEngine(
764
+ graphView: any,
765
+ graphRoot: HTMLElement,
766
+ options: ForceLayoutOptions,
767
+ grabbedNodes: Set<string>,
768
+ ): void {
769
+ const velocity = new Map<string, { x: number; y: number }>();
770
+ const dt = 1;
771
+ const axis = resolveAxisBias(options.axisPreference, options.axisStrength);
772
+
773
+ const step = (): void => {
774
+ if (!graphRoot.isConnected) {
775
+ return;
776
+ }
777
+
778
+ const nodes = graphView.getNodes() as any[];
779
+ const forces = new Map<string, { x: number; y: number }>();
780
+
781
+ for (const node of nodes) {
782
+ forces.set(String(node.id), { x: 0, y: 0 });
783
+ if (!velocity.has(String(node.id))) {
784
+ velocity.set(String(node.id), { x: 0, y: 0 });
785
+ }
786
+ }
787
+
788
+ // Node-node repulsion
789
+ for (let i = 0; i < nodes.length; i += 1) {
790
+ const a = nodes[i];
791
+ const ap = a.getPosition();
792
+ for (let j = i + 1; j < nodes.length; j += 1) {
793
+ const b = nodes[j];
794
+ const bp = b.getPosition();
795
+ let dx = ap.x - bp.x;
796
+ let dy = ap.y - bp.y;
797
+ let dist2 = dx * dx + dy * dy;
798
+ if (dist2 < 1) {
799
+ dx = (Math.random() - 0.5) * 2;
800
+ dy = (Math.random() - 0.5) * 2;
801
+ dist2 = dx * dx + dy * dy;
802
+ }
803
+ const dist = Math.sqrt(dist2);
804
+ const groupA = String((a.getData() as NodeData)?.group ?? '');
805
+ const groupB = String((b.getData() as NodeData)?.group ?? '');
806
+ const sameGroup = groupA.length > 0 && groupA === groupB;
807
+ const repulsionScale = sameGroup ? 1 : options.interGroupRepulsionFactor;
808
+ const forceMag = (options.repulsion * repulsionScale) / dist2;
809
+ const fx = (dx / dist) * forceMag;
810
+ const fy = (dy / dist) * forceMag;
811
+ const af = forces.get(String(a.id))!;
812
+ const bf = forces.get(String(b.id))!;
813
+ af.x += fx;
814
+ af.y += fy;
815
+ bf.x -= fx;
816
+ bf.y -= fy;
817
+
818
+ if (sameGroup && options.intraGroupAttraction > 0) {
819
+ const attractMag = options.intraGroupAttraction * dist;
820
+ const ax2 = (dx / dist) * attractMag;
821
+ const ay2 = (dy / dist) * attractMag;
822
+ af.x -= ax2;
823
+ af.y -= ay2;
824
+ bf.x += ax2;
825
+ bf.y += ay2;
826
+ }
827
+ }
828
+ }
829
+
830
+ // Edge spring forces
831
+ for (const edge of graphView.getEdges() as any[]) {
832
+ const source = edge.getSourceCell();
833
+ const target = edge.getTargetCell();
834
+ if (!source || !target) {
835
+ continue;
836
+ }
837
+ const sp = source.getPosition();
838
+ const tp = target.getPosition();
839
+ let dx = tp.x - sp.x;
840
+ let dy = tp.y - sp.y;
841
+ let dist = Math.sqrt(dx * dx + dy * dy);
842
+ if (dist < 0.001) {
843
+ dist = 0.001;
844
+ dx = 0.001;
845
+ dy = 0;
846
+ }
847
+ const displacement = dist - options.linkDistance;
848
+ const forceMag = options.springStrength * displacement;
849
+ const fx = (dx / dist) * forceMag;
850
+ const fy = (dy / dist) * forceMag;
851
+ const sf = forces.get(String(source.id));
852
+ const tf = forces.get(String(target.id));
853
+ if (sf) { sf.x += fx; sf.y += fy; }
854
+ if (tf) { tf.x -= fx; tf.y -= fy; }
855
+ }
856
+
857
+ // Gravity toward origin
858
+ for (const node of nodes) {
859
+ const p = node.getPosition();
860
+ const f = forces.get(String(node.id));
861
+ if (!f) {
862
+ continue;
863
+ }
864
+ f.x += -p.x * options.gravity * axis.centerX;
865
+ f.y += -p.y * options.gravity * axis.centerY;
866
+ }
867
+
868
+ // Integrate velocity and position
869
+ for (const node of nodes) {
870
+ if (grabbedNodes.has(String(node.id))) {
871
+ continue;
872
+ }
873
+ const f = forces.get(String(node.id))!;
874
+ const v = velocity.get(String(node.id))!;
875
+ v.x = (v.x + f.x * dt) * options.damping;
876
+ v.y = (v.y + f.y * dt) * options.damping;
877
+
878
+ const speed = Math.sqrt(v.x * v.x + v.y * v.y);
879
+ if (speed > options.maxSpeed) {
880
+ const scale = options.maxSpeed / speed;
881
+ v.x *= scale;
882
+ v.y *= scale;
883
+ }
884
+
885
+ const p = node.getPosition();
886
+ node.setPosition(p.x + v.x * dt, p.y + v.y * dt);
887
+ }
888
+
889
+ requestAnimationFrame(step);
890
+ };
891
+
892
+ requestAnimationFrame(step);
893
+ }
894
+
895
+ function resolveAxisBias(
896
+ preference: 'horizontal' | 'vertical' | 'none',
897
+ strength: number,
898
+ ): { centerX: number; centerY: number } {
899
+ const clamped = Math.max(0, Math.min(1, strength));
900
+ if (preference === 'horizontal') {
901
+ return { centerX: 1 - 0.5 * clamped, centerY: 1 + 3 * clamped };
902
+ }
903
+ if (preference === 'vertical') {
904
+ return { centerX: 1 + 3 * clamped, centerY: 1 - 0.5 * clamped };
905
+ }
906
+ return { centerX: 1, centerY: 1 };
907
+ }
908
+
909
+ // --- Graph stylesheet application ---
910
+
911
+ function applyGraphStylesheet(
912
+ graphView: any,
913
+ rules: ReadonlyArray<GraphStyleRule>,
914
+ theme: GraphThemePalette,
915
+ ): void {
916
+ if (rules.length === 0) {
917
+ return;
918
+ }
919
+ for (const rule of rules) {
920
+ if (rule.kind === 'node') {
921
+ for (const node of graphView.getNodes() as any[]) {
922
+ const context = buildNodeContext(node, graphView);
923
+ if (!evaluateExpression(rule.condition, context)) {
924
+ continue;
925
+ }
926
+ if (rule.group && rule.group.trim().length > 0) {
927
+ node.setData({ group: rule.group.trim() }, { merge: true });
928
+ }
929
+ applyElementStyle(node, rule.style, 'node', theme);
930
+ }
931
+ continue;
932
+ }
933
+ for (const edge of graphView.getEdges() as any[]) {
934
+ const context = buildEdgeContext(edge, graphView);
935
+ if (!evaluateExpression(rule.condition, context)) {
936
+ continue;
937
+ }
938
+ applyElementStyle(edge, rule.style, 'edge', theme);
939
+ }
940
+ }
941
+ }
942
+
943
+ function buildNodeContext(node: any, graphView: any): NodeContext {
944
+ const nodeId = String(node.id);
945
+ const nodeContext = buildSelectorNodeContext(node, graphView);
946
+ const connectedEdges = (graphView.getConnectedEdges(node) as any[]) ?? [];
947
+ const incoming: SelectorEdgeContext[] = [];
948
+ const outgoing: SelectorEdgeContext[] = [];
949
+ const neighbors = new Map<string, SelectorNodeContext>();
950
+
951
+ for (const edge of connectedEdges) {
952
+ const edgeContext = buildEdgeContext(edge, graphView);
953
+ if (edge.getTargetCellId() === nodeId) {
954
+ incoming.push(edgeContext);
955
+ neighbors.set(edgeContext.source.id, edgeContext.source);
956
+ }
957
+ if (edge.getSourceCellId() === nodeId) {
958
+ outgoing.push(edgeContext);
959
+ neighbors.set(edgeContext.target.id, edgeContext.target);
960
+ }
961
+ }
962
+
963
+ return {
964
+ value: nodeContext.value,
965
+ incoming,
966
+ outgoing,
967
+ neighbors: [...neighbors.values()],
968
+ };
969
+ }
970
+
971
+ function buildEdgeContext(edge: any, graphView: any): EdgeContext {
972
+ const source = graphView.getCellById(edge.getSourceCellId());
973
+ const target = graphView.getCellById(edge.getTargetCellId());
974
+ return {
975
+ value: String((edge.getData() as { value?: string })?.value ?? ''),
976
+ source: buildSelectorNodeContext(source, graphView),
977
+ target: buildSelectorNodeContext(target, graphView),
978
+ };
979
+ }
980
+
981
+ function buildSelectorNodeContext(node: any, graphView: any): SelectorNodeContext {
982
+ const data = (node?.getData?.() as NodeData | undefined);
983
+ const nodeId = String(node?.id ?? data?.id ?? '');
984
+ return {
985
+ id: nodeId,
986
+ label: String(data?.label ?? shortLabel(data?.value ?? nodeId)),
987
+ value: String(data?.value ?? ''),
988
+ type: resolveNodeTypes(nodeId, graphView),
989
+ degree: Number.isFinite(data?.degree) ? Number(data?.degree) : 0,
990
+ literal: Boolean(data?.literal),
991
+ group: String(data?.group ?? ''),
992
+ };
993
+ }
994
+
995
+ function resolveNodeTypes(nodeId: string, graphView: any): string[] {
996
+ if (!nodeId) {
997
+ return [];
998
+ }
999
+ const node = graphView.getCellById(nodeId);
1000
+ if (!node) {
1001
+ return [];
1002
+ }
1003
+ const connectedEdges = (graphView.getConnectedEdges(node) as any[]) ?? [];
1004
+ const types: string[] = [];
1005
+ for (const edge of connectedEdges) {
1006
+ if (edge.getSourceCellId() !== nodeId) {
1007
+ continue;
1008
+ }
1009
+ const predicateValue = String((edge.getData() as { value?: string })?.value ?? '');
1010
+ if (predicateValue !== RDF_TYPE_IRI && !predicateValue.endsWith('#type')) {
1011
+ continue;
1012
+ }
1013
+ const target = graphView.getCellById(edge.getTargetCellId());
1014
+ const targetValue = String((target?.getData?.() as NodeData | undefined)?.value ?? '');
1015
+ if (!targetValue) {
1016
+ continue;
1017
+ }
1018
+ types.push(targetValue);
1019
+ }
1020
+ return types;
1021
+ }
1022
+
1023
+ function applyElementStyle(
1024
+ element: any,
1025
+ style: Record<string, string>,
1026
+ kind: 'node' | 'edge',
1027
+ theme: GraphThemePalette,
1028
+ ): void {
1029
+ if (kind === 'node') {
1030
+ const bodyAttrs: Record<string, unknown> = {};
1031
+ const labelAttrs: Record<string, unknown> = {};
1032
+ for (const [rawProperty, rawValue] of Object.entries(style)) {
1033
+ const property = rawProperty.trim().toLowerCase();
1034
+ const value = String(rawValue);
1035
+ if (!property) {
1036
+ continue;
1037
+ }
1038
+ const resolved = resolveColorToken(value, theme);
1039
+ if (property === 'fill') { bodyAttrs.fill = resolved; continue; }
1040
+ if (property === 'stroke') { bodyAttrs.stroke = resolved; continue; }
1041
+ if (property === 'stroke-width') { bodyAttrs.strokeWidth = value; continue; }
1042
+ if (property === 'color') { labelAttrs.fill = resolved; continue; }
1043
+ const camel = property.replace(/-([a-z])/g, (_: string, c: string) => c.toUpperCase());
1044
+ bodyAttrs[camel] = resolved;
1045
+ }
1046
+ const toSet: Record<string, unknown> = {};
1047
+ if (Object.keys(bodyAttrs).length > 0) toSet.body = bodyAttrs;
1048
+ if (Object.keys(labelAttrs).length > 0) toSet.label = labelAttrs;
1049
+ if (Object.keys(toSet).length > 0) {
1050
+ element.setAttrs(toSet);
1051
+ }
1052
+ return;
1053
+ }
1054
+
1055
+ // edge
1056
+ const lineAttrs: Record<string, unknown> = {};
1057
+ let edgeLabelFill: string | undefined;
1058
+ for (const [rawProperty, rawValue] of Object.entries(style)) {
1059
+ const property = rawProperty.trim().toLowerCase();
1060
+ const value = String(rawValue);
1061
+ if (!property) {
1062
+ continue;
1063
+ }
1064
+ const resolved = resolveColorToken(value, theme);
1065
+ if (property === 'stroke') {
1066
+ lineAttrs.stroke = resolved;
1067
+ lineAttrs.targetMarker = { name: 'classic', size: 6, fill: resolved, stroke: resolved };
1068
+ continue;
1069
+ }
1070
+ if (property === 'stroke-width') { lineAttrs.strokeWidth = value; continue; }
1071
+ if (property === 'color') { edgeLabelFill = resolved; continue; }
1072
+ const camel = property.replace(/-([a-z])/g, (_: string, c: string) => c.toUpperCase());
1073
+ lineAttrs[camel] = resolved;
1074
+ }
1075
+ if (Object.keys(lineAttrs).length > 0) {
1076
+ element.setAttrs({ line: lineAttrs });
1077
+ }
1078
+ if (edgeLabelFill) {
1079
+ try {
1080
+ element.prop('labels/0/attrs/label/fill', edgeLabelFill);
1081
+ } catch {
1082
+ // ignore
1083
+ }
1084
+ }
1085
+ }
1086
+
1087
+ // --- Toolbar and neighbourhood filter ---
1088
+
1089
+ function installGraphToolbar(graphRoot: HTMLElement, graphView: any, csvDataset: CsvDataset): void {
1090
+ const hotspot = document.createElement('div');
1091
+ hotspot.className = 'graph-corner-hotspot';
1092
+ graphRoot.appendChild(hotspot);
1093
+
1094
+ const toolbar = document.createElement('div');
1095
+ toolbar.className = 'graph-corner-toolbar';
1096
+
1097
+ const filterInput = document.createElement('input');
1098
+ filterInput.type = 'search';
1099
+ filterInput.className = 'tree-filter graph-filter';
1100
+ filterInput.placeholder = 'Filter...';
1101
+ filterInput.addEventListener('input', () => {
1102
+ applyNeighborhoodFilter(graphView, filterInput.value);
1103
+ });
1104
+ toolbar.appendChild(filterInput);
1105
+
1106
+ const downloadButton = createDownloadButton();
1107
+ downloadButton.addEventListener('click', () => {
1108
+ csvDataset.downloadCsv(toCsv(csvDataset.columns, csvDataset.rows));
1109
+ });
1110
+ toolbar.appendChild(downloadButton);
1111
+
1112
+ graphRoot.appendChild(toolbar);
1113
+
1114
+ let hideTimer = 0;
1115
+ const showToolbar = () => {
1116
+ if (hideTimer) {
1117
+ window.clearTimeout(hideTimer);
1118
+ hideTimer = 0;
1119
+ }
1120
+ graphRoot.classList.add('graph-toolbar-visible');
1121
+ };
1122
+ const scheduleHideToolbar = () => {
1123
+ if (document.activeElement === filterInput) {
1124
+ return;
1125
+ }
1126
+ hideTimer = window.setTimeout(() => {
1127
+ graphRoot.classList.remove('graph-toolbar-visible');
1128
+ hideTimer = 0;
1129
+ }, 120);
1130
+ };
1131
+
1132
+ hotspot.addEventListener('mouseenter', showToolbar);
1133
+ toolbar.addEventListener('mouseenter', showToolbar);
1134
+ hotspot.addEventListener('mouseleave', scheduleHideToolbar);
1135
+ toolbar.addEventListener('mouseleave', scheduleHideToolbar);
1136
+ filterInput.addEventListener('focus', showToolbar);
1137
+ filterInput.addEventListener('blur', scheduleHideToolbar);
1138
+
1139
+ const swallowPointer = (event: Event) => {
1140
+ event.stopPropagation();
1141
+ };
1142
+ toolbar.addEventListener('pointerdown', swallowPointer, true);
1143
+ toolbar.addEventListener('mousedown', swallowPointer, true);
1144
+ filterInput.addEventListener('pointerdown', swallowPointer, true);
1145
+ filterInput.addEventListener('mousedown', swallowPointer, true);
1146
+ downloadButton.addEventListener('pointerdown', swallowPointer, true);
1147
+ downloadButton.addEventListener('mousedown', swallowPointer, true);
1148
+ }
1149
+
1150
+ function applyNeighborhoodFilter(graphView: any, rawQuery: string): void {
1151
+ const query = rawQuery.trim().toLowerCase();
1152
+ const allNodes = graphView.getNodes() as any[];
1153
+ const allEdges = graphView.getEdges() as any[];
1154
+
1155
+ if (!query) {
1156
+ for (const node of allNodes) node.show();
1157
+ for (const edge of allEdges) edge.show();
1158
+ return;
1159
+ }
1160
+
1161
+ const matchedIds = new Set<string>();
1162
+ for (const node of allNodes) {
1163
+ const nodeData = node.getData() as NodeData;
1164
+ const label = String(nodeData?.label ?? '').toLowerCase();
1165
+ const value = String(nodeData?.value ?? '').toLowerCase();
1166
+ if (label.includes(query) || value.includes(query)) {
1167
+ matchedIds.add(String(node.id));
1168
+ }
1169
+ }
1170
+
1171
+ // Include one-hop neighbours of matched nodes.
1172
+ const keepNodeIds = new Set<string>(matchedIds);
1173
+ for (const id of matchedIds) {
1174
+ const cell = graphView.getCellById(id);
1175
+ if (!cell) {
1176
+ continue;
1177
+ }
1178
+ const connectedEdges = (graphView.getConnectedEdges(cell) ?? []) as any[];
1179
+ for (const edge of connectedEdges) {
1180
+ keepNodeIds.add(edge.getSourceCellId());
1181
+ keepNodeIds.add(edge.getTargetCellId());
1182
+ }
1183
+ }
1184
+
1185
+ for (const node of allNodes) {
1186
+ if (keepNodeIds.has(String(node.id))) {
1187
+ node.show();
1188
+ } else {
1189
+ node.hide();
1190
+ }
1191
+ }
1192
+
1193
+ // Show only edges whose both endpoints are visible.
1194
+ for (const edge of allEdges) {
1195
+ const srcId = edge.getSourceCellId();
1196
+ const tgtId = edge.getTargetCellId();
1197
+ if (keepNodeIds.has(srcId) && keepNodeIds.has(tgtId)) {
1198
+ edge.show();
1199
+ } else {
1200
+ edge.hide();
1201
+ }
1202
+ }
1203
+ }
1204
+
1205
+ function createDownloadButton(): HTMLButtonElement {
1206
+ const downloadButton = document.createElement('button');
1207
+ downloadButton.className = 'tree-download-btn';
1208
+ downloadButton.title = 'Download CSV';
1209
+ downloadButton.setAttribute('aria-label', 'Download CSV');
1210
+ const iconSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
1211
+ iconSvg.setAttribute('viewBox', '0 0 24 24');
1212
+ iconSvg.setAttribute('width', '20');
1213
+ iconSvg.setAttribute('height', '20');
1214
+ iconSvg.setAttribute('aria-hidden', 'true');
1215
+ iconSvg.setAttribute('focusable', 'false');
1216
+ iconSvg.style.fill = 'currentColor';
1217
+ const iconPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
1218
+ iconPath.setAttribute('d', 'M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z');
1219
+ iconSvg.appendChild(iconPath);
1220
+ downloadButton.appendChild(iconSvg);
1221
+ return downloadButton;
1222
+ }
1223
+
1224
+ // --- Theme resolution ---
1225
+
1226
+ function resolveThemePalette(): GraphThemePalette {
1227
+ const styles = getComputedStyle(document.body);
1228
+ const foreground = readCssVar(
1229
+ styles,
1230
+ '--vscode-editor-foreground',
1231
+ readCssVar(styles, '--oml-static-foreground', '#24292f'),
1232
+ );
1233
+ const background = readCssVar(
1234
+ styles,
1235
+ '--vscode-editor-background',
1236
+ readCssVar(styles, '--oml-static-background', '#ffffff'),
1237
+ );
1238
+ const muted = readCssVar(
1239
+ styles,
1240
+ '--vscode-descriptionForeground',
1241
+ readCssVar(styles, '--oml-static-muted', '#57606a'),
1242
+ );
1243
+ const border = readCssVar(
1244
+ styles,
1245
+ '--vscode-editorWidget-border',
1246
+ readCssVar(styles, '--oml-static-border', '#d0d7de'),
1247
+ );
1248
+ const link = readCssVar(
1249
+ styles,
1250
+ '--vscode-textLink-foreground',
1251
+ readCssVar(styles, '--oml-static-link', '#0969da'),
1252
+ );
1253
+ const accent = readCssVar(styles, '--vscode-button-background', link);
1254
+ const accentForeground = readCssVar(styles, '--vscode-button-foreground', '#ffffff');
1255
+ const selected = readCssVar(styles, '--vscode-focusBorder', link);
1256
+ const nodeFill = readCssVar(styles, '--vscode-button-secondaryBackground', accent);
1257
+ const nodeText = autoContrastingText(background);
1258
+ const nodeStroke = readCssVar(styles, '--vscode-button-border', border);
1259
+ const literalFill = readCssVar(styles, '--vscode-badge-background', muted);
1260
+ const literalText = autoContrastingText(background);
1261
+ const fontFamily = readCssVar(
1262
+ styles,
1263
+ '--vscode-editor-font-family',
1264
+ '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
1265
+ );
1266
+ const edgeLabelText = border;
1267
+ const edgeLabelBg = background;
1268
+ return {
1269
+ nodeFill,
1270
+ nodeStroke,
1271
+ nodeText,
1272
+ literalFill,
1273
+ literalStroke: border,
1274
+ literalText,
1275
+ edgeStroke: border,
1276
+ edgeText: foreground,
1277
+ edgeLabelBg,
1278
+ edgeLabelText,
1279
+ selectedStroke: selected,
1280
+ fontFamily,
1281
+ foreground,
1282
+ background,
1283
+ muted,
1284
+ link,
1285
+ accent,
1286
+ accentForeground,
1287
+ border,
1288
+ };
1289
+ }
1290
+
1291
+ // --- Stylesheet compilation ---
1292
+
1293
+ function compileGraphStylesheet(options: Record<string, unknown> | undefined): GraphStyleRule[] {
1294
+ const raw = options?.stylesheet;
1295
+ if (!Array.isArray(raw)) {
1296
+ return [];
1297
+ }
1298
+ const rules: GraphStyleRule[] = [];
1299
+ for (const entry of raw) {
1300
+ if (!isRecord(entry)) {
1301
+ continue;
1302
+ }
1303
+ const selector = parseGraphSelector(entry.selector);
1304
+ if (!selector) {
1305
+ continue;
1306
+ }
1307
+ const style = normalizeStyle(entry.style);
1308
+ if (Object.keys(style).length === 0) {
1309
+ continue;
1310
+ }
1311
+ rules.push({
1312
+ kind: selector.kind,
1313
+ condition: selector.condition,
1314
+ style,
1315
+ group: selector.kind === 'node' && typeof entry.group === 'string' ? entry.group : undefined,
1316
+ });
1317
+ }
1318
+ return rules;
1319
+ }
1320
+
1321
+ function parseGraphSelector(rawSelector: unknown): { kind: 'node' | 'edge'; condition?: string } | undefined {
1322
+ if (typeof rawSelector !== 'string') {
1323
+ return undefined;
1324
+ }
1325
+ const trimmed = rawSelector.trim();
1326
+ if (!trimmed) {
1327
+ return undefined;
1328
+ }
1329
+ const match = /^(node|edge)(?:\s*\[(.*)\])?$/i.exec(trimmed);
1330
+ if (!match) {
1331
+ return undefined;
1332
+ }
1333
+ const kind = match[1].toLowerCase() as 'node' | 'edge';
1334
+ const condition = match[2]?.trim();
1335
+ return { kind, condition: condition && condition.length > 0 ? condition : undefined };
1336
+ }
1337
+
1338
+ function normalizeStyle(rawStyle: unknown): Record<string, string> {
1339
+ if (!isRecord(rawStyle)) {
1340
+ return {};
1341
+ }
1342
+ const style: Record<string, string> = {};
1343
+ for (const [property, rawValue] of Object.entries(rawStyle)) {
1344
+ if (!property.trim()) {
1345
+ continue;
1346
+ }
1347
+ if (rawValue === undefined || rawValue === null) {
1348
+ continue;
1349
+ }
1350
+ style[property] = String(rawValue);
1351
+ }
1352
+ return style;
1353
+ }
1354
+
1355
+ function evaluateExpression(condition: string | undefined, context: object): boolean {
1356
+ if (!condition) {
1357
+ return true;
1358
+ }
1359
+ try {
1360
+ const scope = Object.assign(Object.create(null), context) as Record<string, unknown>;
1361
+ const keys = Object.keys(scope);
1362
+ const values = keys.map((key) => scope[key]);
1363
+ // eslint-disable-next-line no-new-func
1364
+ const evaluator = new Function(...keys, `"use strict"; return (${condition});`);
1365
+ return Boolean(evaluator(...values));
1366
+ } catch {
1367
+ return false;
1368
+ }
1369
+ }
1370
+
1371
+ // --- Layout options parsing ---
1372
+
1373
+ function resolveLayoutOptions(options: Record<string, unknown> | undefined): ResolvedLayout {
1374
+ const layout = isRecord(options?.layout) ? options.layout : {};
1375
+ const mode = parseMode(layout.mode);
1376
+ const force = isRecord(layout.force) ? layout.force : {};
1377
+ const forceGroup = isRecord(force.group) ? force.group : {};
1378
+ const radial = isRecord(layout.radial) ? layout.radial : {};
1379
+ const dag = isRecord(layout.dag) ? layout.dag : {};
1380
+ const grid = isRecord(layout.grid) ? layout.grid : {};
1381
+ return {
1382
+ mode,
1383
+ running: mode === 'force' ? asBoolean(layout.running, true) : false,
1384
+ fit: asBoolean(layout.fit, true),
1385
+ padding: asNumber(layout.padding, 24),
1386
+ force: {
1387
+ repulsion: asNumber(force.repulsion, 12_000),
1388
+ linkDistance: asNumber(force.linkDistance, 110),
1389
+ springStrength: asNumber(force.springStrength, 0.006),
1390
+ gravity: asNumber(force.gravity, 0.0009),
1391
+ damping: asNumber(force.damping, 0.86),
1392
+ maxSpeed: asNumber(force.maxSpeed, 8),
1393
+ intraGroupAttraction: asNumber(forceGroup.intraAttraction, 0),
1394
+ interGroupRepulsionFactor: asNumber(forceGroup.interRepulsionFactor, 1),
1395
+ axisPreference: force.axisPreference === 'vertical'
1396
+ ? 'vertical'
1397
+ : force.axisPreference === 'none'
1398
+ ? 'none'
1399
+ : 'horizontal',
1400
+ axisStrength: asNumber(force.axisStrength, 0.55),
1401
+ },
1402
+ radial: {
1403
+ startAngle: asNumber(radial.startAngle, -Math.PI / 2),
1404
+ clockwise: asBoolean(radial.clockwise, true),
1405
+ sortBy: radial.sortBy === 'label' ? 'label' : 'degree',
1406
+ },
1407
+ dag: {
1408
+ rankDir: dag.rankDir === 'LR' ? 'LR' : 'TB',
1409
+ nodeSep: asNumber(dag.nodeSep, 40),
1410
+ rankSep: asNumber(dag.rankSep, 80),
1411
+ acyclic: asBoolean(dag.acyclic, true),
1412
+ },
1413
+ grid: {
1414
+ rows: asOptionalNumber(grid.rows),
1415
+ cols: asOptionalNumber(grid.cols),
1416
+ sortBy: grid.sortBy === 'label' ? 'label' : 'degree',
1417
+ condense: asBoolean(grid.condense, false),
1418
+ },
1419
+ };
1420
+ }
1421
+
1422
+ function parseMode(value: unknown): 'force' | 'radial' | 'dag' | 'grid' {
1423
+ if (value === 'radial' || value === 'dag' || value === 'grid') {
1424
+ return value;
1425
+ }
1426
+ return 'force';
1427
+ }
1428
+
1429
+ // --- Canvas dimension helpers ---
1430
+
1431
+ function resolveCanvasHeight(options: Record<string, unknown> | undefined): string {
1432
+ const canvas = isRecord(options?.canvas) ? options.canvas : undefined;
1433
+ const raw = canvas?.height ?? options?.height;
1434
+ const minHeight = numericCanvasMinHeight(options);
1435
+ if (typeof raw === 'number' && Number.isFinite(raw) && raw >= minHeight) {
1436
+ return `${raw}px`;
1437
+ }
1438
+ if (typeof raw === 'string') {
1439
+ const trimmed = raw.trim();
1440
+ if (/^\d+(\.\d+)?(px|vh|%)$/.test(trimmed)) {
1441
+ return trimmed;
1442
+ }
1443
+ }
1444
+ return '460px';
1445
+ }
1446
+
1447
+ function resolveCanvasMinHeight(options: Record<string, unknown> | undefined): string {
1448
+ return `${numericCanvasMinHeight(options)}px`;
1449
+ }
1450
+
1451
+ function numericCanvasMinHeight(options: Record<string, unknown> | undefined): number {
1452
+ const canvas = isRecord(options?.canvas) ? options.canvas : undefined;
1453
+ const raw = canvas?.minHeight ?? options?.minHeight;
1454
+ if (typeof raw === 'number' && Number.isFinite(raw)) {
1455
+ return Math.max(120, Math.round(raw));
1456
+ }
1457
+ return 220;
1458
+ }
1459
+
1460
+ // --- Utility helpers ---
1461
+
1462
+ function asNumber(value: unknown, fallback: number): number {
1463
+ if (typeof value === 'number' && Number.isFinite(value)) {
1464
+ return value;
1465
+ }
1466
+ return fallback;
1467
+ }
1468
+
1469
+ function asOptionalNumber(value: unknown): number | undefined {
1470
+ if (typeof value === 'number' && Number.isFinite(value)) {
1471
+ return value;
1472
+ }
1473
+ return undefined;
1474
+ }
1475
+
1476
+ function asBoolean(value: unknown, fallback: boolean): boolean {
1477
+ if (typeof value === 'boolean') {
1478
+ return value;
1479
+ }
1480
+ return fallback;
1481
+ }
1482
+
1483
+ function readCssVar(styles: CSSStyleDeclaration, name: string, fallback: string): string {
1484
+ const value = styles.getPropertyValue(name).trim();
1485
+ return value.length > 0 ? value : fallback;
1486
+ }
1487
+
1488
+ function parseRgbColor(value: string): { r: number; g: number; b: number } | undefined {
1489
+ const match = /^rgba?\(\s*([0-9.]+)\s*,\s*([0-9.]+)\s*,\s*([0-9.]+)/i.exec(value.trim());
1490
+ if (!match) {
1491
+ return undefined;
1492
+ }
1493
+ const r = Number.parseFloat(match[1]);
1494
+ const g = Number.parseFloat(match[2]);
1495
+ const b = Number.parseFloat(match[3]);
1496
+ if (!Number.isFinite(r) || !Number.isFinite(g) || !Number.isFinite(b)) {
1497
+ return undefined;
1498
+ }
1499
+ return {
1500
+ r: Math.max(0, Math.min(255, Math.round(r))),
1501
+ g: Math.max(0, Math.min(255, Math.round(g))),
1502
+ b: Math.max(0, Math.min(255, Math.round(b))),
1503
+ };
1504
+ }
1505
+
1506
+ function parseBrowserColor(value: string): { r: number; g: number; b: number } | undefined {
1507
+ const probe = document.createElement('span');
1508
+ probe.style.color = '';
1509
+ probe.style.color = value.trim();
1510
+ if (!probe.style.color) {
1511
+ return undefined;
1512
+ }
1513
+ probe.style.position = 'absolute';
1514
+ probe.style.left = '-9999px';
1515
+ probe.style.top = '-9999px';
1516
+ document.body.appendChild(probe);
1517
+ const resolved = getComputedStyle(probe).color;
1518
+ probe.remove();
1519
+ return parseRgbColor(resolved);
1520
+ }
1521
+
1522
+ function parseCssColor(value: string): { r: number; g: number; b: number } | undefined {
1523
+ return parseHexColor(value) ?? parseRgbColor(value) ?? parseBrowserColor(value);
1524
+ }
1525
+
1526
+ function parseHexColor(value: string): { r: number; g: number; b: number } | undefined {
1527
+ const trimmed = value.trim();
1528
+ const match = /^#([0-9a-f]{6}|[0-9a-f]{3})$/i.exec(trimmed);
1529
+ if (!match) {
1530
+ return undefined;
1531
+ }
1532
+ const hex = match[1];
1533
+ if (hex.length === 3) {
1534
+ const r = Number.parseInt(hex[0] + hex[0], 16);
1535
+ const g = Number.parseInt(hex[1] + hex[1], 16);
1536
+ const b = Number.parseInt(hex[2] + hex[2], 16);
1537
+ return { r, g, b };
1538
+ }
1539
+ const r = Number.parseInt(hex.slice(0, 2), 16);
1540
+ const g = Number.parseInt(hex.slice(2, 4), 16);
1541
+ const b = Number.parseInt(hex.slice(4, 6), 16);
1542
+ return { r, g, b };
1543
+ }
1544
+
1545
+ function relativeLuminance(color: { r: number; g: number; b: number }): number {
1546
+ const toLinear = (channel: number): number => {
1547
+ const c = channel / 255;
1548
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
1549
+ };
1550
+ const r = toLinear(color.r);
1551
+ const g = toLinear(color.g);
1552
+ const b = toLinear(color.b);
1553
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
1554
+ }
1555
+
1556
+ function contrastRatio(a: string, b: string): number {
1557
+ const ca = parseCssColor(a);
1558
+ const cb = parseCssColor(b);
1559
+ if (!ca || !cb) {
1560
+ return 1;
1561
+ }
1562
+ const la = relativeLuminance(ca);
1563
+ const lb = relativeLuminance(cb);
1564
+ const light = Math.max(la, lb);
1565
+ const dark = Math.min(la, lb);
1566
+ return (light + 0.05) / (dark + 0.05);
1567
+ }
1568
+
1569
+ function autoContrastingText(background: string): string {
1570
+ const dark = '#111111';
1571
+ const light = '#f5f5f5';
1572
+ return contrastRatio(background, dark) >= contrastRatio(background, light) ? dark : light;
1573
+ }
1574
+
1575
+ function resolveColorToken(value: string, theme: GraphThemePalette): string {
1576
+ const trimmed = value.trim();
1577
+ if (trimmed === 'theme.node') return theme.nodeFill;
1578
+ if (trimmed === 'theme.nodeStroke') return theme.nodeStroke;
1579
+ if (trimmed === 'theme.nodeText') return theme.nodeText;
1580
+ if (trimmed === 'theme.literal') return theme.literalFill;
1581
+ if (trimmed === 'theme.literalStroke') return theme.literalStroke;
1582
+ if (trimmed === 'theme.literalText') return theme.literalText;
1583
+ if (trimmed === 'theme.edge') return theme.edgeStroke;
1584
+ if (trimmed === 'theme.edgeText') return theme.edgeText;
1585
+ if (trimmed === 'theme.edgeLabelBg') return theme.edgeLabelBg;
1586
+ if (trimmed === 'theme.edgeLabelText') return theme.edgeLabelText;
1587
+ if (trimmed === 'theme.selected') return theme.selectedStroke;
1588
+ if (trimmed === 'theme.foreground') return theme.foreground;
1589
+ if (trimmed === 'theme.background') return theme.background;
1590
+ if (trimmed === 'theme.muted') return theme.muted;
1591
+ if (trimmed === 'theme.link') return theme.link;
1592
+ if (trimmed === 'theme.accent') return theme.accent;
1593
+ if (trimmed === 'theme.accentForeground') return theme.accentForeground;
1594
+ if (trimmed === 'theme.border') return theme.border;
1595
+ return trimmed;
1596
+ }
1597
+
1598
+ function ensureNode(nodes: Map<string, NodeData>, rawValue: string): void {
1599
+ const id = toNodeId(rawValue);
1600
+ if (nodes.has(id)) {
1601
+ return;
1602
+ }
1603
+ nodes.set(id, {
1604
+ id,
1605
+ label: shortLabel(rawValue),
1606
+ value: rawValue,
1607
+ degree: 0,
1608
+ literal: isLiteralValue(rawValue),
1609
+ group: '',
1610
+ });
1611
+ }
1612
+
1613
+ function isLiteralValue(value: string): boolean {
1614
+ return !/^https?:\/\//i.test(value) && !value.startsWith('_:');
1615
+ }
1616
+
1617
+ function toNodeId(value: string): string {
1618
+ return encodeURIComponent(value);
1619
+ }
1620
+
1621
+ function toCsv(columns: string[], rows: string[][]): string {
1622
+ const escape = (value: string): string => {
1623
+ if (/[",\n\r]/.test(value)) {
1624
+ return `"${value.replace(/"/g, '""')}"`;
1625
+ }
1626
+ return value;
1627
+ };
1628
+ const lines: string[] = [];
1629
+ lines.push(columns.map((value) => escape(String(value ?? ''))).join(','));
1630
+ for (const row of rows) {
1631
+ lines.push(row.map((value) => escape(String(value ?? ''))).join(','));
1632
+ }
1633
+ return lines.join('\n');
1634
+ }
1635
+
1636
+ function shortLabel(value: string): string {
1637
+ if (value.startsWith('_:')) {
1638
+ return value;
1639
+ }
1640
+ const hash = value.lastIndexOf('#');
1641
+ if (hash >= 0 && hash < value.length - 1) {
1642
+ return value.slice(hash + 1);
1643
+ }
1644
+ const slash = value.lastIndexOf('/');
1645
+ if (slash >= 0 && slash < value.length - 1) {
1646
+ return value.slice(slash + 1);
1647
+ }
1648
+ return value;
1649
+ }
1650
+
1651
+ function isRecord(value: unknown): value is Record<string, unknown> {
1652
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
1653
+ }