36.4 KB1091 lines
Blame
1import type { InternalHelpers, LayoutData, RenderOptions, SVG, SVGGroup } from 'mermaid';
2// @ts-ignore TODO: Investigate D3 issue
3import { curveLinear } from 'd3';
4import ELK from 'elkjs/lib/elk.bundled.js';
5import { type TreeData, findCommonAncestor } from './find-common-ancestor.js';
6
7import {
8 type P,
9 type RectLike,
10 outsideNode,
11 computeNodeIntersection,
12 replaceEndpoint,
13 onBorder,
14} from './geometry.js';
15
16type Node = LayoutData['nodes'][number];
17
18// Minimal structural type to avoid depending on d3 Selection typings
19interface D3Selection<T extends Element> {
20 node(): T | null;
21 attr(name: string, value: string): D3Selection<T>;
22}
23
24interface LabelData {
25 width: number;
26 height: number;
27 wrappingWidth?: number;
28 labelNode?: SVGGElement | null;
29}
30
31interface NodeWithVertex extends Omit<Node, 'domId'> {
32 children?: LayoutData['nodes'];
33 labelData?: LabelData;
34 domId?: D3Selection<SVGAElement | SVGGElement>;
35}
36
37export const render = async (
38 data4Layout: LayoutData,
39 svg: SVG,
40 {
41 common,
42 getConfig,
43 insertCluster,
44 insertEdge,
45 insertEdgeLabel,
46 insertMarkers,
47 insertNode,
48 interpolateToCurve,
49 labelHelper,
50 log,
51 positionEdgeLabel,
52 }: InternalHelpers,
53 { algorithm }: RenderOptions
54) => {
55 const nodeDb: Record<string, any> = {};
56 const clusterDb: Record<string, any> = {};
57
58 const addVertex = async (
59 nodeEl: SVGGroup,
60 graph: { children: NodeWithVertex[] },
61 nodeArr: Node[],
62 node: Node
63 ) => {
64 const labelData: LabelData = { width: 0, height: 0 };
65
66 const config = getConfig();
67
68 // Add the element to the DOM
69 if (!node.isGroup) {
70 // const child = node as NodeWithVertex;
71 const child: NodeWithVertex = {
72 id: node.id,
73 width: node.width,
74 height: node.height,
75 // Store the original node data for later use
76 label: node.label,
77 isGroup: node.isGroup,
78 shape: node.shape,
79 padding: node.padding,
80 cssClasses: node.cssClasses,
81 cssStyles: node.cssStyles,
82 look: node.look,
83 // Include parentId for subgraph processing
84 parentId: node.parentId,
85 };
86 graph.children.push(child);
87 nodeDb[node.id] = node;
88
89 const childNodeEl = await insertNode(nodeEl, node, { config, dir: node.dir });
90 const boundingBox = childNodeEl.node()!.getBBox();
91 // Store the domId separately for rendering, not in the ELK graph
92 child.domId = childNodeEl;
93 child.width = boundingBox.width;
94 child.height = boundingBox.height;
95 } else {
96 // A subgraph
97 const child: NodeWithVertex & { children: NodeWithVertex[] } = {
98 ...node,
99 domId: undefined,
100 children: [],
101 };
102 // Let elk render with the copy
103 graph.children.push(child);
104 // Save the original containing the intersection function
105 nodeDb[node.id] = child;
106 await addVertices(nodeEl, nodeArr, child, node.id);
107
108 if (node.label) {
109 // @ts-ignore TODO: fix this
110 const { shapeSvg, bbox } = await labelHelper(nodeEl, node, undefined, true);
111 labelData.width = bbox.width;
112 labelData.wrappingWidth = config.flowchart!.wrappingWidth;
113 // Give some padding for elk
114 labelData.height = bbox.height - 2;
115 labelData.labelNode = shapeSvg.node();
116 // We need the label hight to be able to size the subgraph;
117 shapeSvg.remove();
118 } else {
119 // Subgraph without label
120 labelData.width = 0;
121 labelData.height = 0;
122 }
123 child.labelData = labelData;
124 child.domId = nodeEl;
125 }
126 };
127
128 const addVertices = async function (
129 nodeEl: SVGGroup,
130 nodeArr: Node[],
131 graph: { children: NodeWithVertex[] },
132 parentId?: string
133 ) {
134 const siblings = nodeArr.filter((node) => node?.parentId === parentId);
135 log.info('addVertices APA12', siblings, parentId);
136 // Iterate through each item in the vertex object (containing all the vertices found) in the graph definition
137 await Promise.all(
138 siblings.map(async (node) => {
139 await addVertex(nodeEl, graph, nodeArr, node);
140 })
141 );
142 return graph;
143 };
144
145 const drawNodes = async (
146 relX: number,
147 relY: number,
148 nodeArray: any[],
149 svg: any,
150 subgraphsEl: SVGGroup,
151 depth: number
152 ) => {
153 await Promise.all(
154 nodeArray.map(async function (node: {
155 id: string | number;
156 x: any;
157 y: any;
158 width: number;
159 labels: { width: any }[];
160 height: number;
161 isGroup: any;
162 labelData: any;
163 offset: { posX: number; posY: number };
164 shape: any;
165 domId: { node: () => any; attr: (arg0: string, arg1: string) => void };
166 }) {
167 if (node) {
168 nodeDb[node.id] ??= {};
169 nodeDb[node.id].offset = {
170 posX: node.x + relX,
171 posY: node.y + relY,
172 x: relX,
173 y: relY,
174 depth,
175 width: Math.max(node.width, node.labels ? node.labels[0]?.width || 0 : 0),
176 height: node.height,
177 };
178 if (node.isGroup) {
179 log.debug('Id abc88 subgraph = ', node.id, node.x, node.y, node.labelData);
180 const subgraphEl = subgraphsEl.insert('g').attr('class', 'subgraph');
181 // TODO use faster way of cloning
182 const clusterNode = JSON.parse(JSON.stringify(node));
183 clusterNode.x = node.offset.posX + node.width / 2;
184 clusterNode.y = node.offset.posY + node.height / 2;
185 clusterNode.width = Math.max(clusterNode.width, node.labelData.width);
186 await insertCluster(subgraphEl, clusterNode);
187
188 log.debug('Id (UIO)= ', node.id, node.width, node.shape, node.labels);
189 } else {
190 log.info(
191 'Id NODE = ',
192 node.id,
193 node.x,
194 node.y,
195 relX,
196 relY,
197 node.domId.node(),
198 `translate(${node.x + relX + node.width / 2}, ${node.y + relY + node.height / 2})`
199 );
200 node.domId.attr(
201 'transform',
202 `translate(${node.x + relX + node.width / 2}, ${node.y + relY + node.height / 2})`
203 );
204 }
205 }
206 })
207 );
208
209 await Promise.all(
210 nodeArray.map(async function (node: { isGroup: any; x: any; y: any; children: any }) {
211 if (node?.isGroup) {
212 await drawNodes(relX + node.x, relY + node.y, node.children, svg, subgraphsEl, depth + 1);
213 }
214 })
215 );
216 };
217
218 const addSubGraphs = (nodeArr: any[]): TreeData => {
219 const parentLookupDb: TreeData = { parentById: {}, childrenById: {} };
220 const subgraphs = nodeArr.filter((node: { isGroup: any }) => node.isGroup);
221 log.info('Subgraphs - ', subgraphs);
222 subgraphs.forEach((subgraph: { id: string }) => {
223 const children = nodeArr.filter((node: { parentId: any }) => node.parentId === subgraph.id);
224 children.forEach((node: any) => {
225 parentLookupDb.parentById[node.id] = subgraph.id;
226 if (parentLookupDb.childrenById[subgraph.id] === undefined) {
227 parentLookupDb.childrenById[subgraph.id] = [];
228 }
229 parentLookupDb.childrenById[subgraph.id].push(node);
230 });
231 });
232
233 return parentLookupDb;
234 };
235
236 const getEdgeStartEndPoint = (edge: any) => {
237 // edge.start and edge.end are IDs (string/number) in our layout data
238 const sourceId: string | number = edge.start;
239 const targetId: string | number = edge.end;
240
241 const source = sourceId;
242 const target = targetId;
243
244 const startNode = nodeDb[sourceId];
245 const endNode = nodeDb[targetId];
246
247 if (!startNode || !endNode) {
248 return { source, target };
249 }
250
251 // Add the edge to the graph
252 return { source, target, sourceId, targetId };
253 };
254
255 const calcOffset = function (src: string, dest: string, parentLookupDb: TreeData) {
256 const ancestor = findCommonAncestor(src, dest, parentLookupDb);
257 if (ancestor === undefined || ancestor === 'root') {
258 return { x: 0, y: 0 };
259 }
260
261 const ancestorOffset = nodeDb[ancestor].offset;
262 return { x: ancestorOffset.posX, y: ancestorOffset.posY };
263 };
264
265 /**
266 * Add edges to graph based on parsed graph definition
267 */
268 // Edge helper maps and utilities (de-duplicated)
269 const ARROW_MAP: Record<string, [string, string]> = {
270 arrow_open: ['arrow_open', 'arrow_open'],
271 arrow_cross: ['arrow_open', 'arrow_cross'],
272 double_arrow_cross: ['arrow_cross', 'arrow_cross'],
273 arrow_point: ['arrow_open', 'arrow_point'],
274 double_arrow_point: ['arrow_point', 'arrow_point'],
275 arrow_circle: ['arrow_open', 'arrow_circle'],
276 double_arrow_circle: ['arrow_circle', 'arrow_circle'],
277 };
278
279 const computeStroke = (
280 stroke: string | undefined,
281 defaultStyle?: string,
282 defaultLabelStyle?: string
283 ) => {
284 // Defaults correspond to 'normal'
285 let thickness = 'normal';
286 let pattern = 'solid';
287 let style = '';
288 let labelStyle = '';
289
290 if (stroke === 'dotted') {
291 pattern = 'dotted';
292 style = 'fill:none;stroke-width:2px;stroke-dasharray:3;';
293 } else if (stroke === 'thick') {
294 thickness = 'thick';
295 style = 'stroke-width: 3.5px;fill:none;';
296 } else {
297 // normal
298 style = defaultStyle ?? 'fill:none;';
299 if (defaultLabelStyle !== undefined) {
300 labelStyle = defaultLabelStyle;
301 }
302 }
303 return { thickness, pattern, style, labelStyle };
304 };
305
306 const getCurve = (edgeInterpolate: any, edgesDefaultInterpolate: any, confCurve: any) => {
307 if (edgeInterpolate !== undefined) {
308 return interpolateToCurve(edgeInterpolate, curveLinear);
309 }
310 if (edgesDefaultInterpolate !== undefined) {
311 return interpolateToCurve(edgesDefaultInterpolate, curveLinear);
312 }
313 // @ts-ignore TODO: fix this
314 return interpolateToCurve(confCurve, curveLinear);
315 };
316 const buildEdgeData = (
317 edge: any,
318 defaults: {
319 defaultStyle?: string;
320 defaultLabelStyle?: string;
321 defaultInterpolate?: any;
322 confCurve: any;
323 },
324 common: any
325 ) => {
326 const edgeData: any = { style: '', labelStyle: '' };
327 edgeData.minlen = edge.length || 1;
328 // maintain legacy behavior
329 edge.text = edge.label;
330
331 // Arrowhead fill vs none
332 edgeData.arrowhead = edge.type === 'arrow_open' ? 'none' : 'normal';
333
334 // Arrow types
335 const arrowMap = ARROW_MAP[edge.type] ?? ARROW_MAP.arrow_open;
336 edgeData.arrowTypeStart = arrowMap[0];
337 edgeData.arrowTypeEnd = arrowMap[1];
338
339 // Optional edge label positioning flags
340 edgeData.startLabelRight = edge.startLabelRight;
341 edgeData.endLabelLeft = edge.endLabelLeft;
342
343 // Stroke
344 const strokeRes = computeStroke(edge.stroke, defaults.defaultStyle, defaults.defaultLabelStyle);
345 edgeData.thickness = strokeRes.thickness;
346 edgeData.pattern = strokeRes.pattern;
347 edgeData.style = (edgeData.style || '') + (strokeRes.style || '');
348 edgeData.labelStyle = (edgeData.labelStyle || '') + (strokeRes.labelStyle || '');
349
350 // Curve
351 // @ts-ignore - defaults.confCurve is present at runtime but missing in type
352 edgeData.curve = getCurve(edge.interpolate, defaults.defaultInterpolate, defaults.confCurve);
353
354 // Arrowhead style + labelpos when we have label text
355 const hasText = (edge?.text ?? '') !== '';
356 if (hasText) {
357 edgeData.arrowheadStyle = 'fill: #333';
358 edgeData.labelpos = 'c';
359 } else if (edge.style !== undefined) {
360 edgeData.arrowheadStyle = 'fill: #333';
361 }
362
363 edgeData.labelType = edge.labelType;
364 edgeData.label = (edge?.text ?? '').replace(common.lineBreakRegex, '\n');
365
366 if (edge.style === undefined) {
367 edgeData.style = edgeData.style ?? 'stroke: #333; stroke-width: 1.5px;fill:none;';
368 }
369
370 edgeData.labelStyle = edgeData.labelStyle.replace('color:', 'fill:');
371 return edgeData;
372 };
373
374 const addEdges = async function (
375 dataForLayout: { edges: any; direction?: string },
376 graph: {
377 id?: string;
378 layoutOptions?: {
379 'elk.hierarchyHandling': string;
380 'elk.algorithm': any;
381 'nodePlacement.strategy': any;
382 'elk.layered.mergeEdges': any;
383 'elk.direction': string;
384 'spacing.baseValue': number;
385 };
386 children?: never[];
387 edges: any;
388 },
389 svg: SVG
390 ) {
391 log.info('abc78 DAGA edges = ', dataForLayout);
392 const edges = dataForLayout.edges;
393 const labelsEl = svg.insert('g').attr('class', 'edgeLabels');
394 const linkIdCnt: any = {};
395 let defaultStyle: string | undefined;
396 let defaultLabelStyle: string | undefined;
397
398 await Promise.all(
399 edges.map(async function (edge: {
400 id: string;
401 start: string;
402 end: string;
403 length: number;
404 text: undefined;
405 label: any;
406 type: string;
407 stroke: any;
408 interpolate: undefined;
409 style: undefined;
410 labelType: any;
411 startLabelRight?: string;
412 endLabelLeft?: string;
413 }) {
414 // Identify Link
415 const linkIdBase = edge.id; // 'L-' + edge.start + '-' + edge.end;
416 // count the links from+to the same node to give unique id
417 if (linkIdCnt[linkIdBase] === undefined) {
418 linkIdCnt[linkIdBase] = 0;
419 log.info('abc78 new entry', linkIdBase, linkIdCnt[linkIdBase]);
420 } else {
421 linkIdCnt[linkIdBase]++;
422 log.info('abc78 new entry', linkIdBase, linkIdCnt[linkIdBase]);
423 }
424 const linkId = linkIdBase; // + '_' + linkIdCnt[linkIdBase];
425 edge.id = linkId;
426 log.info('abc78 new link id to be used is', linkIdBase, linkId, linkIdCnt[linkIdBase]);
427 const linkNameStart = 'LS_' + edge.start;
428 const linkNameEnd = 'LE_' + edge.end;
429
430 const conf = getConfig();
431 const edgeData = buildEdgeData(
432 edge,
433 {
434 defaultStyle,
435 defaultLabelStyle,
436 defaultInterpolate: edges.defaultInterpolate,
437 // @ts-ignore - conf.curve exists at runtime but is missing from typing
438 confCurve: conf.curve,
439 },
440 common
441 );
442
443 edgeData.id = linkId;
444 edgeData.classes = 'flowchart-link ' + linkNameStart + ' ' + linkNameEnd;
445
446 const labelEl = await insertEdgeLabel(labelsEl, edgeData);
447
448 // calculate start and end points of the edge, note that the source and target
449 // can be modified for shapes that have ports
450
451 const { source, target, sourceId, targetId } = getEdgeStartEndPoint(edge);
452 log.debug('abc78 source and target', source, target);
453 // Add the edge to the graph
454 graph.edges.push({
455 ...edge,
456 sources: [source],
457 targets: [target],
458 sourceId,
459 targetId,
460 labelEl: labelEl,
461 labels: [
462 {
463 width: edgeData.width,
464 height: edgeData.height,
465 orgWidth: edgeData.width,
466 orgHeight: edgeData.height,
467 text: edgeData.label,
468 layoutOptions: {
469 'edgeLabels.inline': 'true',
470 'edgeLabels.placement': 'CENTER',
471 },
472 },
473 ],
474 edgeData,
475 });
476 })
477 );
478 return graph;
479 };
480
481 function dir2ElkDirection(dir: any) {
482 switch (dir) {
483 case 'LR':
484 return 'RIGHT';
485 case 'RL':
486 return 'LEFT';
487 case 'TB':
488 case 'TD': // TD is an alias for TB in Mermaid
489 return 'DOWN';
490 case 'BT':
491 return 'UP';
492 default:
493 return 'DOWN';
494 }
495 }
496
497 function setIncludeChildrenPolicy(nodeId: string, ancestorId: string) {
498 const node = nodeDb[nodeId];
499
500 if (!node) {
501 return;
502 }
503 if (node?.layoutOptions === undefined) {
504 node.layoutOptions = {};
505 }
506 node.layoutOptions['elk.hierarchyHandling'] = 'INCLUDE_CHILDREN';
507 if (node.id !== ancestorId) {
508 setIncludeChildrenPolicy(node.parentId, ancestorId);
509 }
510 }
511
512 // Node bounds helpers (global)
513 const getEffectiveGroupWidth = (node: any): number => {
514 const labelW = node?.labels?.[0]?.width ?? 0;
515 const padding = node?.padding ?? 0;
516 return Math.max(node.width ?? 0, labelW + padding);
517 };
518
519 const boundsFor = (node: any): RectLike => {
520 const width = node?.isGroup ? getEffectiveGroupWidth(node) : node.width;
521 return {
522 x: node.offset.posX + node.width / 2,
523 y: node.offset.posY + node.height / 2,
524 width,
525 height: node.height,
526 padding: node.padding,
527 };
528 };
529 // Helper utilities for endpoint handling around cutter2
530 type Side = 'start' | 'end';
531 const approxEq = (a: number, b: number, eps = 1e-6) => Math.abs(a - b) < eps;
532 const isCenterApprox = (pt: P, node: { x: number; y: number }) =>
533 approxEq(pt.x, node.x) && approxEq(pt.y, node.y);
534
535 const getCandidateBorderPoint = (
536 points: P[],
537 node: any,
538 side: Side
539 ): { candidate: P; centerApprox: boolean } => {
540 if (!points?.length) {
541 return { candidate: { x: node.x, y: node.y } as P, centerApprox: true };
542 }
543 if (side === 'start') {
544 const first = points[0];
545 const centerApprox = isCenterApprox(first, node);
546 const candidate = centerApprox && points.length > 1 ? points[1] : first;
547 return { candidate, centerApprox };
548 } else {
549 const last = points[points.length - 1];
550 const centerApprox = isCenterApprox(last, node);
551 const candidate = centerApprox && points.length > 1 ? points[points.length - 2] : last;
552 return { candidate, centerApprox };
553 }
554 };
555
556 const dropAutoCenterPoint = (points: P[], side: Side, doDrop: boolean) => {
557 if (!doDrop) {
558 return;
559 }
560 if (side === 'start') {
561 if (points.length > 0) {
562 points.shift();
563 }
564 } else {
565 if (points.length > 0) {
566 points.pop();
567 }
568 }
569 };
570
571 const applyStartIntersectionIfNeeded = (points: P[], startNode: any, startBounds: RectLike) => {
572 let firstOutsideStartIndex = -1;
573 for (const [i, p] of points.entries()) {
574 if (outsideNode(startBounds, p)) {
575 firstOutsideStartIndex = i;
576 break;
577 }
578 }
579 if (firstOutsideStartIndex !== -1) {
580 const outsidePointForStart = points[firstOutsideStartIndex];
581 const startCenter = points[0];
582 const startIntersection = computeNodeIntersection(
583 startNode,
584 startBounds,
585 outsidePointForStart,
586 startCenter
587 );
588 replaceEndpoint(points, 'start', startIntersection);
589 log.debug('UIO cutter2: start-only intersection applied', { startIntersection });
590 }
591 };
592
593 const applyEndIntersectionIfNeeded = (points: P[], endNode: any, endBounds: RectLike) => {
594 let outsideIndexForEnd = -1;
595 for (let i = points.length - 1; i >= 0; i--) {
596 if (outsideNode(endBounds, points[i])) {
597 outsideIndexForEnd = i;
598 break;
599 }
600 }
601 if (outsideIndexForEnd !== -1) {
602 const outsidePointForEnd = points[outsideIndexForEnd];
603 const endCenter = points[points.length - 1];
604 const endIntersection = computeNodeIntersection(
605 endNode,
606 endBounds,
607 outsidePointForEnd,
608 endCenter
609 );
610 replaceEndpoint(points, 'end', endIntersection);
611 log.debug('UIO cutter2: end-only intersection applied', { endIntersection });
612 }
613 };
614
615 const cutter2 = (startNode: any, endNode: any, _points: any[]) => {
616 const startBounds = boundsFor(startNode);
617 const endBounds = boundsFor(endNode);
618
619 if (_points.length === 0) {
620 return [];
621 }
622
623 // Copy the original points array
624 const points: P[] = [..._points] as P[];
625
626 // The first point is the center of sNode, the last point is the center of eNode
627 const startCenter = points[0];
628 const endCenter = points[points.length - 1];
629
630 // Minimal, structured logging for diagnostics
631 log.debug('PPP cutter2: bounds', { startBounds, endBounds });
632 log.debug('PPP cutter2: original points', _points);
633
634 let firstOutsideStartIndex = -1;
635
636 // Single iteration through the array
637 for (const [i, point] of points.entries()) {
638 if (firstOutsideStartIndex === -1 && outsideNode(startBounds, point)) {
639 firstOutsideStartIndex = i;
640 }
641 if (outsideNode(endBounds, point)) {
642 // keep scanning; we'll also scan from the end for the last outside point
643 }
644 }
645
646 // Calculate intersection with start node if we found a point outside it
647 if (firstOutsideStartIndex !== -1) {
648 const outsidePointForStart = points[firstOutsideStartIndex];
649 const startIntersection = computeNodeIntersection(
650 startNode,
651 startBounds,
652 outsidePointForStart,
653 startCenter
654 );
655 log.debug('UIO cutter2: start intersection', startIntersection);
656 replaceEndpoint(points, 'start', startIntersection);
657 }
658
659 // Calculate intersection with end node
660 let outsidePointForEnd = null;
661 let outsideIndexForEnd = -1;
662
663 for (let i = points.length - 1; i >= 0; i--) {
664 if (outsideNode(endBounds, points[i])) {
665 outsidePointForEnd = points[i];
666 outsideIndexForEnd = i;
667 break;
668 }
669 }
670
671 if (!outsidePointForEnd && points.length > 1) {
672 outsidePointForEnd = points[points.length - 2];
673 outsideIndexForEnd = points.length - 2;
674 }
675
676 if (outsidePointForEnd) {
677 const endIntersection = computeNodeIntersection(
678 endNode,
679 endBounds,
680 outsidePointForEnd,
681 endCenter
682 );
683 log.debug('UIO cutter2: end intersection', { endIntersection, outsideIndexForEnd });
684 replaceEndpoint(points, 'end', endIntersection);
685 }
686
687 // Final cleanup: Check if the last point is too close to the previous point
688 if (points.length > 1) {
689 const lastPoint = points[points.length - 1];
690 const secondLastPoint = points[points.length - 2];
691 const distance = Math.sqrt(
692 (lastPoint.x - secondLastPoint.x) ** 2 + (lastPoint.y - secondLastPoint.y) ** 2
693 );
694 if (distance < 2) {
695 log.debug('UIO cutter2: trimming tail point (too close)', {
696 distance,
697 lastPoint,
698 secondLastPoint,
699 });
700 points.pop();
701 }
702 }
703
704 log.debug('UIO cutter2: final points', points);
705
706 return points;
707 };
708
709 // @ts-ignore - ELK is not typed
710 const elk = new ELK();
711 const element = svg.select('g');
712 // Add the arrowheads to the svg
713 insertMarkers(element, data4Layout.markers, data4Layout.type, data4Layout.diagramId);
714
715 // Setup the graph with the layout options and the data for the layout
716 let elkGraph: any = {
717 id: 'root',
718 layoutOptions: {
719 'elk.hierarchyHandling': 'INCLUDE_CHILDREN',
720 'elk.algorithm': algorithm,
721 'nodePlacement.strategy': data4Layout.config.elk?.nodePlacementStrategy,
722 'elk.layered.mergeEdges': data4Layout.config.elk?.mergeEdges,
723 'elk.direction': 'DOWN',
724 'spacing.baseValue': 40,
725 'elk.layered.crossingMinimization.forceNodeModelOrder':
726 data4Layout.config.elk?.forceNodeModelOrder,
727 'elk.layered.considerModelOrder.strategy': data4Layout.config.elk?.considerModelOrder,
728 'elk.layered.unnecessaryBendpoints': true,
729 'elk.layered.cycleBreaking.strategy': data4Layout.config.elk?.cycleBreakingStrategy,
730
731 // 'elk.layered.cycleBreaking.strategy': 'GREEDY_MODEL_ORDER',
732 // 'elk.layered.cycleBreaking.strategy': 'MODEL_ORDER',
733 // 'spacing.nodeNode': 20,
734 // 'spacing.nodeNodeBetweenLayers': 25,
735 // 'spacing.edgeNode': 20,
736 // 'spacing.edgeNodeBetweenLayers': 10,
737 // 'spacing.edgeEdge': 10,
738 // 'spacing.edgeEdgeBetweenLayers': 20,
739 // 'spacing.nodeSelfLoop': 20,
740
741 // Tweaking options
742 // 'nodePlacement.favorStraightEdges': true,
743 // 'elk.layered.nodePlacement.favorStraightEdges': true,
744 // 'nodePlacement.feedbackEdges': true,
745 'elk.layered.wrapping.multiEdge.improveCuts': true,
746 'elk.layered.wrapping.multiEdge.improveWrappedEdges': true,
747 // 'elk.layered.wrapping.strategy': 'MULTI_EDGE',
748 // 'elk.layered.wrapping.strategy': 'SINGLE_EDGE',
749 'elk.layered.edgeRouting.selfLoopDistribution': 'EQUALLY',
750 'elk.layered.mergeHierarchyEdges': true,
751
752 // 'elk.layered.feedbackEdges': true,
753 // 'elk.layered.crossingMinimization.semiInteractive': true,
754 // 'elk.layered.edgeRouting.splines.sloppy.layerSpacingFactor': 1,
755 // 'elk.layered.edgeRouting.polyline.slopedEdgeZoneWidth': 4.0,
756 // 'elk.layered.wrapping.validify.strategy': 'LOOK_BACK',
757 // 'elk.insideSelfLoops.activate': true,
758 // 'elk.separateConnectedComponents': true,
759 // 'elk.alg.layered.options.EdgeStraighteningStrategy': 'NONE',
760 // 'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES', // NODES_AND_EDGES
761 // 'elk.layered.considerModelOrder.strategy': 'EDGES', // NODES_AND_EDGES
762 // 'elk.layered.wrapping.cutting.strategy': 'ARD', // NODES_AND_EDGES
763 },
764 children: [],
765 edges: [],
766 };
767
768 log.info('Drawing flowchart using v4 renderer', elk);
769
770 // Set the direction of the graph based on the parsed information
771 const dir = data4Layout.direction ?? 'DOWN';
772 elkGraph.layoutOptions['elk.direction'] = dir2ElkDirection(dir);
773
774 // Create the lookup db for the subgraphs and their children to used when creating
775 // the tree structured graph
776 const parentLookupDb: any = addSubGraphs(data4Layout.nodes);
777
778 // Add elements in the svg to be used to hold the subgraphs container
779 // elements and the nodes
780 const subGraphsEl = svg.insert('g').attr('class', 'subgraphs');
781
782 const nodeEl = svg.insert('g').attr('class', 'nodes');
783
784 // Add the nodes to the graph, this will entail creating the actual nodes
785 // in order to get the size of the node. You can't get the size of a node
786 // that is not in the dom so we need to add it to the dom, get the size
787 // we will position the nodes when we get the layout from elkjs
788 elkGraph = await addVertices(nodeEl, data4Layout.nodes, elkGraph);
789 // Time for the edges, we start with adding an element in the node to hold the edges
790 const edgesEl = svg.insert('g').attr('class', 'edges edgePaths');
791
792 // Add the edges to the elk graph, this will entail creating the actual edges
793 elkGraph = await addEdges(data4Layout, elkGraph, svg);
794
795 // Iterate through all nodes and add the top level nodes to the graph
796 const nodes = data4Layout.nodes;
797 nodes.forEach((n: { id: string | number }) => {
798 const node = nodeDb[n.id];
799
800 // Subgraph
801 if (parentLookupDb.childrenById[node.id] !== undefined) {
802 // Set label and adjust node width separately (avoid side effects in labels array)
803 node.labels = [
804 {
805 text: node.label,
806 width: node?.labelData?.width ?? 50,
807 height: node?.labelData?.height ?? 50,
808 },
809 ];
810 node.width = node.width + 2 * node.padding;
811 log.debug('UIO node label', node?.labelData?.width, node.padding);
812 node.layoutOptions = {
813 'spacing.baseValue': 30,
814 'nodeLabels.placement': '[H_CENTER V_TOP, INSIDE]',
815 };
816 if (node.dir) {
817 node.layoutOptions = {
818 ...node.layoutOptions,
819 'elk.algorithm': algorithm,
820 'elk.direction': dir2ElkDirection(node.dir),
821 'nodePlacement.strategy': data4Layout.config.elk?.nodePlacementStrategy,
822 'elk.layered.mergeEdges': data4Layout.config.elk?.mergeEdges,
823 'elk.hierarchyHandling': 'SEPARATE_CHILDREN',
824 };
825 }
826 delete node.x;
827 delete node.y;
828 delete node.width;
829 delete node.height;
830 }
831 });
832 log.debug('APA01 processing edges, count:', elkGraph.edges.length);
833 elkGraph.edges.forEach((edge: any, index: number) => {
834 log.debug('APA01 processing edge', index, ':', edge);
835 const source = edge.sources[0];
836 const target = edge.targets[0];
837 log.debug('APA01 source:', source, 'target:', target);
838 log.debug('APA01 nodeDb[source]:', nodeDb[source]);
839 log.debug('APA01 nodeDb[target]:', nodeDb[target]);
840
841 if (nodeDb[source] && nodeDb[target] && nodeDb[source].parentId !== nodeDb[target].parentId) {
842 const ancestorId = findCommonAncestor(source, target, parentLookupDb);
843 // an edge that breaks a subgraph has been identified, set configuration accordingly
844 setIncludeChildrenPolicy(source, ancestorId);
845 setIncludeChildrenPolicy(target, ancestorId);
846 }
847 });
848
849 log.debug('APA01 before');
850 log.debug('APA01 elkGraph structure:', JSON.stringify(elkGraph, null, 2));
851 log.debug('APA01 elkGraph.children length:', elkGraph.children?.length);
852 log.debug('APA01 elkGraph.edges length:', elkGraph.edges?.length);
853
854 // Validate that all edge references exist as nodes
855 elkGraph.edges?.forEach((edge: any, index: number) => {
856 log.debug(`APA01 validating edge ${index}:`, edge);
857 if (edge.sources) {
858 edge.sources.forEach((sourceId: any) => {
859 const sourceExists = elkGraph.children?.some((child: any) => child.id === sourceId);
860 log.debug(`APA01 source ${sourceId} exists:`, sourceExists);
861 });
862 }
863 if (edge.targets) {
864 edge.targets.forEach((targetId: any) => {
865 const targetExists = elkGraph.children?.some((child: any) => child.id === targetId);
866 log.debug(`APA01 target ${targetId} exists:`, targetExists);
867 });
868 }
869 });
870
871 let g;
872 try {
873 g = await elk.layout(elkGraph);
874 log.debug('APA01 after - success');
875 log.info('APA01 layout result:', JSON.stringify(g, null, 2));
876 } catch (error) {
877 log.error('APA01 ELK layout error:', error);
878 log.error('APA01 elkGraph that caused error:', JSON.stringify(elkGraph, null, 2));
879 throw error;
880 }
881
882 // debugger;
883 await drawNodes(0, 0, g.children, svg, subGraphsEl, 0);
884
885 g.edges?.map(
886 (edge: {
887 sources: (string | number)[];
888 targets: (string | number)[];
889 start: any;
890 end: any;
891 sections: { startPoint: any; endPoint: any; bendPoints: any }[];
892 points: any[];
893 x: any;
894 labels: { height: number; width: number; x: number; y: number }[];
895 y: any;
896 }) => {
897 // (elem, edge, clusterDb, diagramType, graph, id)
898 const startNode = nodeDb[edge.sources[0]];
899 const startCluster = parentLookupDb[edge.sources[0]];
900 const endNode = nodeDb[edge.targets[0]];
901 const sourceId = edge.start;
902 const targetId = edge.end;
903
904 const offset = calcOffset(sourceId, targetId, parentLookupDb);
905 log.debug(
906 'APA18 offset',
907 offset,
908 sourceId,
909 ' ==> ',
910 targetId,
911 'edge:',
912 edge,
913 'cluster:',
914 startCluster,
915 startNode
916 );
917 if (edge.sections) {
918 const src = edge.sections[0].startPoint;
919 const dest = edge.sections[0].endPoint;
920 const segments = edge.sections[0].bendPoints ? edge.sections[0].bendPoints : [];
921
922 const segPoints = segments.map((segment: { x: any; y: any }) => {
923 return { x: segment.x + offset.x, y: segment.y + offset.y };
924 });
925 edge.points = [
926 { x: src.x + offset.x, y: src.y + offset.y },
927 ...segPoints,
928 { x: dest.x + offset.x, y: dest.y + offset.y },
929 ];
930
931 let sw = startNode.width;
932 let ew = endNode.width;
933 if (startNode.isGroup) {
934 const bbox = startNode.domId.node().getBBox();
935 // sw = Math.max(bbox.width, startNode.width, startNode.labels[0].width);
936 sw = Math.max(startNode.width, startNode.labels[0].width + startNode.padding);
937 // sw = startNode.width;
938 log.info(
939 'UIO width',
940 startNode.id,
941 startNode.width,
942 'bbox.width=',
943 bbox.width,
944 'lw=',
945 startNode.labels[0].width,
946 'node:',
947 startNode.width,
948 'SW = ',
949 sw
950 // 'HTML:',
951 // startNode.domId.node().innerHTML
952 );
953 }
954 if (endNode.isGroup) {
955 const bbox = endNode.domId.node().getBBox();
956 ew = Math.max(endNode.width, endNode.labels[0].width + endNode.padding);
957
958 log.debug(
959 'UIO width',
960 startNode.id,
961 startNode.width,
962 bbox.width,
963 'EW = ',
964 ew,
965 'HTML:',
966 startNode.innerHTML
967 );
968 }
969 startNode.x = startNode.offset.posX + startNode.width / 2;
970 startNode.y = startNode.offset.posY + startNode.height / 2;
971 endNode.x = endNode.offset.posX + endNode.width / 2;
972 endNode.y = endNode.offset.posY + endNode.height / 2;
973
974 // Only add center points for non-subgraph nodes or when the edge path doesn't already end near the target
975 const shouldAddStartCenter = startNode.shape !== 'rect33';
976 const shouldAddEndCenter = endNode.shape !== 'rect33';
977
978 if (shouldAddStartCenter) {
979 edge.points.unshift({
980 x: startNode.x,
981 y: startNode.y,
982 });
983 }
984
985 if (shouldAddEndCenter) {
986 edge.points.push({
987 x: endNode.x,
988 y: endNode.y,
989 });
990 }
991
992 // Debug and sanitize points around cutter2
993 const prevPoints = Array.isArray(edge.points) ? [...edge.points] : [];
994 const endBounds = boundsFor(endNode);
995 log.debug(
996 'PPP cutter2: Points before cutter2:',
997 JSON.stringify(edge.points),
998 'endBounds:',
999 endBounds,
1000 onBorder(endBounds, edge.points[edge.points.length - 1])
1001 );
1002 // Block for reducing variable scope and guardrails for the cutter function
1003 {
1004 const startBounds = boundsFor(startNode);
1005 const endBounds = boundsFor(endNode);
1006
1007 const startIsGroup = !!startNode?.isGroup;
1008 const endIsGroup = !!endNode?.isGroup;
1009
1010 const { candidate: startCandidate, centerApprox: startCenterApprox } =
1011 getCandidateBorderPoint(prevPoints as P[], startNode, 'start');
1012 const { candidate: endCandidate, centerApprox: endCenterApprox } =
1013 getCandidateBorderPoint(prevPoints as P[], endNode, 'end');
1014
1015 const skipStart = startIsGroup && onBorder(startBounds, startCandidate);
1016 const skipEnd = endIsGroup && onBorder(endBounds, endCandidate);
1017
1018 dropAutoCenterPoint(prevPoints as P[], 'start', skipStart && startCenterApprox);
1019 dropAutoCenterPoint(prevPoints as P[], 'end', skipEnd && endCenterApprox);
1020
1021 if (skipStart || skipEnd) {
1022 if (!skipStart) {
1023 applyStartIntersectionIfNeeded(prevPoints as P[], startNode, startBounds);
1024 }
1025 if (!skipEnd) {
1026 applyEndIntersectionIfNeeded(prevPoints as P[], endNode, endBounds);
1027 }
1028
1029 log.debug('PPP cutter2: skipping cutter2 due to on-border group endpoint(s)', {
1030 skipStart,
1031 skipEnd,
1032 startCenterApprox,
1033 endCenterApprox,
1034 startCandidate,
1035 endCandidate,
1036 });
1037 edge.points = prevPoints;
1038 } else {
1039 edge.points = cutter2(startNode, endNode, prevPoints);
1040 }
1041 }
1042 log.debug('PPP cutter2: Points after cutter2:', JSON.stringify(edge.points));
1043 const hasNaN = (pts: { x: number; y: number }[]) =>
1044 pts?.some((p) => !Number.isFinite(p?.x) || !Number.isFinite(p?.y));
1045 if (!Array.isArray(edge.points) || edge.points.length < 2 || hasNaN(edge.points)) {
1046 log.warn(
1047 'POI cutter2: Invalid points from cutter2, falling back to prevPoints',
1048 edge.points
1049 );
1050 // Fallback to previous points and strip any invalid ones just in case
1051 const cleaned = prevPoints.filter((p) => Number.isFinite(p?.x) && Number.isFinite(p?.y));
1052 edge.points = cleaned.length >= 2 ? cleaned : prevPoints;
1053 }
1054 log.debug('UIO cutter2: Points after cutter2 (sanitized):', edge.points);
1055 // Remove consecutive duplicate points to avoid zero-length segments in path builders
1056 const deduped = edge.points.filter(
1057 (p: { x: number; y: number }, i: number, arr: { x: number; y: number }[]) => {
1058 if (i === 0) {
1059 return true;
1060 }
1061 const prev = arr[i - 1];
1062 return Math.abs(p.x - prev.x) > 1e-6 || Math.abs(p.y - prev.y) > 1e-6;
1063 }
1064 );
1065 if (deduped.length !== edge.points.length) {
1066 log.debug('UIO cutter2: removed consecutive duplicate points', {
1067 before: edge.points,
1068 after: deduped,
1069 });
1070 }
1071 edge.points = deduped;
1072 const paths = insertEdge(
1073 edgesEl,
1074 edge,
1075 clusterDb,
1076 data4Layout.type,
1077 startNode,
1078 endNode,
1079 data4Layout.diagramId,
1080 true
1081 );
1082 log.info('APA12 edge points after insert', JSON.stringify(edge.points));
1083
1084 edge.x = edge.labels[0].x + offset.x + edge.labels[0].width / 2;
1085 edge.y = edge.labels[0].y + offset.y + edge.labels[0].height / 2;
1086 positionEdgeLabel(edge, paths);
1087 }
1088 }
1089 );
1090};
1091