@hpcc-js/graph 3.8.4 → 3.9.1

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.
@@ -0,0 +1,448 @@
1
+ import type { Graph } from "@hpcc-js/wasm-graphviz";
2
+ import { d3Event, SVGZoomWidget, type Widget as WidgetT } from "@hpcc-js/common";
3
+ import { graphvizDot, GraphvizDotResponse } from "../common/layouts/index.ts";
4
+
5
+ import "./Widget.css";
6
+
7
+ export const CUSTOM_HTML = "htmlContent";
8
+ export const CUSTOM_SVG = "svgContent";
9
+ export interface CustomVertex {
10
+ id: string;
11
+ svg?: string;
12
+ html?: string;
13
+ }
14
+
15
+ export class Widget extends SVGZoomWidget {
16
+
17
+ protected static readonly _customVertexDPI = 72;
18
+
19
+ private static readonly _svgColorMap: Record<string, string> = {
20
+ '"black"': '"var(--gv-fg)"',
21
+ '"white"': '"var(--gv-bg)"'
22
+ };
23
+ private static readonly _svgColorRe = /"black"|"white"/g;
24
+
25
+ protected _selection: { [id: string]: boolean } = {};
26
+ protected _selectionPreferredNode: { [id: string]: SVGGElement } = {};
27
+
28
+ constructor() {
29
+ super();
30
+ this._drawStartPos = "origin";
31
+ this.zoomToFitLimit(1);
32
+ this.showToolbar(false);
33
+ }
34
+
35
+ protected _data: Graph;
36
+ data(_: Graph): this;
37
+ data(): Graph;
38
+ data(_?: Graph): this | Graph {
39
+ if (!arguments.length) return this._data;
40
+ this._data = _;
41
+ return this;
42
+ }
43
+
44
+ clearSelection(broadcast: boolean = false) {
45
+ this._selection = {};
46
+ this._selectionPreferredNode = {};
47
+ this._selectionChanged(broadcast);
48
+ }
49
+
50
+ toggleSelection(id: string, broadcast: boolean = false, preferredNode?: SVGGElement) {
51
+ if (this._selection[id]) {
52
+ delete this._selection[id];
53
+ delete this._selectionPreferredNode[id];
54
+ } else {
55
+ this._selection[id] = true;
56
+ if (preferredNode) {
57
+ this._selectionPreferredNode[id] = preferredNode;
58
+ }
59
+ }
60
+ this._selectionChanged(broadcast);
61
+ }
62
+
63
+ selectionCompare(_: string[]): boolean {
64
+ const currSelection = this.selection();
65
+ return currSelection.length !== _.length || _.some(id => currSelection.indexOf(id) < 0);
66
+ }
67
+
68
+ selection(): string[];
69
+ selection(_: string[]): this;
70
+ selection(_: string[], broadcast: boolean): this;
71
+ selection(_?: string[], broadcast: boolean = false): string[] | this {
72
+ if (!arguments.length) return Object.keys(this._selection);
73
+ if (this.selectionCompare(_)) {
74
+ this.clearSelection();
75
+ _.forEach(id => this._selection[id] = true);
76
+ this._selectionPreferredNode = {};
77
+ this._selectionChanged(broadcast);
78
+ }
79
+ return this;
80
+ }
81
+
82
+ setClass(className: string, ids?: string[]): this {
83
+ if (ids) {
84
+ for (const id of ids) {
85
+ const elem = this._renderElement.select(`#${id}`);
86
+ if (!elem.empty()) {
87
+ elem.classed(className, true);
88
+ }
89
+ }
90
+ } else {
91
+ this._renderElement
92
+ .selectAll(".node,.edge,.cluster")
93
+ .classed(className, true)
94
+ ;
95
+ }
96
+ return this;
97
+ }
98
+
99
+ clearClass(className: string, ids?: string[]): this {
100
+ if (ids) {
101
+ for (const id of ids) {
102
+ const elem = this._renderElement.select(`#${id}`);
103
+ if (!elem.empty()) {
104
+ elem.classed(className, false);
105
+ }
106
+ }
107
+ } else {
108
+ this._renderElement
109
+ .selectAll(".node,.edge,.cluster")
110
+ .classed(className, false)
111
+ ;
112
+ }
113
+ return this;
114
+ }
115
+
116
+ hasClass(className: string, id: string): boolean {
117
+ const elem = this._renderElement.select(`#${id}`);
118
+ return !elem.empty() && elem.classed(className);
119
+ }
120
+
121
+ protected _selectionChanged(broadcast = false) {
122
+ const hasClass = (node: any, className: string): boolean => {
123
+ return !!(node && node.classList && typeof node.classList.contains === "function" && node.classList.contains(className));
124
+ };
125
+ const addSelectedClass = (node: any): void => {
126
+ if (node && node.classList && typeof node.classList.add === "function") {
127
+ node.classList.add("selected");
128
+ }
129
+ };
130
+
131
+ this._renderElement.selectAll(".node,.edge,.cluster")
132
+ .classed("selected", false);
133
+
134
+ for (const id of Object.keys(this._selection)) {
135
+ const matches = this._renderElement.selectAll(`.node[id='${id}'],.edge[id='${id}'],.cluster[id='${id}']`) as any;
136
+ const nodes: SVGGElement[] = typeof matches.nodes === "function"
137
+ ? matches.nodes()
138
+ : (() => {
139
+ const retVal: SVGGElement[] = [];
140
+ if (typeof matches.each === "function") {
141
+ matches.each(function (this: SVGGElement) {
142
+ retVal.push(this);
143
+ });
144
+ }
145
+ return retVal;
146
+ })();
147
+ if (!nodes.length) continue;
148
+
149
+ // Some graphs emit duplicate cluster IDs; prefer the exact clicked cluster when available.
150
+ const clusterNodes = nodes.filter(n => hasClass(n, "cluster"));
151
+ if (clusterNodes.length > 1) {
152
+ const preferred = this._selectionPreferredNode[id];
153
+ if (preferred && clusterNodes.indexOf(preferred) >= 0) {
154
+ addSelectedClass(preferred);
155
+ } else {
156
+ let largest = clusterNodes[0] as SVGGraphicsElement;
157
+ let largestArea = 0;
158
+ for (const node of clusterNodes) {
159
+ const bbox = (node as SVGGraphicsElement).getBBox();
160
+ const area = bbox.width * bbox.height;
161
+ if (area > largestArea) {
162
+ largestArea = area;
163
+ largest = node as SVGGraphicsElement;
164
+ }
165
+ }
166
+ addSelectedClass(largest);
167
+ }
168
+ for (const node of nodes) {
169
+ if (!hasClass(node, "cluster")) {
170
+ addSelectedClass(node);
171
+ }
172
+ }
173
+ } else {
174
+ for (const node of nodes) {
175
+ addSelectedClass(node);
176
+ }
177
+ }
178
+ }
179
+
180
+ if (broadcast) {
181
+ this.selectionChanged();
182
+ }
183
+ }
184
+
185
+ enter(domNode: HTMLElement | SVGElement, element?: SVGGElement): this {
186
+ super.enter(domNode, element);
187
+ const context = this;
188
+ this._renderElement
189
+ .on("click", function () {
190
+ const event = d3Event();
191
+ let target = event.target as SVGElement;
192
+ while (target && target !== event.currentTarget) {
193
+ const action = (target as Element).getAttribute?.("data-action");
194
+ if (action) {
195
+ let nodeEl = target.parentElement as unknown as SVGElement;
196
+ while (nodeEl && nodeEl !== event.currentTarget) {
197
+ if (nodeEl.classList?.contains("node")) {
198
+ context.vertexButtonClicked(nodeEl.id, action);
199
+ return;
200
+ }
201
+ nodeEl = nodeEl.parentElement as unknown as SVGElement;
202
+ }
203
+ return;
204
+ }
205
+ if (target.classList.contains("node") || target.classList.contains("edge") || target.classList.contains("cluster")) {
206
+ if (!event.ctrlKey && !event.metaKey) {
207
+ context.clearSelection();
208
+ }
209
+ context.toggleSelection(target.id, true, target as SVGGElement);
210
+ return;
211
+ }
212
+ target = target.parentElement as unknown as SVGElement;
213
+ }
214
+ context.clearSelection(true);
215
+ })
216
+ .on("dblclick", function () {
217
+ const event = d3Event();
218
+ event.stopPropagation();
219
+ event.preventDefault();
220
+ let target = event.target as SVGElement;
221
+ while (target && target !== event.currentTarget) {
222
+ if (target.classList.contains("node") || target.classList.contains("edge") || target.classList.contains("cluster")) {
223
+ context.zoomToItem(target as SVGGraphicsElement);
224
+ return;
225
+ }
226
+ target = target.parentElement as unknown as SVGElement;
227
+ }
228
+ context.zoomToFit();
229
+ })
230
+ ;
231
+
232
+ this._zoomGrab
233
+ .on("dblclick", function () {
234
+ const event = d3Event();
235
+ event.stopPropagation();
236
+ event.preventDefault();
237
+ context.zoomToFit();
238
+ })
239
+ ;
240
+
241
+ this.on("startMarqueeSelection", () => {
242
+ context.clearSelection();
243
+ }).on("updateMarqueeSelection", () => {
244
+ const selectedIds: string[] = [];
245
+
246
+ // Use screen coordinates to avoid coordinate system complexity
247
+ // (graphviz root g has a Y-flip transform that getBBox() does not account for)
248
+ const marqueeNode = context._marqueeSelection?.node() as SVGRectElement | undefined;
249
+ if (!marqueeNode) return;
250
+ const marqueeRect = marqueeNode.getBoundingClientRect();
251
+
252
+ context._renderElement.selectAll(".node,.edge,.cluster")
253
+ .each(function (this: SVGGraphicsElement) {
254
+ if (!this.id) return;
255
+ const elemRect = this.getBoundingClientRect();
256
+ if (!elemRect) return;
257
+
258
+ if (marqueeRect.left <= elemRect.left && marqueeRect.right >= elemRect.right &&
259
+ marqueeRect.top <= elemRect.top && marqueeRect.bottom >= elemRect.bottom) {
260
+ selectedIds.push(this.id);
261
+ }
262
+ });
263
+
264
+ context.selection(selectedIds);
265
+ }).on("endMarqueeSelection", () => {
266
+ context.selectionChanged();
267
+ });
268
+
269
+ return this;
270
+ }
271
+
272
+ protected collectCustomVertices(): CustomVertex[] {
273
+ const customVertices: CustomVertex[] = [];
274
+ for (const nodeName of this._data.nodeNames()) {
275
+ const svg = this._data.getNodeAttr(nodeName, CUSTOM_SVG);
276
+ const html = this._data.getNodeAttr(nodeName, CUSTOM_HTML);
277
+ if (!svg && !html) continue;
278
+
279
+ const svgWidth = parseFloat(this._data.getNodeAttr(nodeName, "svgWidth"));
280
+ const svgHeight = parseFloat(this._data.getNodeAttr(nodeName, "svgHeight"));
281
+ if (!isFinite(svgWidth) || !isFinite(svgHeight) || svgWidth <= 0 || svgHeight <= 0) continue;
282
+
283
+ customVertices.push({ id: nodeName, svg: svg || undefined, html: html || undefined });
284
+
285
+ this._data
286
+ .setNodeAttr(nodeName, "label", "")
287
+ .setNodeAttr(nodeName, "shape", this._data.getNodeAttr(nodeName, "shape") || "rectangle")
288
+ .setNodeAttr(nodeName, "fixedsize", true)
289
+ .setNodeAttr(nodeName, "width", svgWidth / Widget._customVertexDPI)
290
+ .setNodeAttr(nodeName, "height", svgHeight / Widget._customVertexDPI);
291
+ }
292
+ return customVertices;
293
+ }
294
+
295
+ protected postrenderCustomVertices(customVertices: CustomVertex[]) {
296
+ for (const v of customVertices) {
297
+ const nodeGroup = this._renderElement.select(`#${v.id}`);
298
+ if (nodeGroup.empty()) continue;
299
+
300
+ const bbox = (nodeGroup.node() as SVGGraphicsElement).getBBox();
301
+ const cx = bbox.x + bbox.width / 2;
302
+ const cy = bbox.y + bbox.height / 2;
303
+
304
+ nodeGroup.select("polygon,ellipse,path")
305
+ .attr("stroke", "transparent")
306
+ .attr("fill", "transparent");
307
+
308
+ if (v.html) {
309
+ const nodeId = v.id;
310
+ const fo = nodeGroup.append("foreignObject")
311
+ .attr("class", CUSTOM_HTML)
312
+ .attr("width", bbox.width)
313
+ .attr("height", bbox.height)
314
+ .attr("x", bbox.x)
315
+ .attr("y", bbox.y);
316
+
317
+ const div = fo.append("xhtml:div")
318
+ .attr("xmlns", "http://www.w3.org/1999/xhtml")
319
+ .style("width", `${bbox.width}px`)
320
+ .style("height", `${bbox.height}px`)
321
+ .style("overflow", "hidden")
322
+ .html(v.html);
323
+
324
+ (div.node() as HTMLElement).querySelectorAll<HTMLElement>("[data-action]").forEach(btn => {
325
+ btn.addEventListener("click", (e) => {
326
+ e.stopPropagation();
327
+ this.vertexButtonClicked(nodeId, btn.dataset.action!);
328
+ });
329
+ });
330
+ } else if (v.svg) {
331
+ const g = nodeGroup.append("g")
332
+ .attr("class", CUSTOM_SVG);
333
+ g.html(v.svg);
334
+
335
+ const contentBBox = (g.node() as SVGGraphicsElement).getBBox();
336
+ const dx = cx - (contentBBox.x + contentBBox.width / 2);
337
+ const dy = cy - (contentBBox.y + contentBBox.height / 2);
338
+ g.attr("transform", `translate(${dx},${dy})`);
339
+ }
340
+ }
341
+ }
342
+
343
+ itemBBox(scopeID: string): ReturnType<Widget["getRenderElementBBox"]>;
344
+ itemBBox(node: SVGGraphicsElement): ReturnType<Widget["getRenderElementBBox"]>;
345
+ itemBBox(scopeIDOrNode: string | SVGGraphicsElement) {
346
+ const node = typeof scopeIDOrNode === "string"
347
+ ? this._renderElement.select(`#${scopeIDOrNode}`).node() as SVGGraphicsElement
348
+ : scopeIDOrNode;
349
+ const renderNode = this._renderElement.node() as SVGGraphicsElement;
350
+ if (node && renderNode) {
351
+ const clientRect = node.getBoundingClientRect();
352
+ const inverseScreenCTM = renderNode.getScreenCTM()?.inverse();
353
+ if (inverseScreenCTM && clientRect.width && clientRect.height) {
354
+ const topLeft = {
355
+ x: inverseScreenCTM.a * clientRect.left + inverseScreenCTM.c * clientRect.top + inverseScreenCTM.e,
356
+ y: inverseScreenCTM.b * clientRect.left + inverseScreenCTM.d * clientRect.top + inverseScreenCTM.f
357
+ };
358
+ const bottomRight = {
359
+ x: inverseScreenCTM.a * clientRect.right + inverseScreenCTM.c * clientRect.bottom + inverseScreenCTM.e,
360
+ y: inverseScreenCTM.b * clientRect.right + inverseScreenCTM.d * clientRect.bottom + inverseScreenCTM.f
361
+ };
362
+ return {
363
+ x: Math.min(topLeft.x, bottomRight.x),
364
+ y: Math.min(topLeft.y, bottomRight.y),
365
+ width: Math.abs(bottomRight.x - topLeft.x),
366
+ height: Math.abs(bottomRight.y - topLeft.y)
367
+ };
368
+ }
369
+ return node.getBBox();
370
+ }
371
+ return this.getRenderElementBBox();
372
+ }
373
+
374
+ zoomToItem(scopeID: string): this;
375
+ zoomToItem(node: SVGGraphicsElement): this;
376
+ zoomToItem(scopeIDOrNode: string | SVGGraphicsElement) {
377
+ const itemBBox = typeof scopeIDOrNode === "string"
378
+ ? this.itemBBox(scopeIDOrNode)
379
+ : this.itemBBox(scopeIDOrNode);
380
+ this.zoomToBBox(itemBBox);
381
+ return this;
382
+ }
383
+
384
+ update(domNode: HTMLElement | SVGElement, element: SVGGElement): this {
385
+ super.update(domNode, element);
386
+ return this;
387
+ }
388
+
389
+ exit(domNode: HTMLElement | SVGElement, element?: SVGGElement): this {
390
+ super.exit(domNode, element);
391
+ return this;
392
+ }
393
+
394
+ protected _prevDot: string | undefined;
395
+ protected _prevLayout: GraphvizDotResponse | undefined;
396
+ render(callback?: (w: WidgetT) => void): this {
397
+ super.render((w: WidgetT) => {
398
+ const customVertices = this.collectCustomVertices();
399
+ const dot = this._data.toDot();
400
+ if (this._prevDot !== dot) {
401
+ this._prevDot = dot;
402
+ if (this._prevLayout) {
403
+ this._prevLayout.terminate();
404
+ }
405
+ const renderNode = this._renderElement.node();
406
+ renderNode.innerHTML = "";
407
+
408
+ this._prevLayout = graphvizDot(dot, "dot");
409
+ this._prevLayout.response
410
+ .then(svg => {
411
+ if (this._prevLayout) {
412
+ this._prevLayout = undefined;
413
+ }
414
+ svg = svg.replace(Widget._svgColorRe, m => Widget._svgColorMap[m]);
415
+ const svgDoc = new DOMParser().parseFromString(svg, "image/svg+xml");
416
+ renderNode.replaceChildren(...svgDoc.documentElement.childNodes);
417
+ this.postrenderCustomVertices(customVertices);
418
+ this._selectionChanged();
419
+ }).catch(e => {
420
+ if (this._prevLayout) {
421
+ this._prevLayout = undefined;
422
+ }
423
+ renderNode.innerHTML = "";
424
+ this.postrenderCustomVertices([]);
425
+ this._selectionChanged();
426
+ console.error(e);
427
+ }).finally(() => {
428
+ if (callback) {
429
+ callback(w);
430
+ }
431
+ });
432
+ } else {
433
+ if (callback) {
434
+ callback(w);
435
+ }
436
+ }
437
+ });
438
+ return this;
439
+ }
440
+
441
+ // Events ---
442
+ selectionChanged() {
443
+ }
444
+
445
+ vertexButtonClicked(id: string, action: string) {
446
+ }
447
+ }
448
+ Widget.prototype._class += " graph_GraphvizWidget";
@@ -0,0 +1 @@
1
+ export * from "./Widget.ts";
package/src/index.ts CHANGED
@@ -8,3 +8,4 @@ export * from "./Vertex.ts";
8
8
  export * from "./common/index.ts";
