@ocelescope/resources 0.1.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/index.d.ts +1 -6
  2. package/dist/index.js +1055 -1091
  3. package/package.json +13 -15
package/dist/index.js CHANGED
@@ -6,10 +6,10 @@ import FileSaver from "file-saver";
6
6
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
7
7
  import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
8
8
  import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
9
- import { Background, BaseEdge, ControlButton, Controls, EdgeLabelRenderer, Handle, Position, ReactFlow, ReactFlowProvider, applyNodeChanges, getBezierPath, getSmoothStepPath, useInternalNode, useNodes, useNodesInitialized, useReactFlow } from "@xyflow/react";
10
- import ELK from "elkjs/lib/elk.bundled.js";
9
+ import { Background, BaseEdge, ControlButton, Controls, EdgeLabelRenderer, Handle, Position, ReactFlow, ReactFlowProvider, applyNodeChanges, getBezierPath, getSmoothStepPath, useInternalNode, useReactFlow } from "@xyflow/react";
11
10
  import { toPng, toSvg } from "html-to-image";
12
11
  import { jsPDF } from "jspdf";
12
+ import ELK from "elkjs/lib/elk.bundled.js";
13
13
  import dynamic from "next/dynamic.js";
14
14
  import { DataTable } from "mantine-datatable";
15
15
  import dayjs from "dayjs";
@@ -70,7 +70,8 @@ const SvgViewer = ({ visualization, isPreview }) => {
70
70
  const cleanedSvg = visualization.svg.replace(/width="[^"]+"/, "width=\"100%\"").replace(/height="[^"]+"/, "height=\"100%\"").replace(/<svg(?![^>]*xmlns=)/, "<svg xmlns=\"http://www.w3.org/2000/svg\"");
71
71
  const handleDownload = () => {
72
72
  if (!cleanedSvg) return;
73
- saveAs$2(new Blob([cleanedSvg], { type: "image/svg+xml;charset=utf-8" }), "resource.svg");
73
+ const blob = new Blob([cleanedSvg], { type: "image/svg+xml;charset=utf-8" });
74
+ saveAs$2(blob, "resource.svg");
74
75
  };
