| 1 | import type { InternalHelpers, LayoutData, RenderOptions, SVG, SVGGroup } from 'mermaid'; |
| 2 | // @ts-ignore TODO: Investigate D3 issue |
| 3 | import { curveLinear } from 'd3'; |
| 4 | import ELK from 'elkjs/lib/elk.bundled.js'; |
| 5 | import { type TreeData, findCommonAncestor } from './find-common-ancestor.js'; |
| 6 | |
| 7 | import { |
| 8 | type P, |
| 9 | type RectLike, |
| 10 | outsideNode, |
| 11 | computeNodeIntersection, |
| 12 | replaceEndpoint, |
| 13 | onBorder, |
| 14 | } from './geometry.js'; |
| 15 | |
| 16 | type Node = LayoutData['nodes'][number]; |
| 17 | |
| 18 | // Minimal structural type to avoid depending on d3 Selection typings |
| 19 | interface D3Selection<T extends Element> { |
| 20 | node(): T | null; |
| 21 | attr(name: string, value: string): D3Selection<T>; |
| 22 | } |
| 23 | |
| 24 | interface LabelData { |
| 25 | width: number; |
| 26 | height: number; |
| 27 | wrappingWidth?: number; |
| 28 | labelNode?: SVGGElement | null; |
| 29 | } |
| 30 | |
| 31 | interface NodeWithVertex extends Omit<Node, 'domId'> { |
| 32 | children?: LayoutData['nodes']; |
| 33 | labelData?: LabelData; |
| 34 | domId?: D3Selection<SVGAElement | SVGGElement>; |
| 35 | } |
| 36 | |
| 37 | export 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 | |