9
9
  export * from "./html/index.ts";
10
10
  export * from "./react/index.ts";
11
+ export * as Graphviz from "./graphviz/index.ts";
@@ -0,0 +1,8 @@
1
+ import { type Engine } from "@hpcc-js/wasm-graphviz";
2
+ import { isLayoutSuccess, type LayoutSVG } from "./workers/graphvizDotOptions.ts";
3
+ export { isLayoutSuccess, type LayoutSVG };
4
+ export interface GraphvizDotResponse {
5
+ response: Promise<string>;
6
+ terminate: () => void;
7
+ }
8
+ export declare function graphvizDot(dot: string, layout?: Engine): GraphvizDotResponse;
@@ -3,5 +3,6 @@ export * from "./dagre.ts";
3
3
  export * from "./forceDirected.ts";
4
4
  export * from "./geoForceDirected.ts";
5
5
  export * from "./graphviz.ts";
6
+ export * from "./graphvizDot.ts";
6
7
  export type { ILayout } from "./layout.ts";
7
8
  export * from "./null.ts";
@@ -0,0 +1,11 @@
1
+ export interface LayoutError {
2
+ error?: string;
3
+ errorDot?: string;
4
+ }
5
+ export interface LayoutSVG extends LayoutError {
6
+ svg?: string;
7
+ }
8
+ export type LayoutSuccess = {
9
+ svg: string;
10
+ };
11
+ export declare function isLayoutSuccess(item: any): item is LayoutSuccess;
@@ -0,0 +1,50 @@
1
+ import type { Graph } from "@hpcc-js/wasm-graphviz";
2
+ import { SVGZoomWidget, type Widget as WidgetT } from "@hpcc-js/common";
3
+ import { GraphvizDotResponse } from "../common/layouts/index.ts";
4
+ import "./Widget.css";
5
+ export declare const CUSTOM_HTML = "htmlContent";
6
+ export declare const CUSTOM_SVG = "svgContent";
7
+ export interface CustomVertex {
8
+ id: string;
9
+ svg?: string;
10
+ html?: string;
11
+ }
12
+ export declare class Widget extends SVGZoomWidget {
13
+ protected static readonly _customVertexDPI = 72;
14
+ private static readonly _svgColorMap;
15
+ private static readonly _svgColorRe;
16
+ protected _selection: {
17
+ [id: string]: boolean;
18
+ };
19
+ protected _selectionPreferredNode: {
20
+ [id: string]: SVGGElement;
21
+ };
22
+ constructor();
23
+ protected _data: Graph;
24
+ data(_: Graph): this;
25
+ data(): Graph;
26
+ clearSelection(broadcast?: boolean): void;
27
+ toggleSelection(id: string, broadcast?: boolean, preferredNode?: SVGGElement): void;
28
+ selectionCompare(_: string[]): boolean;
29
+ selection(): string[];
30
+ selection(_: string[]): this;
31
+ selection(_: string[], broadcast: boolean): this;
32
+ setClass(className: string, ids?: string[]): this;
33
+ clearClass(className: string, ids?: string[]): this;
34
+ hasClass(className: string, id: string): boolean;
35
+ protected _selectionChanged(broadcast?: boolean): void;
36
+ enter(domNode: HTMLElement | SVGElement, element?: SVGGElement): this;
37
+ protected collectCustomVertices(): CustomVertex[];
38
+ protected postrenderCustomVertices(customVertices: CustomVertex[]): void;
39
+ itemBBox(scopeID: string): ReturnType<Widget["getRenderElementBBox"]>;
40
+ itemBBox(node: SVGGraphicsElement): ReturnType<Widget["getRenderElementBBox"]>;
41
+ zoomToItem(scopeID: string): this;
42
+ zoomToItem(node: SVGGraphicsElement): this;
43
+ update(domNode: HTMLElement | SVGElement, element: SVGGElement): this;
44
+ exit(domNode: HTMLElement | SVGElement, element?: SVGGElement): this;
45
+ protected _prevDot: string | undefined;
46
+ protected _prevLayout: GraphvizDotResponse | undefined;
47
+ render(callback?: (w: WidgetT) => void): this;
48
+ selectionChanged(): void;
49
+ vertexButtonClicked(id: string, action: string): void;
50
+ }
@@ -0,0 +1 @@
1
+ export * from "./Widget.ts";
package/types/index.d.ts CHANGED
@@ -8,3 +8,4 @@ export * from "./Vertex.ts";
8
8
  export * from "./common/index.ts";
9
9
  export * from "./html/index.ts";
10
10
  export * from "./react/index.ts";
11
+ export * as Graphviz from "./graphviz/index.ts";