75
76
  const content = /* @__PURE__ */ jsx("div", {
76
77
  style: {
@@ -116,7 +117,8 @@ const DotToSvgViewer = ({ isPreview, visualization }) => {
116
117
  }, [svg]);
117
118
  const handleDownload = () => {
118
119
  if (!cleanedSvg) return;
119
- saveAs$1(new Blob([cleanedSvg], { type: "image/svg+xml;charset=utf-8" }), "resource.svg");
120
+ const blob = new Blob([cleanedSvg], { type: "image/svg+xml;charset=utf-8" });
121
+ saveAs$1(blob, "resource.svg");
120
122
  };
121
123
  const content = /* @__PURE__ */ jsxs("div", {
122
124
  style: {
@@ -158,15 +160,283 @@ const DotToSvgViewer = ({ isPreview, visualization }) => {
158
160
  });
159
161
  };
160
162
  //#endregion
163
+ //#region src/components/ResourceView/Viewers/GraphFlow/GraphControls.tsx
164
+ const GraphControls = ({ onFitView, onDownloadPng, onDownloadSvg, onDownloadPdf, menuItems, isPreview }) => {
165
+ if (isPreview) return null;
166
+ return /* @__PURE__ */ jsxs(Controls, {
167
+ showInteractive: false,
168
+ showFitView: false,
169
+ children: [
170
+ /* @__PURE__ */ jsx(ControlButton, {
171
+ onClick: onFitView,
172
+ title: "fit view",
173
+ children: /* @__PURE__ */ jsx(Maximize2Icon, { size: 11 })
174
+ }),
175
+ /* @__PURE__ */ jsxs(Menu, {
176
+ position: "right",
177
+ withinPortal: false,
178
+ children: [/* @__PURE__ */ jsx(Menu.Target, { children: /* @__PURE__ */ jsx(ActionIcon, {
179
+ variant: "transparent",
180
+ children: /* @__PURE__ */ jsx(DownloadIcon, {
181
+ color: "black",
182
+ size: 18
183
+ })
184
+ }) }), /* @__PURE__ */ jsxs(Menu.Dropdown, { children: [
185
+ /* @__PURE__ */ jsx(Menu.Item, {
186
+ onClick: onDownloadPng,
187
+ children: "PNG"
188
+ }),
189
+ /* @__PURE__ */ jsx(Menu.Item, {
190
+ onClick: onDownloadSvg,
191
+ children: "SVG"
192
+ }),
193
+ /* @__PURE__ */ jsx(Menu.Item, {
194
+ onClick: onDownloadPdf,
195
+ children: "PDF"
196
+ })
197
+ ] })]
198
+ }),
199
+ menuItems?.map((menuItem) => /* @__PURE__ */ jsx(ControlButton, {
200
+ onClick: menuItem.onClick,
201
+ title: menuItem.label,
202
+ children: menuItem.icon
203
+ }, menuItem.label))
204
+ ]
205
+ });
206
+ };
207
+ //#endregion
208
+ //#region src/components/ResourceView/Viewers/GraphFlow/utils/bounds.ts
209
+ function nodeBoundsFromData(nodes) {
210
+ if (nodes.length === 0) return {
211
+ x: 0,
212
+ y: 0,
213
+ width: 0,
214
+ height: 0
215
+ };
216
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
217
+ for (const node of nodes) {
218
+ const x = node.position.x;
219
+ const y = node.position.y;
220
+ const w = node.measured?.width ?? 0;
221
+ const h = node.measured?.height ?? 0;
222
+ minX = Math.min(minX, x);
223
+ minY = Math.min(minY, y);
224
+ maxX = Math.max(maxX, x + w);
225
+ maxY = Math.max(maxY, y + h);
226
+ }
227
+ return Number.isFinite(minX) ? {
228
+ x: minX,
229
+ y: minY,
230
+ width: maxX - minX,
231
+ height: maxY - minY
232
+ } : {
233
+ x: 0,
234
+ y: 0,
235
+ width: 0,
236
+ height: 0
237
+ };
238
+ }
239
+ function svgPathBounds(d) {
240
+ const nums = (d.match(/-?[\d.]+(?:e[+-]?\d+)?/gi) ?? []).map(parseFloat);
241
+ if (nums.length < 2) return null;
242
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
243
+ for (let i = 0; i + 1 < nums.length; i += 2) {
244
+ minX = Math.min(minX, nums[i]);
245
+ minY = Math.min(minY, nums[i + 1]);
246
+ maxX = Math.max(maxX, nums[i]);
247
+ maxY = Math.max(maxY, nums[i + 1]);
248
+ }
249
+ return Number.isFinite(minX) ? {
250
+ x: minX,
251
+ y: minY,
252
+ width: maxX - minX,
253
+ height: maxY - minY
254
+ } : null;
255
+ }
256
+ /**
257
+ * Computes content bounds from graph data without touching the DOM.
258
+ * Uses node positions/sizes and pre-computed ELK edge paths.
259
+ * Safe to call immediately after layout with no timing concerns.
260
+ */
261
+ function computeAutoFitBounds(nodes, edges) {
262
+ let combined = nodeBoundsFromData(nodes);
263
+ for (const edge of edges) {
264
+ const path = edge.data?.layout?.path;
265
+ if (!path) continue;
266
+ const eb = svgPathBounds(path);
267
+ if (eb) combined = unionRects(combined, eb);
268
+ }
269
+ return combined;
270
+ }
271
+ function unionRects(a, b) {
272
+ const x = Math.min(a.x, b.x);
273
+ const y = Math.min(a.y, b.y);
274
+ const right = Math.max(a.x + a.width, b.x + b.width);
275
+ const bottom = Math.max(a.y + a.height, b.y + b.height);
276
+ return {
277
+ x,
278
+ y,
279
+ width: right - x,
280
+ height: bottom - y
281
+ };
282
+ }
283
+ /**
284
+ * Reads the viewport transform directly from the inline style React Flow writes:
285
+ * `transform: translate(Xpx, Ypx) scale(Z)`
286
+ */
287
+ function parseViewportTransform(viewportEl) {
288
+ const match = (viewportEl.style.transform ?? "").match(/translate\((-?[\d.]+)px,\s*(-?[\d.]+)px\)\s*scale\((-?[\d.]+)\)/);
289
+ if (!match) return null;
290
+ return {
291
+ x: parseFloat(match[1]),
292
+ y: parseFloat(match[2]),
293
+ zoom: parseFloat(match[3])
294
+ };
295
+ }
296
+ /**
297
+ * Returns the bounding rect (in flow coordinates) of all nodes plus all
298
+ * rendered edge paths. React Flow's built-in helpers only account for nodes;
299
+ * curved or long edges can extend well outside the node bounds, causing them
300
+ * to be clipped in exports and in fitView.
301
+ *
302
+ * In @xyflow/react v12 each edge is wrapped in its own <svg> (position:
303
+ * absolute), so getBBox() coordinates are not reliably in flow space. Instead
304
+ * we use getBoundingClientRect() on each .react-flow__edge-path element and
305
+ * convert from screen coords back to flow coords via the inverse of the
306
+ * viewport's CSS transform.
307
+ */
308
+ function getContentBounds(nodes, viewportEl, getNodesBounds) {
309
+ const nodeBounds = getNodesBounds(nodes);
310
+ const vt = parseViewportTransform(viewportEl);
311
+ if (!vt || vt.zoom === 0) return nodeBounds;
312
+ const containerRect = viewportEl.parentElement?.getBoundingClientRect();
313
+ if (!containerRect) return nodeBounds;
314
+ const edgePaths = viewportEl.querySelectorAll(".react-flow__edge-path");
315
+ if (edgePaths.length === 0) return nodeBounds;
316
+ let combined = nodeBounds;
317
+ for (const path of Array.from(edgePaths)) {
318
+ const rect = path.getBoundingClientRect();
319
+ if (rect.width === 0 && rect.height === 0) continue;
320
+ const flowX = (rect.left - containerRect.left - vt.x) / vt.zoom;
321
+ const flowY = (rect.top - containerRect.top - vt.y) / vt.zoom;
322
+ const flowRight = (rect.right - containerRect.left - vt.x) / vt.zoom;
323
+ const flowBottom = (rect.bottom - containerRect.top - vt.y) / vt.zoom;
324
+ combined = unionRects(combined, {
325
+ x: flowX,
326
+ y: flowY,
327
+ width: flowRight - flowX,
328
+ height: flowBottom - flowY
329
+ });
330
+ }
331
+ return combined;
332
+ }
333
+ //#endregion
334
+ //#region src/components/ResourceView/Viewers/GraphFlow/utils/download.ts
335
+ const { saveAs } = FileSaver;
336
+ const MAX_PX = 2048;
337
+ const EXPORT_PADDING = .05;
338
+ function buildCaptureOptions(imageWidth, imageHeight, x, y, zoom) {
339
+ return {
340
+ backgroundColor: "#ffffff",
341
+ width: imageWidth,
342
+ height: imageHeight,
343
+ skipFonts: true,
344
+ style: {
345
+ width: `${imageWidth}px`,
346
+ height: `${imageHeight}px`,
347
+ transform: `translate(${x}px, ${y}px) scale(${zoom})`
348
+ },
349
+ filter: (node) => !(node instanceof Element) || !node.classList.contains("react-flow__panel") && !node.classList.contains("react-flow__background")
350
+ };
351
+ }
352
+ function resolveViewport(container, nodes, getNodesBounds) {
353
+ if (nodes.length === 0) return null;
354
+ const viewportEl = container.querySelector(".react-flow__viewport");
355
+ if (!viewportEl) return null;
356
+ const bounds = getContentBounds(nodes, viewportEl, getNodesBounds);
357
+ const padX = bounds.width * EXPORT_PADDING;
358
+ const padY = bounds.height * EXPORT_PADDING;
359
+ const paddedWidth = bounds.width + 2 * padX;
360
+ const paddedHeight = bounds.height + 2 * padY;
361
+ const zoom = Math.min(MAX_PX / paddedWidth, MAX_PX / paddedHeight);
362
+ return {
363
+ viewportEl,
364
+ imageWidth: Math.round(paddedWidth * zoom),
365
+ imageHeight: Math.round(paddedHeight * zoom),
366
+ x: (padX - bounds.x) * zoom,
367
+ y: (padY - bounds.y) * zoom,
368
+ zoom
369
+ };
370
+ }
371
+ async function downloadPng(container, nodes, getNodesBounds) {
372
+ const capture = resolveViewport(container, nodes, getNodesBounds);
373
+ if (!capture) return;
374
+ const { viewportEl, imageWidth, imageHeight, x, y, zoom } = capture;
375
+ const dataUrl = await toPng(viewportEl, buildCaptureOptions(imageWidth, imageHeight, x, y, zoom));
376
+ saveAs(dataUrl, "graph.png");
377
+ }
378
+ async function downloadSvg(container, nodes, getNodesBounds) {
379
+ const capture = resolveViewport(container, nodes, getNodesBounds);
380
+ if (!capture) return;
381
+ const { viewportEl, imageWidth, imageHeight, x, y, zoom } = capture;
382
+ const dataUrl = await toSvg(viewportEl, buildCaptureOptions(imageWidth, imageHeight, x, y, zoom));
383
+ saveAs(dataUrl, "graph.svg");
384
+ }
385
+ async function downloadPdf(container, nodes, getNodesBounds) {
386
+ const capture = resolveViewport(container, nodes, getNodesBounds);
387
+ if (!capture) return;
388
+ const { viewportEl, imageWidth, imageHeight, x, y, zoom } = capture;
389
+ const dataUrl = await toPng(viewportEl, buildCaptureOptions(imageWidth, imageHeight, x, y, zoom));
390
+ const pdf = new jsPDF({
391
+ orientation: imageWidth >= imageHeight ? "landscape" : "portrait",
392
+ unit: "px",
393
+ format: [imageWidth, imageHeight]
394
+ });
395
+ pdf.addImage(dataUrl, "PNG", 0, 0, imageWidth, imageHeight);
396
+ pdf.save("graph.pdf");
397
+ }
398
+ //#endregion
399
+ //#region src/components/ResourceView/Viewers/GraphFlow/hooks/useGraphExport.ts
400
+ const useGraphExport = (wrapperRef) => {
401
+ const { getNodes, getNodesBounds } = useReactFlow();
402
+ return {
403
+ downloadPng: useCallback(() => {
404
+ if (!wrapperRef.current) return;
405
+ downloadPng(wrapperRef.current, getNodes(), getNodesBounds);
406
+ }, [
407
+ wrapperRef,
408
+ getNodes,
409
+ getNodesBounds
410
+ ]),
411
+ downloadSvg: useCallback(() => {
412
+ if (!wrapperRef.current) return;
413
+ downloadSvg(wrapperRef.current, getNodes(), getNodesBounds);
414
+ }, [
415
+ wrapperRef,
416
+ getNodes,
417
+ getNodesBounds
418
+ ]),
419
+ downloadPdf: useCallback(() => {
420
+ if (!wrapperRef.current) return;
421
+ downloadPdf(wrapperRef.current, getNodes(), getNodesBounds);
422
+ }, [
423
+ wrapperRef,
424
+ getNodes,
425
+ getNodesBounds
426
+ ])
427
+ };
428
+ };
429
+ //#endregion
161
430
  //#region src/components/ResourceView/Viewers/GraphFlow/model/types.ts
162
- var GraphVisualizationError = class extends Error {
431
+ var GraphError = class extends Error {
163
432
  details;
164
433
  constructor(message, details) {
165
434
  super(message);
166
- this.name = "GraphVisualizationError";
435
+ this.name = "GraphError";
167
436
  this.details = details;
168
437
  }
169
438
  };
439
+ const toGraphError = (error) => error instanceof GraphError ? error : new GraphError("Graph rendering failed.", [error instanceof Error ? error.message : String(error)]);
170
440
  const DEFAULT_COLORS = {
171
441
  edge: "#555555",
172
442
  place: "#aec6e8",
@@ -176,7 +446,7 @@ const DEFAULT_COLORS = {
176
446
  };
177
447
  const FIT_VIEW_PADDING = .15;
178
448
  //#endregion
179
- //#region src/components/ResourceView/Viewers/GraphFlow/model/buildModel.ts
449
+ //#region src/components/ResourceView/Viewers/GraphFlow/layout/elk.ts
180
450
  const EDGE_ROUTINGS = /* @__PURE__ */ new Set([
181
451
  "SPLINES",
182
452
  "ORTHOGONAL",
@@ -184,252 +454,151 @@ const EDGE_ROUTINGS = /* @__PURE__ */ new Set([
184
454
  ]);
185
455
  const EDGE_ROUTING_KEYS = ["elk.edgeRouting", "org.eclipse.elk.edgeRouting"];
186
456
  const normalizeEdgeRouting = (value) => {
187
- if (typeof value !== "string") throw new GraphVisualizationError("Invalid ELK edge routing.", [`Expected one of ${Array.from(EDGE_ROUTINGS).join(", ")}, got ${String(value)}.`]);
457
+ if (typeof value !== "string") throw new GraphError("Invalid ELK edge routing.", [`Expected one of ${[...EDGE_ROUTINGS].join(", ")}, got ${String(value)}.`]);
188
458
  const normalized = value.toUpperCase();
189
- if (!EDGE_ROUTINGS.has(normalized)) throw new GraphVisualizationError("Invalid ELK edge routing.", [`Unknown edge routing "${value}". Supported values are ${Array.from(EDGE_ROUTINGS).join(", ")}.`]);
459
+ if (!EDGE_ROUTINGS.has(normalized)) throw new GraphError("Invalid ELK edge routing.", [`Unknown edge routing "${value}". Supported values are ${[...EDGE_ROUTINGS].join(", ")}.`]);
190
460
  return normalized;
191
461
  };
192
- const normalizeElkOptions = (elkOptions) => {
193
- const edgeRouting = EDGE_ROUTING_KEYS.flatMap((key) => key in elkOptions ? [normalizeEdgeRouting(elkOptions[key])] : [])[0] ?? "POLYLINE";
194
- const normalizedOptions = { ...elkOptions };
195
- for (const key of EDGE_ROUTING_KEYS) if (key in normalizedOptions) normalizedOptions[key] = edgeRouting;
462
+ const resolveGraphRouting = (options) => {
463
+ const routing = EDGE_ROUTING_KEYS.flatMap((key) => key in options ? [normalizeEdgeRouting(options[key])] : [])[0] ?? "POLYLINE";
464
+ const normalized = { ...options };
465
+ for (const key of EDGE_ROUTING_KEYS) if (key in normalized) normalized[key] = routing;
196
466
  return {
197
- edgeRouting,
198
- elkOptions: normalizedOptions
467
+ routing,
468
+ options: normalized
199
469
  };
200
470
  };
201
- const mapNode = (node) => ({
202
- id: node.id,
203
- type: "node",
204
- position: {
205
- x: node.x ?? 0,
206
- y: node.y ?? 0
207
- },
208
- data: node
209
- });
210
- const mapEdge = (edge) => ({
211
- id: edge.id,
212
- source: edge.source,
213
- target: edge.target,
214
- type: "graphflow",
215
- data: edge
216
- });
217
- const createLayoutPlan = (visualization) => {
218
- const layoutConfig = visualization.layout_config;
219
- switch (layoutConfig?.type) {
220
- case "graphviz":
221
- if (!layoutConfig.engine) throw new GraphVisualizationError("Invalid Graphviz layout config.", ["Missing Graphviz layout engine."]);
222
- return {
223
- type: "graphviz",
224
- engine: layoutConfig.engine,
225
- graphAttrs: layoutConfig.graphAttrs ?? {},
226
- nodeAttrs: layoutConfig.nodeAttrs ?? {},
227
- edgeAttrs: layoutConfig.edgeAttrs ?? {}
228
- };
229
- case "elk": {
230
- const normalized = normalizeElkOptions(layoutConfig.options ?? {});
231
- return {
232
- type: "elk",
233
- elkOptions: normalized.elkOptions,
234
- edgeRouting: normalized.edgeRouting
235
- };
236
- }
237
- default: throw new GraphVisualizationError("Invalid graph layout config.", ["Expected an ELK or Graphviz layout config from the backend."]);
238
- }
239
- };
240
- const buildGraphFlowModel = (visualization) => ({
241
- nodes: (visualization.nodes ?? []).map(mapNode),
242
- edges: (visualization.edges ?? []).map(mapEdge),
243
- layoutPlan: createLayoutPlan(visualization)
244
- });
245
- //#endregion
246
- //#region src/components/ResourceView/Viewers/GraphFlow/model/runLayout.ts
247
- const toGraphError = (error) => {
248
- if (error instanceof GraphVisualizationError) return error;
249
- return new GraphVisualizationError("Graph layout failed.", [error instanceof Error ? error.message : String(error)]);
250
- };
251
- const measuredNodesMatchModel = (measuredNodes, model) => {
252
- if (measuredNodes.length !== model.nodes.length) return false;
253
- const measuredNodeIds = new Set(measuredNodes.map((node) => node.id));
254
- return model.nodes.every((node) => measuredNodeIds.has(node.id));
471
+ const sectionPoints = (section) => {
472
+ if (!section.startPoint || !section.endPoint) return [];
473
+ return [
474
+ section.startPoint,
475
+ ...section.bendPoints ?? [],
476
+ section.endPoint
477
+ ];
255
478
  };
256
- const applyNodePositions = (nodes, positions) => nodes.map((node) => {
257
- const position = positions[node.id];
258
- return position ? {
259
- ...node,
260
- position
261
- } : node;
262
- });
263
- const applyEdgeLayouts = (edges, edgeLayouts) => edges.map((edge) => {
264
- const layout = edgeLayouts[edge.id];
265
- if (!layout) return edge;
266
- return {
267
- ...edge,
268
- data: {
269
- ...edge.data,
270
- path: layout.path,
271
- labelPosition: layout.labelPosition,
272
- startPoint: layout.startPoint,
273
- endPoint: layout.endPoint
274
- }
275
- };
276
- });
277
- //#endregion
278
- //#region src/components/ResourceView/Viewers/GraphFlow/hooks/useElkLayout.ts
279
- const buildPolylinePath$1 = (points) => {
479
+ const isSplineControlList = (points) => points.length >= 4 && (points.length - 1) % 3 === 0;
480
+ const routeSection = (section, routing) => {
481
+ const points = sectionPoints(section);
280
482
  if (points.length < 2) return null;
281
- return points.map((point, index) => `${index === 0 ? "M" : "L"} ${point.x},${point.y}`).join(" ");
282
- };
283
- const buildSplinePath$1 = (section) => {
284
- const start = section.startPoint;
285
- const end = section.endPoint;
286
- if (!start || !end) return null;
287
- const bendPoints = section.bendPoints ?? [];
288
- if (bendPoints.length === 0) return `M ${start.x},${start.y} L ${end.x},${end.y}`;
289
- let path = `M ${start.x},${start.y}`;
290
- for (let index = 0; index < bendPoints.length; index += 3) {
291
- const cp1 = bendPoints[index];
292
- const cp2 = bendPoints[index + 1];
293
- if (!cp1 || !cp2) return null;
294
- const anchor = bendPoints[index + 2] ?? end;
295
- path += ` C ${cp1.x},${cp1.y} ${cp2.x},${cp2.y} ${anchor.x},${anchor.y}`;
483
+ if (routing === "SPLINES" && section.startPoint) {
484
+ const control = [section.startPoint, ...section.bendPoints ?? []];
485
+ if ((control.length - 1) % 3 === 2 && section.endPoint) control.push(section.endPoint);
486
+ if (isSplineControlList(control)) return {
487
+ kind: "spline",
488
+ points: control,
489
+ startAnchor: null,
490
+ endAnchor: null
491
+ };
296
492
  }
297
- return path;
298
- };
299
- const buildEdgePath = (section, routing) => {
300
- if (routing === "SPLINES") return buildSplinePath$1(section);
301
- const start = section.startPoint;
302
- const end = section.endPoint;
303
- if (!start || !end) return null;
304
- return buildPolylinePath$1([
305
- start,
306
- ...section.bendPoints ?? [],
307
- end
308
- ]);
493
+ return {
494
+ kind: "polyline",
495
+ points,
496
+ startAnchor: null,
497
+ endAnchor: null
498
+ };
309
499
  };
310
500
  const sectionMidpoint = (section) => {
311
- if (!section.startPoint || !section.endPoint) return null;
312
- const points = [
313
- section.startPoint,
314
- ...section.bendPoints ?? [],
315
- section.endPoint
316
- ];
501
+ const points = sectionPoints(section);
317
502
  const mid = points[Math.floor(points.length / 2)];
318
503
  return mid ? {
319
504
  x: mid.x,
320
505
  y: mid.y
321
506
  } : null;
322
507
  };
323
- const getEdgeLabelPosition = (label, section) => {
508
+ const labelPosition = (edge, section) => {
509
+ const label = edge.labels?.[0];
324
510
  if (label?.x != null && label?.y != null) return {
325
511
  x: label.x + (label.width ?? 0) / 2,
326
512
  y: label.y + (label.height ?? 0) / 2
327
513
  };
328
514
  return sectionMidpoint(section);
329
515
  };
330
- const elk = new ELK();
331
- const getEdgeRouting = (edge, fallback) => {
516
+ const edgeRoutingOf = (edge, fallback) => {
332
517
  const options = edge.layoutOptions ?? {};
333
- const edgeRouting = options["elk.edgeRouting"] ?? options["org.eclipse.elk.edgeRouting"];
334
- return edgeRouting === void 0 ? fallback : normalizeEdgeRouting(edgeRouting);
518
+ const routing = options["elk.edgeRouting"] ?? options["org.eclipse.elk.edgeRouting"];
519
+ return routing === void 0 ? fallback : normalizeEdgeRouting(routing);
335
520
  };
336
- const toPositionMap = (children = []) => children.reduce((acc, child) => {
337
- if (child.id && child.x != null && child.y != null) acc[child.id] = {
338
- x: child.x,
339
- y: child.y
340
- };
341
- return acc;
342
- }, {});
343
- const toEdgeLayoutMap = (edges = [], graphRouting) => edges.reduce((acc, edge) => {
344
- const section = edge.sections?.[0];
345
- if (!edge.id || !section) return acc;
346
- const path = buildEdgePath(section, getEdgeRouting(edge, graphRouting));
347
- if (!path) return acc;
348
- acc[edge.id] = {
349
- path,
350
- labelPosition: getEdgeLabelPosition(edge.labels?.[0], section),
351
- startPoint: section.startPoint ? {
352
- x: section.startPoint.x,
353
- y: section.startPoint.y
354
- } : null,
355
- endPoint: section.endPoint ? {
356
- x: section.endPoint.x,
357
- y: section.endPoint.y
358
- } : null
359
- };
360
- return acc;
361
- }, {});
362
- const compareById = (left, right) => left.id.localeCompare(right.id);
363
- const compareEdgesForLayout = (left, right) => left.source.localeCompare(right.source) || left.target.localeCompare(right.target) || left.id.localeCompare(right.id);
364
- const runElk = async ({ nodes, edges, layoutPlan }) => {
365
- const mergedLayoutOptions = nodes.some((node) => typeof node.data.rank === "number") ? {
366
- "elk.partitioning.activate": true,
367
- ...layoutPlan.elkOptions
368
- } : layoutPlan.elkOptions;
369
- const layoutNodes = [...nodes].sort(compareById);
370
- const layoutEdges = [...edges].sort(compareEdgesForLayout);
371
- const graph = await elk.layout({
372
- id: "root",
373
- layoutOptions: mergedLayoutOptions,
374
- children: layoutNodes.map((node) => {
375
- const nodeData = node.data;
376
- const rank = nodeData.rank ?? null;
377
- const layoutOptions = { ...nodeData.layout_attrs ?? {} };
378
- if (rank === "source") layoutOptions["elk.layered.layering.layerConstraint"] = "FIRST";
379
- else if (rank === "sink") layoutOptions["elk.layered.layering.layerConstraint"] = "LAST";
380
- else if (typeof rank === "number") layoutOptions["elk.partitioning.partition"] = String(rank);
381
- return {
382
- id: node.id,
383
- width: nodeData.width,
384
- height: nodeData.height,
385
- layoutOptions
386
- };
387
- }),
388
- edges: layoutEdges.map((edge) => {
389
- const edgeData = edge.data;
390
- const edgeLabel = edgeData?.label;
391
- return {
521
+ const byId = (a, b) => a.id.localeCompare(b.id);
522
+ const byEndpoints = (a, b) => a.source.localeCompare(b.source) || a.target.localeCompare(b.target) || a.id.localeCompare(b.id);
523
+ const nodeSize = (node) => ({
524
+ width: node.width ?? 120,
525
+ height: node.height ?? 40
526
+ });
527
+ var ElkEngine = class ElkEngine {
528
+ config;
529
+ static elk = new ELK();
530
+ constructor(config) {
531
+ this.config = config;
532
+ }
533
+ async run(model) {
534
+ const { routing, options } = resolveGraphRouting(this.config.options);
535
+ const layoutOptions = model.nodes.some((node) => typeof node.rank === "number") ? {
536
+ "elk.partitioning.activate": true,
537
+ ...options
538
+ } : options;
539
+ const graph = await ElkEngine.elk.layout({
540
+ id: "root",
541
+ layoutOptions,
542
+ children: [...model.nodes].sort(byId).map((node) => {
543
+ const { width, height } = nodeSize(node);
544
+ return {
545
+ id: node.id,
546
+ width,
547
+ height,
548
+ layoutOptions: this.nodeLayoutOptions(node)
549
+ };
550
+ }),
551
+ edges: [...model.edges].sort(byEndpoints).map((edge) => ({
392
552
  id: edge.id,
393
553
  sources: [edge.source],
394
554
  targets: [edge.target],
395
- layoutOptions: edgeData?.layout_attrs ?? {},
396
- ...edgeLabel ? { labels: [{
397
- text: String(edgeLabel),
398
- width: String(edgeLabel).length * 7 + 12,
555
+ layoutOptions: edge.layout_attrs ?? {},
556
+ ...edge.label ? { labels: [{
557
+ text: String(edge.label),
558
+ width: String(edge.label).length * 7 + 12,
399
559
  height: 18
400
560
  }] } : {}
401
- };
402
- })
403
- });
404
- return {
405
- positions: toPositionMap(graph.children),
406
- edgeLayouts: toEdgeLayoutMap(graph.edges, layoutPlan.edgeRouting)
407
- };
408
- };
409
- const useElkLayout = () => {
410
- const measuredNodes = useNodes();
411
- const nodesInitialized = useNodesInitialized();
412
- return useCallback(async (model) => {
413
- if (!nodesInitialized || !measuredNodesMatchModel(measuredNodes, model)) return null;
414
- const layout = await runElk({
415
- nodes: measuredNodes,
416
- edges: model.edges,
417
- layoutPlan: model.layoutPlan
561
+ }))
418
562
  });
419
563
  return {
420
- nodes: applyNodePositions(measuredNodes, layout.positions),
421
- edges: applyEdgeLayouts(model.edges, layout.edgeLayouts)
564
+ positions: this.readPositions(graph.children),
565
+ routes: this.readRoutes(graph.edges, routing)
566
+ };
567
+ }
568
+ nodeLayoutOptions(node) {
569
+ const options = { ...node.layout_attrs ?? {} };
570
+ const rank = node.rank ?? null;
571
+ if (rank === "source") options["elk.layered.layering.layerConstraint"] = "FIRST";
572
+ else if (rank === "sink") options["elk.layered.layering.layerConstraint"] = "LAST";
573
+ else if (typeof rank === "number") options["elk.partitioning.partition"] = String(rank);
574
+ return options;
575
+ }
576
+ readPositions(children = []) {
577
+ const positions = {};
578
+ for (const child of children) if (child.id && child.x != null && child.y != null) positions[child.id] = {
579
+ x: child.x,
580
+ y: child.y
422
581
  };
423
- }, [nodesInitialized, measuredNodes]);
582
+ return positions;
583
+ }
584
+ readRoutes(edges = [], graphRouting) {
585
+ const routes = {};
586
+ for (const edge of edges) {
587
+ const section = edge.sections?.[0];
588
+ if (!edge.id || !section) continue;
589
+ const routed = routeSection(section, edgeRoutingOf(edge, graphRouting));
590
+ if (!routed) continue;
591
+ routes[edge.id] = {
592
+ ...routed,
593
+ labelPosition: labelPosition(edge, section)
594
+ };
595
+ }
596
+ return routes;
597
+ }
424
598
  };
425
599
  //#endregion
426
- //#region src/components/ResourceView/Viewers/GraphFlow/hooks/useGraphvizLayout.ts
600
+ //#region src/components/ResourceView/Viewers/GraphFlow/layout/graphviz.ts
427
601
  const GRAPHVIZ_DPI = 72;
428
- const quoteDotValue = (value) => `"${value.replaceAll("\"", "\\\"")}"`;
429
- const attrsToDot = (attrs) => {
430
- if (!attrs) return "";
431
- return Object.entries(attrs).filter(([, value]) => value != null).map(([key, value]) => typeof value === "string" ? `${key}=${quoteDotValue(value)}` : `${key}=${String(value)}`).join(",");
432
- };
433
602
  const parsePoint = (value) => {
434
603
  if (!value) return null;
435
604
  const [xRaw, yRaw] = value.split(",");
@@ -441,38 +610,200 @@ const parsePoint = (value) => {
441
610
  } : null;
442
611
  };
443
612
  const parseBoundingBoxHeight = (bb) => {
444
- const height = (bb?.split(",").map((value) => Number.parseFloat(value)))?.[3];
613
+ const height = bb?.split(",").map((v) => Number.parseFloat(v))?.[3];
445
614
  return Number.isFinite(height) ? height : 0;
446
615
  };
447
- const parseGraphvizDimension = (value) => {
616
+ const parseDimension = (value) => {
448
617
  if (value == null) return null;
449
618
  const dimension = Number.parseFloat(String(value)) * GRAPHVIZ_DPI;
450
619
  return Number.isFinite(dimension) ? dimension : null;
451
620
  };
452
- const parseGraphvizEdgePosition = (pos) => {
621
+ const flipY = (point, graphHeight) => ({
622
+ x: point.x,
623
+ y: graphHeight - point.y
624
+ });
625
+ const parseEdgePosition = (pos) => {
453
626
  const result = {
454
627
  points: [],
455
- startArrowPoint: null,
456
- endArrowPoint: null
628
+ startArrow: null,
629
+ endArrow: null
457
630
  };
458
631
  if (!pos) return result;
459
- const pointMatches = pos.matchAll(/(?:^|\s)(?:(s|e),)?(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)/g);
460
- for (const match of pointMatches) {
632
+ const matches = pos.matchAll(/(?:^|\s)(?:(s|e),)?(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)/g);
633
+ for (const match of matches) {
461
634
  const point = {
462
635
  x: Number.parseFloat(match[2] ?? "0"),
463
636
  y: Number.parseFloat(match[3] ?? "0")
464
637
  };
465
638
  if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) continue;
466
- if (match[1] === "s") result.startArrowPoint = point;
467
- else if (match[1] === "e") result.endArrowPoint = point;
639
+ if (match[1] === "s") result.startArrow = point;
640
+ else if (match[1] === "e") result.endArrow = point;
468
641
  else result.points.push(point);
469
642
  }
470
643
  return result;
471
644
  };
472
- const flipY = (point, graphHeight) => ({
473
- x: point.x,
474
- y: graphHeight - point.y
645
+ const edgeEndpointKey = (edge, gvidToName) => {
646
+ const tail = typeof edge.tail === "number" ? gvidToName.get(edge.tail) : edge.tail;
647
+ const head = typeof edge.head === "number" ? gvidToName.get(edge.head) : edge.head;
648
+ return tail && head ? `${tail}->${head}` : null;
649
+ };
650
+ const quote = (value) => `"${value.replaceAll("\"", "\\\"")}"`;
651
+ const attrsToDot = (attrs) => attrs ? Object.entries(attrs).filter(([, value]) => value != null).map(([key, value]) => typeof value === "string" ? `${key}=${quote(value)}` : `${key}=${String(value)}`).join(",") : "";
652
+ const dotShape = (shape) => {
653
+ switch (shape) {
654
+ case "circle":
655
+ case "diamond":
656
+ case "hexagon":
657
+ case "triangle": return shape;
658
+ default: return "box";
659
+ }
660
+ };
661
+ var GraphvizEngine = class GraphvizEngine {
662
+ config;
663
+ static instance = null;
664
+ constructor(config) {
665
+ this.config = config;
666
+ }
667
+ static load() {
668
+ GraphvizEngine.instance ??= Graphviz.load();
669
+ return GraphvizEngine.instance;
670
+ }
671
+ async run(model) {
672
+ const graphviz = await GraphvizEngine.load();
673
+ const json = JSON.parse(graphviz.layout(this.toDot(model), "json", this.config.engine));
674
+ const graphHeight = parseBoundingBoxHeight(json.bb);
675
+ const gvidToName = /* @__PURE__ */ new Map();
676
+ const positions = {};
677
+ for (const object of json.objects ?? []) {
678
+ if (object._gvid != null && object.name) gvidToName.set(object._gvid, object.name);
679
+ if (!object.name) continue;
680
+ const center = parsePoint(object.pos);
681
+ if (!center) continue;
682
+ const node = model.nodes.find((candidate) => candidate.id === object.name);
683
+ const width = node?.width ?? parseDimension(object.width) ?? 0;
684
+ const height = node?.height ?? parseDimension(object.height) ?? 0;
685
+ const flipped = flipY(center, graphHeight);
686
+ positions[object.name] = {
687
+ x: flipped.x - width / 2,
688
+ y: flipped.y - height / 2
689
+ };
690
+ }
691
+ return {
692
+ positions,
693
+ routes: this.readRoutes(json.edges ?? [], graphHeight, model.edges, gvidToName)
694
+ };
695
+ }
696
+ readRoutes(graphvizEdges, graphHeight, modelEdges, gvidToName) {
697
+ const idsByKey = modelEdges.reduce((acc, edge) => {
698
+ const key = `${edge.source}->${edge.target}`;
699
+ acc[key] ??= [];
700
+ acc[key].push(edge.id);
701
+ return acc;
702
+ }, {});
703
+ const consumed = /* @__PURE__ */ new Map();
704
+ const routes = {};
705
+ for (const edge of graphvizEdges) {
706
+ const key = edgeEndpointKey(edge, gvidToName);
707
+ const index = key ? consumed.get(key) ?? 0 : 0;
708
+ const id = edge.id ?? (key ? idsByKey[key]?.[index] : void 0);
709
+ if (!id) continue;
710
+ if (key) consumed.set(key, index + 1);
711
+ const position = parseEdgePosition(edge.pos);
712
+ const points = position.points.map((point) => flipY(point, graphHeight));
713
+ const label = parsePoint(edge.lp);
714
+ routes[id] = {
715
+ kind: "spline",
716
+ points,
717
+ startAnchor: position.startArrow ? flipY(position.startArrow, graphHeight) : null,
718
+ endAnchor: position.endArrow ? flipY(position.endArrow, graphHeight) : null,
719
+ labelPosition: label ? flipY(label, graphHeight) : null
720
+ };
721
+ }
722
+ return routes;
723
+ }
724
+ toDot(model) {
725
+ const { graphAttrs, nodeAttrs, edgeAttrs } = this.config;
726
+ const lines = [
727
+ "digraph G {",
728
+ `graph [${attrsToDot(graphAttrs)}];`,
729
+ `node [${attrsToDot(nodeAttrs)}];`,
730
+ `edge [${attrsToDot(edgeAttrs)}];`
731
+ ];
732
+ for (const node of model.nodes) {
733
+ const attrs = {
734
+ ...node.layout_attrs,
735
+ label: node.label ?? "",
736
+ shape: dotShape(node.shape),
737
+ color: node.border_color ?? node.color ?? void 0,
738
+ fillcolor: node.color ?? void 0,
739
+ style: node.color ? "filled" : void 0,
740
+ width: node.width ? (node.width / GRAPHVIZ_DPI).toFixed(4) : void 0,
741
+ height: node.height ? (node.height / GRAPHVIZ_DPI).toFixed(4) : void 0,
742
+ fixedsize: node.width && node.height ? true : void 0
743
+ };
744
+ lines.push(`${quote(node.id)} [${attrsToDot(attrs)}];`);
745
+ }
746
+ for (const edge of model.edges) {
747
+ const attrs = {
748
+ ...edge.layout_attrs,
749
+ id: edge.id,
750
+ label: edge.label ?? void 0,
751
+ color: edge.color ?? void 0
752
+ };
753
+ lines.push(`${quote(edge.source)} -> ${quote(edge.target)} [${attrsToDot(attrs)}];`);
754
+ }
755
+ lines.push(...this.rankConstraints(model.nodes), "}");
756
+ return lines.join("\n");
757
+ }
758
+ rankConstraints(nodes) {
759
+ const ranks = nodes.filter((node) => node.rank != null).reduce((acc, node) => {
760
+ const key = node.rank;
761
+ acc[key] ??= [];
762
+ acc[key].push(quote(node.id));
763
+ return acc;
764
+ }, {});
765
+ return Object.entries(ranks).map(([rank, ids]) => `{ rank=${["source", "sink"].includes(rank) ? rank : "same"}; ${(ids ?? []).join(" ")} }`);
766
+ }
767
+ };
768
+ //#endregion
769
+ //#region src/components/ResourceView/Viewers/GraphFlow/layout/engine.ts
770
+ const selectEngine = (visualization) => {
771
+ const config = visualization.layout_config;
772
+ switch (config?.type) {
773
+ case "graphviz":
774
+ if (!config.engine) throw new GraphError("Invalid Graphviz layout config.", ["Missing Graphviz layout engine."]);
775
+ return new GraphvizEngine({
776
+ engine: config.engine,
777
+ graphAttrs: config.graphAttrs ?? {},
778
+ nodeAttrs: config.nodeAttrs ?? {},
779
+ edgeAttrs: config.edgeAttrs ?? {}
780
+ });
781
+ case "elk": return new ElkEngine({ options: config.options ?? {} });
782
+ default: throw new GraphError("Invalid graph layout config.", ["Expected an ELK or Graphviz layout config from the backend."]);
783
+ }
784
+ };
785
+ //#endregion
786
+ //#region src/components/ResourceView/Viewers/GraphFlow/model/buildModel.ts
787
+ const requireNodeId = (node, index) => {
788
+ if (!node.id) throw new GraphError("Invalid graph node.", [`Node at index ${index} is missing an id.`]);
789
+ return {
790
+ ...node,
791
+ id: node.id
792
+ };
793
+ };
794
+ const requireEdgeId = (edge, index) => {
795
+ if (!edge.id) throw new GraphError("Invalid graph edge.", [`Edge at index ${index} is missing an id.`]);
796
+ return {
797
+ ...edge,
798
+ id: edge.id
799
+ };
800
+ };
801
+ const buildGraphModel = (visualization) => ({
802
+ nodes: (visualization.nodes ?? []).map(requireNodeId),
803
+ edges: (visualization.edges ?? []).map(requireEdgeId)
475
804
  });
805
+ //#endregion
806
+ //#region src/components/ResourceView/Viewers/GraphFlow/render/routeToPath.ts
476
807
  const buildPolylinePath = (points) => {
477
808
  if (points.length < 2) return null;
478
809
  return points.map((point, index) => `${index === 0 ? "M" : "L"} ${point.x},${point.y}`).join(" ");
@@ -481,643 +812,168 @@ const buildSplinePath = (points) => {
481
812
  if (points.length < 4 || (points.length - 1) % 3 !== 0) return null;
482
813
  let path = `M ${points[0]?.x},${points[0]?.y}`;
483
814
  for (let index = 1; index < points.length; index += 3) {
484
- const cp1 = points[index];
485
- const cp2 = points[index + 1];
486
- const end = points[index + 2];
487
- if (!cp1 || !cp2 || !end) return null;
488
- path += ` C ${cp1.x},${cp1.y} ${cp2.x},${cp2.y} ${end.x},${end.y}`;
815
+ const c1 = points[index];
816
+ const c2 = points[index + 1];
817
+ const anchor = points[index + 2];
818
+ if (!c1 || !c2 || !anchor) return null;
819
+ path += ` C ${c1.x},${c1.y} ${c2.x},${c2.y} ${anchor.x},${anchor.y}`;
489
820
  }
490
821
  return path;
491
822
  };
492
- const buildGraphvizEdgePath = ({ points, startArrowPoint, endArrowPoint }) => {
493
- const splinePath = buildSplinePath(points);
494
- if (splinePath) return [
495
- startArrowPoint && points[0] ? `M ${startArrowPoint.x},${startArrowPoint.y} L ${points[0].x},${points[0].y}` : null,
496
- startArrowPoint ? splinePath.replace(/^M [^C]+/, "") : splinePath,
497
- endArrowPoint ? `L ${endArrowPoint.x},${endArrowPoint.y}` : null
498
- ].filter(Boolean).join(" ");
823
+ const stitchAnchors = (body, points, startAnchor, endAnchor) => [
824
+ startAnchor && points[0] ? `M ${startAnchor.x},${startAnchor.y} L ${points[0].x},${points[0].y}` : null,
825
+ startAnchor ? body.replace(/^M [^C]+/, "") : body,
826
+ endAnchor ? `L ${endAnchor.x},${endAnchor.y}` : null
827
+ ].filter(Boolean).join(" ");
828
+ const routeToPath = (route) => {
829
+ const { kind, points, startAnchor, endAnchor } = route;
830
+ if (kind === "spline") {
831
+ const spline = buildSplinePath(points);
832
+ if (spline) return stitchAnchors(spline, points, startAnchor, endAnchor);
833
+ }
499
834
  return buildPolylinePath([
500
- ...startArrowPoint ? [startArrowPoint] : [],
835
+ ...startAnchor ? [startAnchor] : [],
501
836
  ...points,
502
- ...endArrowPoint ? [endArrowPoint] : []
837
+ ...endAnchor ? [endAnchor] : []
503
838
  ]);
504
839
  };
505
- const graphvizShape = (shape) => {
506
- switch (shape) {
507
- case "circle": return "circle";
508
- case "diamond": return "diamond";
509
- case "hexagon": return "hexagon";
510
- case "triangle": return "triangle";
511
- default: return "box";
512
- }
513
- };
514
- const toDot = (visualization, layoutPlan) => {
515
- const lines = [
516
- "digraph G {",
517
- `graph [${attrsToDot(layoutPlan.graphAttrs)}];`,
518
- `node [${attrsToDot(layoutPlan.nodeAttrs)}];`,
519
- `edge [${attrsToDot(layoutPlan.edgeAttrs)}];`
520
- ];
521
- for (const node of visualization.nodes ?? []) {
522
- const attrs = {
523
- ...node.layout_attrs,
524
- label: node.label ?? "",
525
- shape: graphvizShape(node.shape),
526
- color: node.border_color ?? node.color ?? void 0,
527
- fillcolor: node.color ?? void 0,
528
- style: node.color ? "filled" : void 0,
529
- width: node.width ? (node.width / GRAPHVIZ_DPI).toFixed(4) : void 0,
530
- height: node.height ? (node.height / GRAPHVIZ_DPI).toFixed(4) : void 0,
531
- fixedsize: node.width && node.height ? true : void 0
532
- };
533
- lines.push(`${quoteDotValue(node.id)} [${attrsToDot(attrs)}];`);
534
- }
535
- for (const edge of visualization.edges ?? []) {
536
- const attrs = {
537
- ...edge.layout_attrs,
840
+ const midpoint = (a, b) => ({
841
+ x: (a.x + b.x) / 2,
842
+ y: (a.y + b.y) / 2
843
+ });
844
+ const isDegenerate = (a, b) => Math.hypot(a.x - b.x, a.y - b.y) < 1;
845
+ const straightEdgeLayout = (source, target) => ({
846
+ path: `M ${source.x},${source.y} L ${target.x},${target.y}`,
847
+ startPoint: source,
848
+ endPoint: target,
849
+ labelPosition: midpoint(source, target)
850
+ });
851
+ //#endregion
852
+ //#region src/components/ResourceView/Viewers/GraphFlow/render/project.ts
853
+ const projectToRenderModel = (model, layout) => {
854
+ const centers = nodeCenters(model.nodes, layout.positions);
855
+ return {
856
+ nodes: model.nodes.map((node) => ({
857
+ id: node.id,
858
+ type: "node",
859
+ position: layout.positions[node.id] ?? {
860
+ x: node.x ?? 0,
861
+ y: node.y ?? 0
862
+ },
863
+ data: { model: node }
864
+ })),
865
+ edges: model.edges.map((edge) => ({
538
866
  id: edge.id,
539
- label: edge.label ?? void 0,
540
- color: edge.color ?? void 0
867
+ source: edge.source,
868
+ target: edge.target,
869
+ type: "graphflow",
870
+ data: {
871
+ model: edge,
872
+ layout: finalizeEdge(layout.routes[edge.id], centers[edge.source], centers[edge.target])
873
+ }
874
+ }))
875
+ };
876
+ };
877
+ const sizeOf = (node) => ({
878
+ width: node.width ?? 120,
879
+ height: node.height ?? 40
880
+ });
881
+ const nodeCenters = (nodes, positions) => {
882
+ const centers = {};
883
+ for (const node of nodes) {
884
+ const position = positions[node.id];
885
+ if (!position) continue;
886
+ const { width, height } = sizeOf(node);
887
+ centers[node.id] = {
888
+ x: position.x + width / 2,
889
+ y: position.y + height / 2
541
890
  };
542
- lines.push(`${quoteDotValue(edge.source)} -> ${quoteDotValue(edge.target)} [${attrsToDot(attrs)}];`);
543
891
  }
544
- const ranks = (visualization.nodes ?? []).filter(({ rank }) => rank != null).reduce((acc, node) => {
545
- const key = node.rank;
546
- acc[key] ??= [];
547
- acc[key].push(quoteDotValue(node.id));
548
- return acc;
549
- }, {});
550
- lines.push(...Object.entries(ranks).map(([rank, ids]) => `{ rank=${["source", "sink"].includes(rank) ? rank : "same"}; ${(ids ?? []).join(" ")} }`));
551
- lines.push("}");
552
- return lines.join("\n");
553
- };
554
- const getGraphvizEdgeKey = (edge, gvidToName) => {
555
- const tail = typeof edge.tail === "number" ? gvidToName.get(edge.tail) : edge.tail;
556
- const head = typeof edge.head === "number" ? gvidToName.get(edge.head) : edge.head;
557
- return tail && head ? `${tail}->${head}` : null;
892
+ return centers;
558
893
  };
559
- const buildGraphvizEdgeLayouts = (graphvizEdges, graphHeight, edges, gvidToName) => {
560
- const fallbackCounts = /* @__PURE__ */ new Map();
561
- const fallbackIds = edges.reduce((acc, edge) => {
562
- const key = `${edge.source}->${edge.target}`;
563
- acc[key] ??= [];
564
- acc[key].push(edge.id);
565
- return acc;
566
- }, {});
567
- return graphvizEdges.reduce((acc, edge) => {
568
- const edgeKey = getGraphvizEdgeKey(edge, gvidToName);
569
- const fallbackIdsForKey = edgeKey ? fallbackIds[edgeKey] : void 0;
570
- const fallbackIndex = edgeKey ? fallbackCounts.get(edgeKey) ?? 0 : 0;
571
- const edgeId = edge.id ?? fallbackIdsForKey?.[fallbackIndex];
572
- if (!edgeId) return acc;
573
- if (edgeKey) fallbackCounts.set(edgeKey, fallbackIndex + 1);
574
- const position = parseGraphvizEdgePosition(edge.pos);
575
- const points = position.points.map((point) => flipY(point, graphHeight));
576
- const startArrowPoint = position.startArrowPoint ? flipY(position.startArrowPoint, graphHeight) : null;
577
- const endArrowPoint = position.endArrowPoint ? flipY(position.endArrowPoint, graphHeight) : null;
578
- const path = buildGraphvizEdgePath({
579
- points,
580
- startArrowPoint,
581
- endArrowPoint
582
- });
583
- if (!path) return acc;
584
- const labelPosition = parsePoint(edge.lp);
585
- acc[edgeId] = {
586
- path,
587
- labelPosition: labelPosition ? flipY(labelPosition, graphHeight) : null,
588
- startPoint: startArrowPoint ?? points[0] ?? null,
589
- endPoint: endArrowPoint ?? points.at(-1) ?? null
590
- };
591
- return acc;
592
- }, {});
894
+ const finalizeEdge = (route, sourceCenter, targetCenter) => {
895
+ if (route) {
896
+ const layout = routeToEdgeLayout(route);
897
+ if (layout) return layout;
898
+ }
899
+ if (sourceCenter && targetCenter && !isDegenerate(sourceCenter, targetCenter)) return straightEdgeLayout(sourceCenter, targetCenter);
900
+ return null;
593
901
  };
594
- const runGraphviz = async ({ visualization, nodes, edges, layoutPlan }) => {
595
- const graphviz = await Graphviz.load();
596
- const json = JSON.parse(graphviz.layout(toDot(visualization, layoutPlan), "json", layoutPlan.engine));
597
- const graphHeight = parseBoundingBoxHeight(json.bb);
598
- const gvidToName = /* @__PURE__ */ new Map();
902
+ const routeToEdgeLayout = (route) => {
903
+ const path = routeToPath(route);
904
+ if (!path) return null;
599
905
  return {
600
- positions: (json.objects ?? []).reduce((acc, object) => {
601
- if (object._gvid != null && object.name) gvidToName.set(object._gvid, object.name);
602
- if (!object.name) return acc;
603
- const center = parsePoint(object.pos);
604
- if (!center) return acc;
605
- const data = nodes.find((candidate) => candidate.id === object.name)?.data;
606
- const width = data?.width ?? parseGraphvizDimension(object.width) ?? 0;
607
- const height = data?.height ?? parseGraphvizDimension(object.height) ?? 0;
608
- const flipped = flipY(center, graphHeight);
609
- acc[object.name] = {
610
- x: flipped.x - width / 2,
611
- y: flipped.y - height / 2
612
- };
613
- return acc;
614
- }, {}),
615
- edgeLayouts: buildGraphvizEdgeLayouts(json.edges ?? [], graphHeight, edges, gvidToName)
906
+ path,
907
+ startPoint: route.startAnchor ?? route.points[0] ?? null,
908
+ endPoint: route.endAnchor ?? route.points.at(-1) ?? null,
909
+ labelPosition: route.labelPosition
616
910
  };
617
911
  };
618
- const useGraphvizLayout = (visualization) => useCallback(async (model) => {
619
- const layout = await runGraphviz({
620
- visualization,
621
- nodes: model.nodes,
622
- edges: model.edges,
623
- layoutPlan: model.layoutPlan
624
- });
625
- return {
626
- nodes: applyNodePositions(model.nodes, layout.positions),
627
- edges: applyEdgeLayouts(model.edges, layout.edgeLayouts)
628
- };
629
- }, [visualization]);
630
912
  //#endregion
631
- //#region src/components/ResourceView/Viewers/GraphFlow/hooks/useLayout.ts
632
- const buildSafely = (visualization) => {
913
+ //#region src/components/ResourceView/Viewers/GraphFlow/hooks/useGraphLayout.ts
914
+ const prepare = (visualization) => {
633
915
  try {
634
916
  return {
635
- model: buildGraphFlowModel(visualization),
636
- buildError: null
917
+ model: buildGraphModel(visualization),
918
+ engine: selectEngine(visualization),
919
+ error: null
637
920
  };
638
921
  } catch (err) {
639
922
  return {
640
923
  model: null,
641
- buildError: toGraphError(err)
924
+ engine: null,
925
+ error: toGraphError(err)
642
926
  };
643
927
  }
644
928
  };
645
- const isElkModel = (model) => model.layoutPlan.type === "elk";
646
- const isGraphvizModel = (model) => model.layoutPlan.type === "graphviz";
647
- const useLayout = (visualization) => {
648
- const { model, buildError } = useMemo(() => buildSafely(visualization), [visualization]);
649
- const runElkLayout = useElkLayout();
650
- const runGraphvizLayout = useGraphvizLayout(visualization);
929
+ const useGraphLayout = (visualization) => {
930
+ const { model, engine, error: prepareError } = useMemo(() => prepare(visualization), [visualization]);
651
931
  const [nodes, setNodes] = useState([]);
652
932
  const [edges, setEdges] = useState([]);
653
- const [layoutReady, setLayoutReady] = useState(false);
654
- const [layoutError, setLayoutError] = useState(null);
655
- useEffect(() => {
656
- setLayoutError(null);
657
- setNodes(model?.nodes ?? []);
658
- setEdges(model?.edges ?? []);
659
- setLayoutReady(!model || model.nodes.length === 0);
660
- }, [model]);
933
+ const [ready, setReady] = useState(false);
934
+ const [runError, setRunError] = useState(null);
661
935
  useEffect(() => {
662
- if (!model || buildError || layoutReady) return;
936
+ setRunError(null);
937
+ if (!model || !engine) {
938
+ setNodes([]);
939
+ setEdges([]);
940
+ setReady(true);
941
+ return;
942
+ }
943
+ if (model.nodes.length === 0) {
944
+ setNodes([]);
945
+ setEdges([]);
946
+ setReady(true);
947
+ return;
948
+ }
949
+ setReady(false);
663
950
  let cancelled = false;
664
- (isElkModel(model) ? runElkLayout(model) : isGraphvizModel(model) ? runGraphvizLayout(model) : Promise.resolve(null)).then((result) => {
665
- if (cancelled || !result) return;
666
- setNodes(result.nodes);
667
- setEdges(result.edges);
668
- setLayoutReady(true);
951
+ engine.run(model).then((result) => {
952
+ if (cancelled) return;
953
+ const projected = projectToRenderModel(model, result);
954
+ setNodes(projected.nodes);
955
+ setEdges(projected.edges);
956
+ setReady(true);
669
957
  }, (err) => {
670
958
  if (cancelled) return;
671
- setLayoutError(toGraphError(err));
672
- setLayoutReady(true);
959
+ setRunError(toGraphError(err));
960
+ setReady(true);
673
961
  });
674
962
  return () => {
675
963
  cancelled = true;
676
964
  };
677
- }, [
678
- model,
679
- buildError,
680
- layoutReady,
681
- runElkLayout,
682
- runGraphvizLayout
683
- ]);
965
+ }, [model, engine]);
684
966
  const onNodesChange = useCallback((changes) => setNodes((current) => applyNodeChanges(changes, current)), []);
685
967
  return {
686
968
  nodes,
687
969
  edges,
688
- layoutReady,
689
- error: buildError ?? layoutError,
970
+ ready,
971
+ error: prepareError ?? runError,
690
972
  onNodesChange
691
973
  };
692
974
  };
693
975
  //#endregion
694
- //#region src/components/ResourceView/Viewers/GraphFlow/render/Node.tsx
695
- const HIDDEN_HANDLE_STYLE = {
696
- opacity: 0,
697
- pointerEvents: "none"
698
- };
699
- const HiddenHandles = () => /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Handle, {
700
- type: "target",
701
- position: Position.Left,
702
- style: HIDDEN_HANDLE_STYLE
703
- }), /* @__PURE__ */ jsx(Handle, {
704
- type: "source",
705
- position: Position.Right,
706
- style: HIDDEN_HANDLE_STYLE
707
- })] });
708
- const AnnotationBadge = () => /* @__PURE__ */ jsx(Badge, {
709
- size: "xs",
710
- variant: "filled",
711
- color: "gray",
712
- title: "This graph element has an annotation visualization.",
713
- children: "i"
714
- });
715
- const NodeAnnotation = ({ annotation }) => {
716
- const [opened, setOpened] = useState(false);
717
- const toggle = useCallback((e) => {
718
- e.stopPropagation();
719
- setOpened((o) => !o);
720
- }, []);
721
- return /* @__PURE__ */ jsxs(Popover, {
722
- opened,
723
- onClose: () => setOpened(false),
724
- position: "right",
725
- withArrow: true,
726
- withinPortal: true,
727
- shadow: "md",
728
- children: [/* @__PURE__ */ jsx(Popover.Target, { children: /* @__PURE__ */ jsx("button", {
729
- type: "button",
730
- className: "nodrag nopan",
731
- style: {
732
- position: "absolute",
733
- right: -8,
734
- top: -8,
735
- cursor: "pointer",
736
- pointerEvents: "all",
737
- zIndex: 10,
738
- background: "none",
739
- border: "none",
740
- padding: 0
741
- },
742
- onClick: toggle,
743
- onMouseDown: (e) => e.stopPropagation(),
744
- children: /* @__PURE__ */ jsx(AnnotationBadge, {})
745
- }) }), /* @__PURE__ */ jsxs(Popover.Dropdown, {
746
- style: {
747
- minWidth: 200,
748
- maxWidth: 400
749
- },
750
- children: [/* @__PURE__ */ jsx(Group, {
751
- justify: "flex-end",
752
- mb: 4,
753
- children: /* @__PURE__ */ jsx(ActionIcon, {
754
- size: "xs",
755
- variant: "subtle",
756
- color: "gray",
757
- onClick: () => setOpened(false),
758
- children: /* @__PURE__ */ jsx(XIcon, { size: 12 })
759
- })
760
- }), /* @__PURE__ */ jsx(ScrollArea.Autosize, {
761
- mah: 300,
762
- children: /* @__PURE__ */ jsx(Visualization, { visualization: annotation })
763
- })]
764
- })]
765
- });
766
- };
767
- const ExternalLabel = ({ children, placement }) => /* @__PURE__ */ jsx(Text, {
768
- size: "xs",
769
- fw: 600,
770
- style: {
771
- position: "absolute",
772
- [placement === "top" ? "bottom" : "top"]: "100%",
773
- left: "50%",
774
- transform: `translate(-50%, ${placement === "top" ? "-4px" : "4px"})`,
775
- whiteSpace: "nowrap",
776
- color: DEFAULT_COLORS.text,
777
- pointerEvents: "none",
778
- letterSpacing: "0.01em"
779
- },
780
- children
781
- });
782
- const InnerSymbol = ({ symbol, color }) => {
783
- if (symbol === "triangle") return /* @__PURE__ */ jsx("div", { style: {
784
- width: 0,
785
- height: 0,
786
- borderTop: `${12 * .6}px solid transparent`,
787
- borderBottom: `${12 * .6}px solid transparent`,
788
- borderLeft: `12px solid ${color}`,
789
- marginLeft: 2
790
- } });
791
- return /* @__PURE__ */ jsx("div", { style: {
792
- width: 12,
793
- height: 12,
794
- backgroundColor: color
795
- } });
796
- };
797
- const CLIP_PATH = {
798
- triangle: "polygon(50% 0, 100% 100%, 0 100%)",
799
- diamond: "polygon(50% 0, 100% 50%, 50% 100%, 0 50%)",
800
- hexagon: "polygon(25% 0, 75% 0, 100% 50%, 75% 100%, 25% 100%, 0 50%)"
801
- };
802
- const CircleShape = ({ width, height, color, borderColor, doubleBorder, innerSymbol, initialTokens, finalTokens, centerLabel }) => /* @__PURE__ */ jsxs("div", {
803
- style: {
804
- position: "relative",
805
- boxSizing: "border-box",
806
- width,
807
- height,
808
- borderRadius: "50%",
809
- backgroundColor: color,
810
- border: borderColor ? `2px solid ${borderColor}` : "none",
811
- display: "flex",
812
- flexDirection: "column",
813
- alignItems: "center",
814
- justifyContent: "center",
815
- gap: 2
816
- },
817
- children: [
818
- doubleBorder && /* @__PURE__ */ jsx("div", { style: {
819
- position: "absolute",
820
- inset: 5,
821
- borderRadius: "50%",
822
- border: borderColor ? `2px solid ${borderColor}` : "none",
823
- pointerEvents: "none"
824
- } }),
825
- initialTokens && !innerSymbol && (initialTokens === 1 ? /* @__PURE__ */ jsx("div", { style: {
826
- width: 12,
827
- height: 12,
828
- borderRadius: "50%",
829
- backgroundColor: "rgba(0,0,0,0.65)"
830
- } }) : /* @__PURE__ */ jsx("span", {
831
- style: {
832
- color: "#111",
833
- fontSize: 11,
834
- fontWeight: 700,
835
- lineHeight: 1
836
- },
837
- children: initialTokens
838
- })),
839
- finalTokens && finalTokens > 1 && /* @__PURE__ */ jsx("span", {
840
- style: {
841
- position: "absolute",
842
- right: 4,
843
- bottom: 3,
844
- color: "#111",
845
- fontSize: 9,
846
- fontWeight: 700,
847
- lineHeight: 1
848
- },
849
- children: finalTokens
850
- }),
851
- innerSymbol && /* @__PURE__ */ jsx(InnerSymbol, {
852
- symbol: innerSymbol,
853
- color: borderColor ?? "#111"
854
- }),
855
- centerLabel && /* @__PURE__ */ jsx("span", {
856
- style: {
857
- maxWidth: width - 6,
858
- color: "#111",
859
- fontSize: 10,
860
- fontWeight: 600,
861
- lineHeight: 1,
862
- overflow: "hidden",
863
- textAlign: "center",
864
- textOverflow: "ellipsis",
865
- whiteSpace: "nowrap"
866
- },
867
- children: centerLabel
868
- })
869
- ]
870
- });
871
- const BoxShape = ({ shape, color, borderColor, doubleBorder, innerSymbol, width, height, children }) => {
872
- const clipPath = CLIP_PATH[shape];
873
- return /* @__PURE__ */ jsxs("div", {
874
- style: {
875
- position: "relative",
876
- boxSizing: "border-box",
877
- width: width ?? void 0,
878
- height: height ?? void 0,
879
- padding: borderColor != null ? 2 : 0,
880
- backgroundColor: borderColor ?? "transparent",
881
- borderRadius: shape === "rectangle" ? 5 : 0,
882
- clipPath
883
- },
884
- children: [/* @__PURE__ */ jsx("div", {
885
- style: {
886
- boxSizing: "border-box",
887
- width: "100%",
888
- height: "100%",
889
- backgroundColor: color,
890
- borderRadius: shape === "rectangle" ? 3 : 0,
891
- clipPath,
892
- display: "flex",
893
- alignItems: "center",
894
- justifyContent: "center",
895
- overflow: "hidden"
896
- },
897
- children: innerSymbol && !children ? /* @__PURE__ */ jsx(InnerSymbol, {
898
- symbol: innerSymbol,
899
- color: "#111"
900
- }) : children
901
- }), doubleBorder && /* @__PURE__ */ jsx("div", { style: {
902
- position: "absolute",
903
- inset: 6,
904
- border: borderColor ? `1.5px solid ${borderColor}` : "none",
905
- borderRadius: shape === "rectangle" ? 3 : 0,
906
- clipPath,
907
- pointerEvents: "none"
908
- } })]
909
- });
910
- };
911
- const GraphFlowNode = memo(({ data }) => {
912
- const { shape, label, color, border_color, annotation, label_pos, width, height, style } = data;
913
- const doubleBorder = style?.double_border ?? null;
914
- const innerSymbol = style?.inner_symbol ?? null;
915
- const initialTokens = style?.initial_tokens ?? null;
916
- const finalTokens = style?.final_tokens ?? null;
917
- const isExternalLabel = Boolean(label && label_pos !== "center" && label_pos != null);
918
- const maxLabelLines = height != null ? Math.max(1, Math.floor((height - 4) / (14 * 1.2))) : void 0;
919
- return /* @__PURE__ */ jsxs("div", {
920
- style: {
921
- position: "relative",
922
- width: shape === "circle" ? width ?? void 0 : void 0
923
- },
924
- children: [
925
- /* @__PURE__ */ jsx(HiddenHandles, {}),
926
- isExternalLabel && label_pos === "top" && /* @__PURE__ */ jsx(ExternalLabel, {
927
- placement: "top",
928
- children: label
929
- }),
930
- shape === "circle" ? /* @__PURE__ */ jsx(CircleShape, {
931
- width,
932
- height,
933
- color: color ?? DEFAULT_COLORS.transition,
934
- borderColor: border_color ?? null,
935
- doubleBorder,
936
- innerSymbol,
937
- initialTokens,
938
- finalTokens,
939
- centerLabel: label_pos === "center" ? label ?? null : null
940
- }) : /* @__PURE__ */ jsx(BoxShape, {
941
- shape,
942
- color: color ?? DEFAULT_COLORS.transition,
943
- borderColor: border_color ?? null,
944
- doubleBorder,
945
- innerSymbol,
946
- width: width ?? null,
947
- height: height ?? null,
948
- children: !isExternalLabel && label && /* @__PURE__ */ jsx(Text, {
949
- size: "sm",
950
- fw: 500,
951
- ta: "center",
952
- px: 12,
953
- style: {
954
- lineHeight: 1.2,
955
- color: "#111",
956
- maxWidth: width != null ? width : "100%",
957
- boxSizing: "border-box",
958
- overflow: "hidden",
959
- overflowWrap: "anywhere",
960
- ...maxLabelLines != null ? {
961
- display: "-webkit-box",
962
- WebkitBoxOrient: "vertical",
963
- WebkitLineClamp: maxLabelLines
964
- } : { whiteSpace: "normal" }
965
- },
966
- children: label
967
- })
968
- }),
969
- isExternalLabel && label_pos === "bottom" && /* @__PURE__ */ jsx(ExternalLabel, {
970
- placement: "bottom",
971
- children: label
972
- }),
973
- annotation && /* @__PURE__ */ jsx(NodeAnnotation, { annotation })
974
- ]
975
- });
976
- });
977
- GraphFlowNode.displayName = "GraphFlowNode";
978
- //#endregion
979
- //#region src/components/ResourceView/Viewers/GraphFlow/render/Edge.tsx
980
- const placeBounds = (node) => {
981
- const data = node.data;
982
- const width = data.width ?? 0;
983
- const height = data.height ?? 0;
984
- return {
985
- x: node.internals.positionAbsolute.x,
986
- y: node.internals.positionAbsolute.y,
987
- width,
988
- height
989
- };
990
- };
991
- const visualBounds = (node) => {
992
- if (node.data.shape === "circle") return placeBounds(node);
993
- return {
994
- x: node.internals.positionAbsolute.x,
995
- y: node.internals.positionAbsolute.y,
996
- width: node.measured.width ?? 0,
997
- height: node.measured.height ?? 0
998
- };
999
- };
1000
- const center = (node) => {
1001
- const bounds = visualBounds(node);
1002
- return {
1003
- x: bounds.x + bounds.width / 2,
1004
- y: bounds.y + bounds.height / 2
1005
- };
1006
- };
1007
- const rectBorderPoint = (node, other) => {
1008
- const bounds = visualBounds(node);
1009
- const hw = bounds.width / 2;
1010
- const hh = bounds.height / 2;
1011
- if (hw === 0 || hh === 0) return null;
1012
- const nc = center(node);
1013
- const oc = center(other);
1014
- const dx = oc.x - nc.x;
1015
- const dy = oc.y - nc.y;
1016
- const xx = dx / (2 * hw) - dy / (2 * hh);
1017
- const yy = dx / (2 * hw) + dy / (2 * hh);
1018
- const scale = 1 / (Math.abs(xx) + Math.abs(yy));
1019
- if (!Number.isFinite(scale)) return null;
1020
- return {
1021
- x: nc.x + hw * scale * (xx + yy),
1022
- y: nc.y + hh * scale * (-xx + yy)
1023
- };
1024
- };
1025
- const circleBorderPoint = (node, other) => {
1026
- const bounds = placeBounds(node);
1027
- const rx = bounds.width / 2;
1028
- const ry = bounds.height / 2;
1029
- if (rx === 0 || ry === 0) return null;
1030
- const nc = center(node);
1031
- const oc = center(other);
1032
- const angle = Math.atan2(oc.y - nc.y, oc.x - nc.x);
1033
- return {
1034
- x: nc.x + Math.cos(angle) * rx,
1035
- y: nc.y + Math.sin(angle) * ry
1036
- };
1037
- };
1038
- const borderPoint = (node, other) => node.data.shape === "circle" ? circleBorderPoint(node, other) : rectBorderPoint(node, other);
1039
- const edgeSide = (node, pt) => {
1040
- const nc = center(node);
1041
- const dx = pt.x - nc.x;
1042
- const dy = pt.y - nc.y;
1043
- if (Math.abs(dx) >= Math.abs(dy)) return dx >= 0 ? Position.Right : Position.Left;
1044
- return dy >= 0 ? Position.Bottom : Position.Top;
1045
- };
1046
- const getEdgeParams = (source, target) => {
1047
- const sp = borderPoint(source, target);
1048
- const tp = borderPoint(target, source);
1049
- if (!sp || !tp) return null;
1050
- return {
1051
- sx: sp.x,
1052
- sy: sp.y,
1053
- tx: tp.x,
1054
- ty: tp.y,
1055
- sourcePos: edgeSide(source, sp),
1056
- targetPos: edgeSide(target, tp)
1057
- };
1058
- };
1059
- const fallbackLabelPosition = ({ sourceX, sourceY, targetX, targetY }) => ({
1060
- x: (sourceX + targetX) / 2,
1061
- y: (sourceY + targetY) / 2
1062
- });
1063
- const getSelfLoopPath = (sourceNode) => {
1064
- const width = sourceNode.measured.width ?? 60;
1065
- const height = sourceNode.measured.height ?? 34;
1066
- const position = sourceNode.internals.positionAbsolute;
1067
- const [path, labelX, labelY] = getSmoothStepPath({
1068
- sourceX: position.x + width,
1069
- sourceY: position.y + height * .25,
1070
- sourcePosition: "right",
1071
- targetX: position.x + width,
1072
- targetY: position.y + height * .75,
1073
- targetPosition: "right",
1074
- borderRadius: 20,
1075
- offset: 50
1076
- });
1077
- return {
1078
- path,
1079
- labelX,
1080
- labelY
1081
- };
1082
- };
1083
- const getFloatingPath = ({ sourceNode, targetNode, fallback }) => {
1084
- const params = sourceNode && targetNode ? getEdgeParams(sourceNode, targetNode) : null;
1085
- const [path, labelX, labelY] = getBezierPath(params ? {
1086
- sourceX: params.sx,
1087
- sourceY: params.sy,
1088
- sourcePosition: params.sourcePos,
1089
- targetX: params.tx,
1090
- targetY: params.ty,
1091
- targetPosition: params.targetPos
1092
- } : fallback);
1093
- return {
1094
- path,
1095
- labelX,
1096
- labelY
1097
- };
1098
- };
1099
- const resolveEdgePath = ({ edge, sourceNode, targetNode }) => {
1100
- const { data, source, target, sourceX, sourceY, targetX, targetY } = edge;
1101
- const customLabelPosition = fallbackLabelPosition(edge);
1102
- if (data?.path) return {
1103
- path: data.path,
1104
- labelX: data.labelPosition?.x ?? customLabelPosition.x,
1105
- labelY: data.labelPosition?.y ?? customLabelPosition.y
1106
- };
1107
- if (source === target && sourceNode) return getSelfLoopPath(sourceNode);
1108
- return getFloatingPath({
1109
- sourceNode,
1110
- targetNode,
1111
- fallback: {
1112
- sourceX,
1113
- sourceY,
1114
- sourcePosition: edge.sourcePosition,
1115
- targetX,
1116
- targetY,
1117
- targetPosition: edge.targetPosition
1118
- }
1119
- });
1120
- };
976
+ //#region src/components/ResourceView/Viewers/GraphFlow/render/edgeArrows.tsx
1121
977
  const colorToId = (color) => color.replace(/[^a-zA-Z0-9]/g, "");
1122
978
  const ArrowMarker = ({ id, type, color, isStart }) => {
1123
979
  if (!type) return null;
@@ -1284,6 +1140,69 @@ const getMarkerIds = ({ color, startArrow, endArrow }) => {
1284
1140
  endMarkerId: endArrow ? `arrow-end-${endArrow}-${colorKey}` : null
1285
1141
  };
1286
1142
  };
1143
+ //#endregion
1144
+ //#region src/components/ResourceView/Viewers/GraphFlow/render/annotation.tsx
1145
+ const AnnotationBadge = () => /* @__PURE__ */ jsx(Badge, {
1146
+ size: "xs",
1147
+ variant: "filled",
1148
+ color: "gray",
1149
+ title: "This graph element has an annotation visualization.",
1150
+ children: "i"
1151
+ });
1152
+ const NodeAnnotation = ({ annotation }) => {
1153
+ const [opened, setOpened] = useState(false);
1154
+ const toggle = useCallback((e) => {
1155
+ e.stopPropagation();
1156
+ setOpened((o) => !o);
1157
+ }, []);
1158
+ return /* @__PURE__ */ jsxs(Popover, {
1159
+ opened,
1160
+ onClose: () => setOpened(false),
1161
+ position: "right",
1162
+ withArrow: true,
1163
+ withinPortal: true,
1164
+ shadow: "md",
1165
+ children: [/* @__PURE__ */ jsx(Popover.Target, { children: /* @__PURE__ */ jsx("button", {
1166
+ type: "button",
1167
+ className: "nodrag nopan",
1168
+ style: {
1169
+ position: "absolute",
1170
+ right: -8,
1171
+ top: -8,
1172
+ cursor: "pointer",
1173
+ pointerEvents: "all",
1174
+ zIndex: 10,
1175
+ background: "none",
1176
+ border: "none",
1177
+ padding: 0
1178
+ },
1179
+ onClick: toggle,
1180
+ onMouseDown: (e) => e.stopPropagation(),
1181
+ children: /* @__PURE__ */ jsx(AnnotationBadge, {})
1182
+ }) }), /* @__PURE__ */ jsxs(Popover.Dropdown, {
1183
+ style: {
1184
+ minWidth: 200,
1185
+ maxWidth: 400
1186
+ },
1187
+ children: [/* @__PURE__ */ jsx(Group, {
1188
+ justify: "flex-end",
1189
+ mb: 4,
1190
+ children: /* @__PURE__ */ jsx(ActionIcon, {
1191
+ size: "xs",
1192
+ variant: "subtle",
1193
+ color: "gray",
1194
+ onClick: () => setOpened(false),
1195
+ children: /* @__PURE__ */ jsx(XIcon, { size: 12 })
1196
+ })
1197
+ }), /* @__PURE__ */ jsx(ScrollArea.Autosize, {
1198
+ mah: 300,
1199
+ children: /* @__PURE__ */ jsx(Visualization, { visualization: annotation })
1200
+ })]
1201
+ })]
1202
+ });
1203
+ };
1204
+ //#endregion
1205
+ //#region src/components/ResourceView/Viewers/GraphFlow/render/edgeLabels.tsx
1287
1206
  const EdgeLabel = ({ color, label, hasAnnotation, x, y }) => /* @__PURE__ */ jsx(EdgeLabelRenderer, { children: /* @__PURE__ */ jsxs("div", {
1288
1207
  style: {
1289
1208
  position: "absolute",
@@ -1318,8 +1237,6 @@ const EdgeEndLabel = ({ label, x, y }) => /* @__PURE__ */ jsx(EdgeLabelRenderer,
1318
1237
  className: "nodrag nopan",
1319
1238
  children: label
1320
1239
  }) });
1321
- const END_LABEL_INSET = 22;
1322
- const END_LABEL_PERP = 9;
1323
1240
  const endLabelPositions = (start, end) => {
1324
1241
  const dx = end.x - start.x;
1325
1242
  const dy = end.y - start.y;
@@ -1330,40 +1247,82 @@ const endLabelPositions = (start, end) => {
1330
1247
  const py = ux;
1331
1248
  return {
1332
1249
  start: {
1333
- x: start.x + ux * END_LABEL_INSET + px * END_LABEL_PERP,
1334
- y: start.y + uy * END_LABEL_INSET + py * END_LABEL_PERP
1250
+ x: start.x + ux * 22 + px * 9,
1251
+ y: start.y + uy * 22 + py * 9
1335
1252
  },
1336
1253
  end: {
1337
- x: end.x - ux * END_LABEL_INSET + px * END_LABEL_PERP,
1338
- y: end.y - uy * END_LABEL_INSET + py * END_LABEL_PERP
1254
+ x: end.x - ux * 22 + px * 9,
1255
+ y: end.y - uy * 22 + py * 9
1339
1256
  }
1340
1257
  };
1341
1258
  };
1342
- const GraphFlowEdge = memo((props) => {
1343
- const { id, source, target, data, sourceX, sourceY, targetX, targetY } = props;
1344
- const sourceNode = useInternalNode(source);
1345
- const targetNode = useInternalNode(target);
1346
- if (!data) return null;
1347
- const color = data.color ?? "#555";
1348
- const dashed = data.style?.dashed ?? false;
1349
- const bold = data.style?.bold ?? false;
1350
- const startArrow = data.start_arrow ?? null;
1351
- const endArrow = data.end_arrow ?? null;
1259
+ //#endregion
1260
+ //#region src/components/ResourceView/Viewers/GraphFlow/render/Edge.tsx
1261
+ const centerOf = ({ sourceX, sourceY, targetX, targetY }) => ({
1262
+ x: (sourceX + targetX) / 2,
1263
+ y: (sourceY + targetY) / 2
1264
+ });
1265
+ const selfLoopPath = (sourceNode) => {
1266
+ const width = sourceNode.measured.width ?? 60;
1267
+ const height = sourceNode.measured.height ?? 34;
1268
+ const position = sourceNode.internals.positionAbsolute;
1269
+ const [path, labelX, labelY] = getSmoothStepPath({
1270
+ sourceX: position.x + width,
1271
+ sourceY: position.y + height * .25,
1272
+ sourcePosition: "right",
1273
+ targetX: position.x + width,
1274
+ targetY: position.y + height * .75,
1275
+ targetPosition: "right",
1276
+ borderRadius: 20,
1277
+ offset: 50
1278
+ });
1279
+ return {
1280
+ path,
1281
+ labelX,
1282
+ labelY
1283
+ };
1284
+ };
1285
+ const bezierFallback = (edge) => {
1286
+ const [path, labelX, labelY] = getBezierPath(edge);
1287
+ return {
1288
+ path,
1289
+ labelX,
1290
+ labelY
1291
+ };
1292
+ };
1293
+ const resolvePath = (edge, layout, sourceNode) => {
1294
+ if (layout?.path) {
1295
+ const center = centerOf(edge);
1296
+ return {
1297
+ path: layout.path,
1298
+ labelX: layout.labelPosition?.x ?? center.x,
1299
+ labelY: layout.labelPosition?.y ?? center.y
1300
+ };
1301
+ }
1302
+ if (edge.source === edge.target && sourceNode) return selfLoopPath(sourceNode);
1303
+ return bezierFallback(edge);
1304
+ };
1305
+ const GraphFlowEdge = memo((props) => {
1306
+ const { id, source, data, sourceX, sourceY, targetX, targetY } = props;
1307
+ const sourceNode = useInternalNode(source);
1308
+ if (!data) return null;
1309
+ const { model, layout } = data;
1310
+ const color = model.color ?? "#555";
1311
+ const dashed = model.style?.dashed ?? false;
1312
+ const bold = model.style?.bold ?? false;
1313
+ const startArrow = model.start_arrow ?? null;
1314
+ const endArrow = model.end_arrow ?? null;
1352
1315
  const { startMarkerId, endMarkerId } = getMarkerIds({
1353
1316
  color,
1354
1317
  startArrow,
1355
1318
  endArrow
1356
1319
  });
1357
- const edgePath = resolveEdgePath({
1358
- edge: props,
1359
- sourceNode,
1360
- targetNode
1361
- });
1362
- const startPos = data.startPoint ?? {
1320
+ const resolved = resolvePath(props, layout, sourceNode);
1321
+ const startPos = layout?.startPoint ?? {
1363
1322
  x: sourceX,
1364
1323
  y: sourceY
1365
1324
  };
1366
- const endPos = data.endPoint ?? {
1325
+ const endPos = layout?.endPoint ?? {
1367
1326
  x: targetX,
1368
1327
  y: targetY
1369
1328
  };
@@ -1381,7 +1340,7 @@ const GraphFlowEdge = memo((props) => {
1381
1340
  })] }),
1382
1341
  /* @__PURE__ */ jsx(BaseEdge, {
1383
1342
  id,
1384
- path: edgePath.path,
1343
+ path: resolved.path,
1385
1344
  ...endMarkerId ? { markerEnd: `url(#${endMarkerId})` } : {},
1386
1345
  ...startMarkerId ? { markerStart: `url(#${startMarkerId})` } : {},
1387
1346
  style: {
@@ -1390,21 +1349,21 @@ const GraphFlowEdge = memo((props) => {
1390
1349
  strokeDasharray: dashed ? "6 3" : void 0
1391
1350
  }
1392
1351
  }),
1393
- (data.label || data.annotation) && /* @__PURE__ */ jsx(EdgeLabel, {
1352
+ (model.label || model.annotation) && /* @__PURE__ */ jsx(EdgeLabel, {
1394
1353
  color,
1395
- label: data.label,
1396
- hasAnnotation: Boolean(data.annotation),
1397
- x: edgePath.labelX,
1398
- y: edgePath.labelY
1354
+ label: model.label,
1355
+ hasAnnotation: Boolean(model.annotation),
1356
+ x: resolved.labelX,
1357
+ y: resolved.labelY
1399
1358
  }),
1400
- (data.start_label || data.end_label) && (() => {
1359
+ (model.start_label || model.end_label) && (() => {
1401
1360
  const positions = endLabelPositions(startPos, endPos);
1402
- return /* @__PURE__ */ jsxs(Fragment, { children: [data.start_label && /* @__PURE__ */ jsx(EdgeEndLabel, {
1403
- label: data.start_label,
1361
+ return /* @__PURE__ */ jsxs(Fragment, { children: [model.start_label && /* @__PURE__ */ jsx(EdgeEndLabel, {
1362
+ label: model.start_label,
1404
1363
  x: positions.start.x,
1405
1364
  y: positions.start.y
1406
- }), data.end_label && /* @__PURE__ */ jsx(EdgeEndLabel, {
1407
- label: data.end_label,
1365
+ }), model.end_label && /* @__PURE__ */ jsx(EdgeEndLabel, {
1366
+ label: model.end_label,
1408
1367
  x: positions.end.x,
1409
1368
  y: positions.end.y
1410
1369
  })] });
@@ -1413,202 +1372,242 @@ const GraphFlowEdge = memo((props) => {
1413
1372
  });
1414
1373
  GraphFlowEdge.displayName = "GraphFlowEdge";
1415
1374
  //#endregion
1416
- //#region src/components/ResourceView/Viewers/GraphFlow/utils/bounds.ts
1417
- function nodeBoundsFromData(nodes) {
1418
- if (nodes.length === 0) return {
1419
- x: 0,
1420
- y: 0,
1421
- width: 0,
1422
- height: 0
1423
- };
1424
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
1425
- for (const node of nodes) {
1426
- const x = node.position.x;
1427
- const y = node.position.y;
1428
- const w = node.measured?.width ?? 0;
1429
- const h = node.measured?.height ?? 0;
1430
- minX = Math.min(minX, x);
1431
- minY = Math.min(minY, y);
1432
- maxX = Math.max(maxX, x + w);
1433
- maxY = Math.max(maxY, y + h);
1434
- }
1435
- return Number.isFinite(minX) ? {
1436
- x: minX,
1437
- y: minY,
1438
- width: maxX - minX,
1439
- height: maxY - minY
1440
- } : {
1441
- x: 0,
1442
- y: 0,
1375
+ //#region src/components/ResourceView/Viewers/GraphFlow/render/nodeShapes.tsx
1376
+ const InnerSymbol = ({ symbol, color }) => {
1377
+ if (symbol === "triangle") return /* @__PURE__ */ jsx("div", { style: {
1443
1378
  width: 0,
1444
- height: 0
1445
- };
1446
- }
1447
- function svgPathBounds(d) {
1448
- const nums = (d.match(/-?[\d.]+(?:e[+-]?\d+)?/gi) ?? []).map(parseFloat);
1449
- if (nums.length < 2) return null;
1450
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
1451
- for (let i = 0; i + 1 < nums.length; i += 2) {
1452
- minX = Math.min(minX, nums[i]);
1453
- minY = Math.min(minY, nums[i + 1]);
1454
- maxX = Math.max(maxX, nums[i]);
1455
- maxY = Math.max(maxY, nums[i + 1]);
1456
- }
1457
- return Number.isFinite(minX) ? {
1458
- x: minX,
1459
- y: minY,
1460
- width: maxX - minX,
1461
- height: maxY - minY
1462
- } : null;
1463
- }
1464
- /**
1465
- * Computes content bounds from graph data without touching the DOM.
1466
- * Uses node positions/sizes and pre-computed ELK edge paths.
1467
- * Safe to call immediately after layout with no timing concerns.
1468
- */
1469
- function computeAutoFitBounds(nodes, edges) {
1470
- let combined = nodeBoundsFromData(nodes);
1471
- for (const edge of edges) {
1472
- const path = edge.data?.path;
1473
- if (!path) continue;
1474
- const eb = svgPathBounds(path);
1475
- if (eb) combined = unionRects(combined, eb);
1476
- }
1477
- return combined;
1478
- }
1479
- function unionRects(a, b) {
1480
- const x = Math.min(a.x, b.x);
1481
- const y = Math.min(a.y, b.y);
1482
- const right = Math.max(a.x + a.width, b.x + b.width);
1483
- const bottom = Math.max(a.y + a.height, b.y + b.height);
1484
- return {
1485
- x,
1486
- y,
1487
- width: right - x,
1488
- height: bottom - y
1489
- };
1490
- }
1491
- /**
1492
- * Reads the viewport transform directly from the inline style React Flow writes:
1493
- * `transform: translate(Xpx, Ypx) scale(Z)`
1494
- */
1495
- function parseViewportTransform(viewportEl) {
1496
- const match = (viewportEl.style.transform ?? "").match(/translate\((-?[\d.]+)px,\s*(-?[\d.]+)px\)\s*scale\((-?[\d.]+)\)/);
1497
- if (!match) return null;
1498
- return {
1499
- x: parseFloat(match[1]),
1500
- y: parseFloat(match[2]),
1501
- zoom: parseFloat(match[3])
1502
- };
1503
- }
1504
- /**
1505
- * Returns the bounding rect (in flow coordinates) of all nodes plus all
1506
- * rendered edge paths. React Flow's built-in helpers only account for nodes;
1507
- * curved or long edges can extend well outside the node bounds, causing them
1508
- * to be clipped in exports and in fitView.
1509
- *
1510
- * In @xyflow/react v12 each edge is wrapped in its own <svg> (position:
1511
- * absolute), so getBBox() coordinates are not reliably in flow space. Instead
1512
- * we use getBoundingClientRect() on each .react-flow__edge-path element and
1513
- * convert from screen coords back to flow coords via the inverse of the
1514
- * viewport's CSS transform.
1515
- */
1516
- function getContentBounds(nodes, viewportEl, getNodesBounds) {
1517
- const nodeBounds = getNodesBounds(nodes);
1518
- const vt = parseViewportTransform(viewportEl);
1519
- if (!vt || vt.zoom === 0) return nodeBounds;
1520
- const containerRect = viewportEl.parentElement?.getBoundingClientRect();
1521
- if (!containerRect) return nodeBounds;
1522
- const edgePaths = viewportEl.querySelectorAll(".react-flow__edge-path");
1523
- if (edgePaths.length === 0) return nodeBounds;
1524
- let combined = nodeBounds;
1525
- for (const path of Array.from(edgePaths)) {
1526
- const rect = path.getBoundingClientRect();
1527
- if (rect.width === 0 && rect.height === 0) continue;
1528
- const flowX = (rect.left - containerRect.left - vt.x) / vt.zoom;
1529
- const flowY = (rect.top - containerRect.top - vt.y) / vt.zoom;
1530
- const flowRight = (rect.right - containerRect.left - vt.x) / vt.zoom;
1531
- const flowBottom = (rect.bottom - containerRect.top - vt.y) / vt.zoom;
1532
- combined = unionRects(combined, {
1533
- x: flowX,
1534
- y: flowY,
1535
- width: flowRight - flowX,
1536
- height: flowBottom - flowY
1537
- });
1538
- }
1539
- return combined;
1540
- }
1379
+ height: 0,
1380
+ borderTop: `${12 * .6}px solid transparent`,
1381
+ borderBottom: `${12 * .6}px solid transparent`,
1382
+ borderLeft: `12px solid ${color}`,
1383
+ marginLeft: 2
1384
+ } });
1385
+ return /* @__PURE__ */ jsx("div", { style: {
1386
+ width: 12,
1387
+ height: 12,
1388
+ backgroundColor: color
1389
+ } });
1390
+ };
1391
+ const CLIP_PATH = {
1392
+ triangle: "polygon(50% 0, 100% 100%, 0 100%)",
1393
+ diamond: "polygon(50% 0, 100% 50%, 50% 100%, 0 50%)",
1394
+ hexagon: "polygon(25% 0, 75% 0, 100% 50%, 75% 100%, 25% 100%, 0 50%)"
1395
+ };
1396
+ const CircleShape = ({ width, height, color, borderColor, doubleBorder, innerSymbol, initialTokens, finalTokens, centerLabel }) => /* @__PURE__ */ jsxs("div", {
1397
+ style: {
1398
+ position: "relative",
1399
+ boxSizing: "border-box",
1400
+ width,
1401
+ height,
1402
+ borderRadius: "50%",
1403
+ backgroundColor: color,
1404
+ border: borderColor ? `2px solid ${borderColor}` : "none",
1405
+ display: "flex",
1406
+ flexDirection: "column",
1407
+ alignItems: "center",
1408
+ justifyContent: "center",
1409
+ gap: 2
1410
+ },
1411
+ children: [
1412
+ doubleBorder && /* @__PURE__ */ jsx("div", { style: {
1413
+ position: "absolute",
1414
+ inset: 5,
1415
+ borderRadius: "50%",
1416
+ border: borderColor ? `2px solid ${borderColor}` : "none",
1417
+ pointerEvents: "none"
1418
+ } }),
1419
+ initialTokens && !innerSymbol && (initialTokens === 1 ? /* @__PURE__ */ jsx("div", { style: {
1420
+ width: 12,
1421
+ height: 12,
1422
+ borderRadius: "50%",
1423
+ backgroundColor: "rgba(0,0,0,0.65)"
1424
+ } }) : /* @__PURE__ */ jsx("span", {
1425
+ style: {
1426
+ color: "#111",
1427
+ fontSize: 11,
1428
+ fontWeight: 700,
1429
+ lineHeight: 1
1430
+ },
1431
+ children: initialTokens
1432
+ })),
1433
+ finalTokens && finalTokens > 1 && /* @__PURE__ */ jsx("span", {
1434
+ style: {
1435
+ position: "absolute",
1436
+ right: 4,
1437
+ bottom: 3,
1438
+ color: "#111",
1439
+ fontSize: 9,
1440
+ fontWeight: 700,
1441
+ lineHeight: 1
1442
+ },
1443
+ children: finalTokens
1444
+ }),
1445
+ innerSymbol && /* @__PURE__ */ jsx(InnerSymbol, {
1446
+ symbol: innerSymbol,
1447
+ color: borderColor ?? "#111"
1448
+ }),
1449
+ centerLabel && /* @__PURE__ */ jsx("span", {
1450
+ style: {
1451
+ maxWidth: width - 6,
1452
+ color: "#111",
1453
+ fontSize: 10,
1454
+ fontWeight: 600,
1455
+ lineHeight: 1,
1456
+ overflow: "hidden",
1457
+ textAlign: "center",
1458
+ textOverflow: "ellipsis",
1459
+ whiteSpace: "nowrap"
1460
+ },
1461
+ children: centerLabel
1462
+ })
1463
+ ]
1464
+ });
1465
+ const BoxShape = ({ shape, color, borderColor, doubleBorder, innerSymbol, width, height, children }) => {
1466
+ const clipPath = CLIP_PATH[shape];
1467
+ return /* @__PURE__ */ jsxs("div", {
1468
+ style: {
1469
+ position: "relative",
1470
+ boxSizing: "border-box",
1471
+ width: width ?? void 0,
1472
+ height: height ?? void 0,
1473
+ padding: borderColor != null ? 2 : 0,
1474
+ backgroundColor: borderColor ?? "transparent",
1475
+ borderRadius: shape === "rectangle" ? 5 : 0,
1476
+ clipPath
1477
+ },
1478
+ children: [/* @__PURE__ */ jsx("div", {
1479
+ style: {
1480
+ boxSizing: "border-box",
1481
+ width: "100%",
1482
+ height: "100%",
1483
+ backgroundColor: color,
1484
+ borderRadius: shape === "rectangle" ? 3 : 0,
1485
+ clipPath,
1486
+ display: "flex",
1487
+ alignItems: "center",
1488
+ justifyContent: "center",
1489
+ overflow: "hidden"
1490
+ },
1491
+ children: innerSymbol && !children ? /* @__PURE__ */ jsx(InnerSymbol, {
1492
+ symbol: innerSymbol,
1493
+ color: "#111"
1494
+ }) : children
1495
+ }), doubleBorder && /* @__PURE__ */ jsx("div", { style: {
1496
+ position: "absolute",
1497
+ inset: 6,
1498
+ border: borderColor ? `1.5px solid ${borderColor}` : "none",
1499
+ borderRadius: shape === "rectangle" ? 3 : 0,
1500
+ clipPath,
1501
+ pointerEvents: "none"
1502
+ } })]
1503
+ });
1504
+ };
1541
1505
  //#endregion
1542
- //#region src/components/ResourceView/Viewers/GraphFlow/utils/download.ts
1543
- const { saveAs } = FileSaver;
1544
- const MAX_PX = 2048;
1545
- const EXPORT_PADDING = .05;
1546
- function buildCaptureOptions(imageWidth, imageHeight, x, y, zoom) {
1547
- return {
1548
- backgroundColor: "#ffffff",
1549
- width: imageWidth,
1550
- height: imageHeight,
1551
- skipFonts: true,
1506
+ //#region src/components/ResourceView/Viewers/GraphFlow/render/Node.tsx
1507
+ const HIDDEN_HANDLE_STYLE = {
1508
+ opacity: 0,
1509
+ pointerEvents: "none"
1510
+ };
1511
+ const HiddenHandles = () => /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Handle, {
1512
+ type: "target",
1513
+ position: Position.Left,
1514
+ style: HIDDEN_HANDLE_STYLE
1515
+ }), /* @__PURE__ */ jsx(Handle, {
1516
+ type: "source",
1517
+ position: Position.Right,
1518
+ style: HIDDEN_HANDLE_STYLE
1519
+ })] });
1520
+ const ExternalLabel = ({ children, placement }) => /* @__PURE__ */ jsx(Text, {
1521
+ size: "xs",
1522
+ fw: 600,
1523
+ style: {
1524
+ position: "absolute",
1525
+ [placement === "top" ? "bottom" : "top"]: "100%",
1526
+ left: "50%",
1527
+ transform: `translate(-50%, ${placement === "top" ? "-4px" : "4px"})`,
1528
+ whiteSpace: "nowrap",
1529
+ color: DEFAULT_COLORS.text,
1530
+ pointerEvents: "none",
1531
+ letterSpacing: "0.01em"
1532
+ },
1533
+ children
1534
+ });
1535
+ const GraphFlowNode = memo(({ data }) => {
1536
+ const { shape, label, color, border_color, annotation, label_pos, width, height, style } = data.model;
1537
+ const doubleBorder = style?.double_border ?? null;
1538
+ const innerSymbol = style?.inner_symbol ?? null;
1539
+ const initialTokens = style?.initial_tokens ?? null;
1540
+ const finalTokens = style?.final_tokens ?? null;
1541
+ const isExternalLabel = Boolean(label && label_pos !== "center" && label_pos != null);
1542
+ const maxLabelLines = height != null ? Math.max(1, Math.floor((height - 4) / (14 * 1.2))) : void 0;
1543
+ return /* @__PURE__ */ jsxs("div", {
1552
1544
  style: {
1553
- width: `${imageWidth}px`,
1554
- height: `${imageHeight}px`,
1555
- transform: `translate(${x}px, ${y}px) scale(${zoom})`
1545
+ position: "relative",
1546
+ width: shape === "circle" ? width ?? void 0 : void 0
1556
1547
  },
1557
- filter: (node) => !(node instanceof Element) || !node.classList.contains("react-flow__panel") && !node.classList.contains("react-flow__background")
1558
- };
1559
- }
1560
- function resolveViewport(container, nodes, getNodesBounds) {
1561
- if (nodes.length === 0) return null;
1562
- const viewportEl = container.querySelector(".react-flow__viewport");
1563
- if (!viewportEl) return null;
1564
- const bounds = getContentBounds(nodes, viewportEl, getNodesBounds);
1565
- const padX = bounds.width * EXPORT_PADDING;
1566
- const padY = bounds.height * EXPORT_PADDING;
1567
- const paddedWidth = bounds.width + 2 * padX;
1568
- const paddedHeight = bounds.height + 2 * padY;
1569
- const zoom = Math.min(MAX_PX / paddedWidth, MAX_PX / paddedHeight);
1570
- return {
1571
- viewportEl,
1572
- imageWidth: Math.round(paddedWidth * zoom),
1573
- imageHeight: Math.round(paddedHeight * zoom),
1574
- x: (padX - bounds.x) * zoom,
1575
- y: (padY - bounds.y) * zoom,
1576
- zoom
1577
- };
1578
- }
1579
- async function downloadPng(container, nodes, getNodesBounds) {
1580
- const capture = resolveViewport(container, nodes, getNodesBounds);
1581
- if (!capture) return;
1582
- const { viewportEl, imageWidth, imageHeight, x, y, zoom } = capture;
1583
- saveAs(await toPng(viewportEl, buildCaptureOptions(imageWidth, imageHeight, x, y, zoom)), "graph.png");
1584
- }
1585
- async function downloadSvg(container, nodes, getNodesBounds) {
1586
- const capture = resolveViewport(container, nodes, getNodesBounds);
1587
- if (!capture) return;
1588
- const { viewportEl, imageWidth, imageHeight, x, y, zoom } = capture;
1589
- saveAs(await toSvg(viewportEl, buildCaptureOptions(imageWidth, imageHeight, x, y, zoom)), "graph.svg");
1590
- }
1591
- async function downloadPdf(container, nodes, getNodesBounds) {
1592
- const capture = resolveViewport(container, nodes, getNodesBounds);
1593
- if (!capture) return;
1594
- const { viewportEl, imageWidth, imageHeight, x, y, zoom } = capture;
1595
- const dataUrl = await toPng(viewportEl, buildCaptureOptions(imageWidth, imageHeight, x, y, zoom));
1596
- const pdf = new jsPDF({
1597
- orientation: imageWidth >= imageHeight ? "landscape" : "portrait",
1598
- unit: "px",
1599
- format: [imageWidth, imageHeight]
1548
+ children: [
1549
+ /* @__PURE__ */ jsx(HiddenHandles, {}),
1550
+ isExternalLabel && label_pos === "top" && /* @__PURE__ */ jsx(ExternalLabel, {
1551
+ placement: "top",
1552
+ children: label
1553
+ }),
1554
+ shape === "circle" ? /* @__PURE__ */ jsx(CircleShape, {
1555
+ width,
1556
+ height,
1557
+ color: color ?? DEFAULT_COLORS.transition,
1558
+ borderColor: border_color ?? null,
1559
+ doubleBorder,
1560
+ innerSymbol,
1561
+ initialTokens,
1562
+ finalTokens,
1563
+ centerLabel: label_pos === "center" ? label ?? null : null
1564
+ }) : /* @__PURE__ */ jsx(BoxShape, {
1565
+ shape,
1566
+ color: color ?? DEFAULT_COLORS.transition,
1567
+ borderColor: border_color ?? null,
1568
+ doubleBorder,
1569
+ innerSymbol,
1570
+ width: width ?? null,
1571
+ height: height ?? null,
1572
+ children: !isExternalLabel && label && /* @__PURE__ */ jsx(Text, {
1573
+ size: "sm",
1574
+ fw: 500,
1575
+ ta: "center",
1576
+ px: 12,
1577
+ style: {
1578
+ lineHeight: 1.2,
1579
+ color: "#111",
1580
+ maxWidth: width != null ? width : "100%",
1581
+ boxSizing: "border-box",
1582
+ overflow: "hidden",
1583
+ overflowWrap: "anywhere",
1584
+ ...maxLabelLines != null ? {
1585
+ display: "-webkit-box",
1586
+ WebkitBoxOrient: "vertical",
1587
+ WebkitLineClamp: maxLabelLines
1588
+ } : { whiteSpace: "normal" }
1589
+ },
1590
+ children: label
1591
+ })
1592
+ }),
1593
+ isExternalLabel && label_pos === "bottom" && /* @__PURE__ */ jsx(ExternalLabel, {
1594
+ placement: "bottom",
1595
+ children: label
1596
+ }),
1597
+ annotation && /* @__PURE__ */ jsx(NodeAnnotation, { annotation })
1598
+ ]
1600
1599
  });
1601
- pdf.addImage(dataUrl, "PNG", 0, 0, imageWidth, imageHeight);
1602
- pdf.save("graph.pdf");
1603
- }
1600
+ });
1601
+ GraphFlowNode.displayName = "GraphFlowNode";
1604
1602
  //#endregion
1605
1603
  //#region src/components/ResourceView/Viewers/GraphFlow/index.tsx
1606
1604
  const nodeTypes = { node: GraphFlowNode };
1607
1605
  const edgeTypes = { graphflow: GraphFlowEdge };
1608
1606
  const GraphFlowCanvas = ({ visualization, isPreview, menuItems }) => {
1609
- const { nodes, edges, layoutReady, error, onNodesChange } = useLayout(visualization);
1610
- const { fitBounds, getNodes, getEdges, getNodesBounds } = useReactFlow();
1607
+ const { nodes, edges, ready, error, onNodesChange } = useGraphLayout(visualization);
1608
+ const { fitBounds, getNodes, getEdges } = useReactFlow();
1611
1609
  const wrapperRef = useRef(null);
1610
+ const { downloadPng, downloadSvg, downloadPdf } = useGraphExport(wrapperRef);
1612
1611
  const fitViewWithEdges = useCallback(() => {
1613
1612
  fitBounds(computeAutoFitBounds(getNodes(), getEdges()), { padding: FIT_VIEW_PADDING });
1614
1613
  }, [
@@ -1616,19 +1615,16 @@ const GraphFlowCanvas = ({ visualization, isPreview, menuItems }) => {
1616
1615
  getNodes,
1617
1616
  getEdges
1618
1617
  ]);
1619
- const handleDownloadPng = useCallback(() => wrapperRef.current && downloadPng(wrapperRef.current, getNodes(), getNodesBounds), [getNodes, getNodesBounds]);
1620
- const handleDownloadSvg = useCallback(() => wrapperRef.current && downloadSvg(wrapperRef.current, getNodes(), getNodesBounds), [getNodes, getNodesBounds]);
1621
- const handleDownloadPdf = useCallback(() => wrapperRef.current && downloadPdf(wrapperRef.current, getNodes(), getNodesBounds), [getNodes, getNodesBounds]);
1622
1618
  const wasReadyRef = useRef(false);
1623
1619
  useEffect(() => {
1624
- if (!layoutReady) {
1620
+ if (!ready) {
1625
1621
  wasReadyRef.current = false;
1626
1622
  return;
1627
1623
  }
1628
1624
  if (wasReadyRef.current) return;
1629
1625
  wasReadyRef.current = true;
1630
1626
  fitViewWithEdges();
1631
- }, [layoutReady, fitViewWithEdges]);
1627
+ }, [ready, fitViewWithEdges]);
1632
1628
  if (error) return /* @__PURE__ */ jsx(Stack, {
1633
1629
  h: "100%",
1634
1630
  justify: "center",
@@ -1644,16 +1640,16 @@ const GraphFlowCanvas = ({ visualization, isPreview, menuItems }) => {
1644
1640
  })
1645
1641
  });
1646
1642
  return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(LoadingOverlay, {
1647
- visible: !layoutReady,
1643
+ visible: !ready,
1648
1644
  zIndex: 10
1649
1645
  }), /* @__PURE__ */ jsx("div", {
1650
1646
  ref: wrapperRef,
1651
1647
  style: {
1652
1648
  width: "100%",
1653
1649
  height: "100%",
1654
- opacity: layoutReady ? 1 : 0
1650
+ opacity: ready ? 1 : 0
1655
1651
  },
1656
- children: /* @__PURE__ */ jsx(ReactFlow, {
1652
+ children: /* @__PURE__ */ jsxs(ReactFlow, {
1657
1653
  nodes,
1658
1654
  edges,
1659
1655
  nodeTypes,
@@ -1666,50 +1662,18 @@ const GraphFlowCanvas = ({ visualization, isPreview, menuItems }) => {
1666
1662
  panOnDrag: !isPreview,
1667
1663
  minZoom: .05,
1668
1664
  maxZoom: 3,
1669
- children: !isPreview && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Background, {
1665
+ children: [!isPreview && /* @__PURE__ */ jsx(Background, {
1670
1666
  color: "#e5e7eb",
1671
1667
  gap: 20,
1672
1668
  size: 1
1673
- }), /* @__PURE__ */ jsxs(Controls, {
1674
- showInteractive: false,
1675
- showFitView: false,
1676
- children: [
1677
- /* @__PURE__ */ jsx(ControlButton, {
1678
- onClick: fitViewWithEdges,
1679
- title: "fit view",
1680
- children: /* @__PURE__ */ jsx(Maximize2Icon, { size: 11 })
1681
- }),
1682
- /* @__PURE__ */ jsxs(Menu, {
1683
- position: "right",
1684
- withinPortal: false,
1685
- children: [/* @__PURE__ */ jsx(Menu.Target, { children: /* @__PURE__ */ jsx(ActionIcon, {
1686
- variant: "transparent",
1687
- children: /* @__PURE__ */ jsx(DownloadIcon, {
1688
- color: "black",
1689
- size: 18
1690
- })
1691
- }) }), /* @__PURE__ */ jsxs(Menu.Dropdown, { children: [
1692
- /* @__PURE__ */ jsx(Menu.Item, {
1693
- onClick: handleDownloadPng,
1694
- children: "PNG"
1695
- }),
1696
- /* @__PURE__ */ jsx(Menu.Item, {
1697
- onClick: handleDownloadSvg,
1698
- children: "SVG"
1699
- }),
1700
- /* @__PURE__ */ jsx(Menu.Item, {
1701
- onClick: handleDownloadPdf,
1702
- children: "PDF"
1703
- })
1704
- ] })]
1705
- }),
1706
- menuItems?.map((menuItem) => /* @__PURE__ */ jsx(ControlButton, {
1707
- onClick: menuItem.onClick,
1708
- title: menuItem.label,
1709
- children: menuItem.icon
1710
- }))
1711
- ]
1712
- })] })
1669
+ }), /* @__PURE__ */ jsx(GraphControls, {
1670
+ isPreview,
1671
+ onFitView: fitViewWithEdges,
1672
+ onDownloadPng: downloadPng,
1673
+ onDownloadSvg: downloadSvg,
1674
+ onDownloadPdf: downloadPdf,
1675
+ menuItems
1676
+ })]
1713
1677
  })
1714
1678
  })] });
1715
1679
  };