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