diff --git a/cypress/platform/knsv2.html b/cypress/platform/knsv2.html index 455af9429..6a08fa49b 100644 --- a/cypress/platform/knsv2.html +++ b/cypress/platform/knsv2.html @@ -105,10 +105,39 @@
-+--- config: layout: elk + flowchart: + curve: linear + --- + flowchart LR + A[A] -- Mermaid js --> B[B] + A[A] -- Mermaid js --- B[B] + A@{ shape: diamond} + B@{ shape: diamond} + +++ --- + config: + layout: elk + flowchart: + curve: linear + --- + flowchart LR + D["Use the editor"] -- Mermaid js --> I["fa:fa-code Text"] + I --> D & D + D@{ shape: question} + I@{ shape: question} +++ --- + config: + layout: elk + flowchart: + curve: linear --- flowchart LR D["Use the editor"] -- Mermaid js --> I["fa:fa-code Text"] @@ -121,24 +150,27 @@ --- config: layout: elk + --- flowchart LR %% subgraph s1["Untitled subgraph"] - C{"Evaluate"} + C["Evaluate"] %% end B --> C-+--- config: layout: elk + flowchart: + curve: linear --- flowchart LR %% A ==> B %% A2 --> B2 - D --> I((I the Circle)) - D --> I +A{A} --> B((Bo boo)) & B & B & B +--- @@ -456,7 +488,7 @@ kanban // look: 'handDrawn', // 'elk.nodePlacement.strategy': 'NETWORK_SIMPLEX', // layout: 'dagre', - // layout: 'elk', + layout: 'elk', // layout: 'fixed', // htmlLabels: false, flowchart: { titleTopMargin: 10 }, diff --git a/packages/mermaid-layout-elk/src/render.ts b/packages/mermaid-layout-elk/src/render.ts index 52c76e666..7e2a82c75 100644 --- a/packages/mermaid-layout-elk/src/render.ts +++ b/packages/mermaid-layout-elk/src/render.ts @@ -1,11 +1,18 @@ +import type { + InternalHelpers, + LayoutData, + RenderOptions, + SVG, + SVGGroup, +} from '@mermaid-chart/mermaid'; +// @ts-ignore TODO: Investigate D3 issue import { curveLinear } from 'd3'; import ELK from 'elkjs/lib/elk.bundled.js'; -import type { InternalHelpers, LayoutData, RenderOptions, SVG, SVGGroup } from 'mermaid'; import { type TreeData, findCommonAncestor } from './find-common-ancestor.js'; +import { bounds } from '../../mermaid/src/diagrams/user-journey/journeyRenderer'; type Node = LayoutData['nodes'][number]; -// Used to calculate distances in order to avoid floating number rounding issues when comparing floating numbers -const epsilon = 0.0001; + interface LabelData { width: number; height: number; @@ -18,16 +25,7 @@ interface NodeWithVertex extends Omit{ labelData?: LabelData; domId?: Node['domId'] | SVGGroup | d3.Selection ; } -interface Point { - x: number; - y: number; -} -function distance(p1?: Point, p2?: Point): number { - if (!p1 || !p2) { - return 0; - } - return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2)); -} + export const render = async ( data4Layout: LayoutData, svg: SVG, @@ -61,17 +59,13 @@ export const render = async ( // Add the element to the DOM if (!node.isGroup) { - const child: NodeWithVertex = { - ...node, - }; + const child = node as NodeWithVertex; graph.children.push(child); - nodeDb[node.id] = child; + nodeDb[node.id] = node; const childNodeEl = await insertNode(nodeEl, node, { config, dir: node.dir }); const boundingBox = childNodeEl.node()!.getBBox(); child.domId = childNodeEl; - child.calcIntersect = node.calcIntersect; - child.intersect = node.intersect; child.width = boundingBox.width; child.height = boundingBox.height; } else { @@ -80,7 +74,9 @@ export const render = async ( ...node, children: [], }; + // Let lke render with the copy graph.children.push(child); + // Save the original contining the intersection function nodeDb[node.id] = child; await addVertices(nodeEl, nodeArr, child, node.id); @@ -155,7 +151,7 @@ export const render = async ( height: node.height, }; if (node.isGroup) { - log.debug('id abc88 subgraph = ', node.id, node.x, node.y, node.labelData); + log.debug('Id abc88 subgraph = ', node.id, node.x, node.y, node.labelData); const subgraphEl = subgraphsEl.insert('g').attr('class', 'subgraph'); // TODO use faster way of cloning const clusterNode = JSON.parse(JSON.stringify(node)); @@ -164,10 +160,10 @@ export const render = async ( clusterNode.width = Math.max(clusterNode.width, node.labelData.width); await insertCluster(subgraphEl, clusterNode); - log.debug('id (UIO)= ', node.id, node.width, node.shape, node.labels); + log.debug('Id (UIO)= ', node.id, node.width, node.shape, node.labels); } else { log.info( - 'id NODE = ', + 'Id NODE = ', node.id, node.x, node.y, @@ -301,7 +297,7 @@ export const render = async ( linkIdCnt[linkIdBase]++; log.info('abc78 new entry', linkIdBase, linkIdCnt[linkIdBase]); } - const linkId = linkIdBase + '_' + linkIdCnt[linkIdBase]; + const linkId = linkIdBase; // + '_' + linkIdCnt[linkIdBase]; edge.id = linkId; log.info('abc78 new link id to be used is', linkIdBase, linkId, linkIdCnt[linkIdBase]); const linkNameStart = 'LS_' + edge.start; @@ -471,6 +467,372 @@ export const render = async ( } } + const intersection = ( + node: { x: any; y: any; width: number; height: number }, + outsidePoint: { x: number; y: number }, + insidePoint: { x: number; y: number } + ) => { + log.debug(`intersection calc abc89: + outsidePoint: ${JSON.stringify(outsidePoint)} + insidePoint : ${JSON.stringify(insidePoint)} + node : x:${node.x} y:${node.y} w:${node.width} h:${node.height}`); + const x = node.x; + const y = node.y; + + const dx = Math.abs(x - insidePoint.x); + // const dy = Math.abs(y - insidePoint.y); + const w = node.width / 2; + let r = insidePoint.x < outsidePoint.x ? w - dx : w + dx; + const h = node.height / 2; + + const Q = Math.abs(outsidePoint.y - insidePoint.y); + const R = Math.abs(outsidePoint.x - insidePoint.x); + + if (Math.abs(y - outsidePoint.y) * w > Math.abs(x - outsidePoint.x) * h) { + // Intersection is top or bottom of rect. + const q = insidePoint.y < outsidePoint.y ? outsidePoint.y - h - y : y - h - outsidePoint.y; + r = (R * q) / Q; + const res = { + x: insidePoint.x < outsidePoint.x ? insidePoint.x + r : insidePoint.x - R + r, + y: insidePoint.y < outsidePoint.y ? insidePoint.y + Q - q : insidePoint.y - Q + q, + }; + + if (r === 0) { + res.x = outsidePoint.x; + res.y = outsidePoint.y; + } + if (R === 0) { + res.x = outsidePoint.x; + } + if (Q === 0) { + res.y = outsidePoint.y; + } + + log.debug(`abc89 topp/bott calc, Q ${Q}, q ${q}, R ${R}, r ${r}`, res); // cspell: disable-line + + return res; + } else { + // Intersection onn sides of rect + if (insidePoint.x < outsidePoint.x) { + r = outsidePoint.x - w - x; + } else { + // r = outsidePoint.x - w - x; + r = x - w - outsidePoint.x; + } + const q = (Q * r) / R; + // OK let _x = insidePoint.x < outsidePoint.x ? insidePoint.x + R - r : insidePoint.x + dx - w; + // OK let _x = insidePoint.x < outsidePoint.x ? insidePoint.x + R - r : outsidePoint.x + r; + let _x = insidePoint.x < outsidePoint.x ? insidePoint.x + R - r : insidePoint.x - R + r; + // let _x = insidePoint.x < outsidePoint.x ? insidePoint.x + R - r : outsidePoint.x + r; + let _y = insidePoint.y < outsidePoint.y ? insidePoint.y + q : insidePoint.y - q; + log.debug(`sides calc abc89, Q ${Q}, q ${q}, R ${R}, r ${r}`, { _x, _y }); + if (r === 0) { + _x = outsidePoint.x; + _y = outsidePoint.y; + } + if (R === 0) { + _x = outsidePoint.x; + } + if (Q === 0) { + _y = outsidePoint.y; + } + + return { x: _x, y: _y }; + } + }; + const outsideNode = ( + node: { x: any; y: any; width: number; height: number }, + point: { x: number; y: number } + ) => { + const x = node.x; + const y = node.y; + const dx = Math.abs(point.x - x); + const dy = Math.abs(point.y - y); + const w = node.width / 2; + const h = node.height / 2; + if (dx >= w || dy >= h) { + return true; + } + return false; + }; + + const cutter2 = (startNode: any, endNode: any, _points: any[]) => { + const startBounds = { + x: startNode.offset.posX + startNode.width / 2, + y: startNode.offset.posY + startNode.height / 2, + width: startNode.width, + height: startNode.height, + padding: startNode.padding, + }; + const endBounds = { + x: endNode.offset.posX + endNode.width / 2, + y: endNode.offset.posY + endNode.height / 2, + width: endNode.width, + height: endNode.height, + padding: endNode.padding, + }; + + if (_points.length === 0) { + return []; + } + + // Copy the original points array + const points = [..._points]; + + // The first point is the center of sNode, the last point is the center of eNode + const startCenter = points[0]; + const endCenter = points[points.length - 1]; + + log.debug('UIO cutter2: startCenter:', startCenter); + log.debug('UIO cutter2: endCenter:', endCenter); + + let firstOutsideStartIndex = -1; + let lastOutsideEndIndex = -1; + + // Single iteration through the array + for (let i = 0; i < points.length; i++) { + const point = points[i]; + + // Check if this is the first point outside the start node + if (firstOutsideStartIndex === -1 && outsideNode(startBounds, point)) { + firstOutsideStartIndex = i; + log.debug('UIO cutter2: First point outside start node at index', i, point); + } + + // Check if this point is outside the end node (keep updating to find the last one) + if (outsideNode(endBounds, point)) { + lastOutsideEndIndex = i; + log.debug('UIO cutter2: Point outside end node at index', i, point); + } + } + + log.debug( + 'UIO cutter2: firstOutsideStartIndex:', + firstOutsideStartIndex, + 'lastOutsideEndIndex:', + lastOutsideEndIndex + ); + log.debug('UIO cutter2: startBounds:', startBounds); + log.debug('UIO cutter2: endBounds:', endBounds); + log.debug('UIO cutter2: original points:', _points); + + // Calculate intersection with start node if we found a point outside it + if (firstOutsideStartIndex !== -1) { + const outsidePoint = points[firstOutsideStartIndex]; + let startIntersection; + + // Try using the node's intersect method first + if (startNode.intersect) { + startIntersection = startNode.intersect(outsidePoint); + + // Check if the intersection is valid (distance > 1) + const distance = Math.sqrt( + (startCenter.x - startIntersection.x) ** 2 + (startCenter.y - startIntersection.y) ** 2 + ); + if (distance <= 1) { + startIntersection = null; + } + } + + // Fallback to intersection function + if (!startIntersection) { + startIntersection = intersection(startBounds, startCenter, outsidePoint); + } + + // Replace the first point with the intersection + if (startIntersection) { + // Check if the intersection is the same as any existing point + const isDuplicate = points.some( + (p, index) => + index > 0 && + Math.abs(p.x - startIntersection.x) < 0.1 && + Math.abs(p.y - startIntersection.y) < 0.1 + ); + + if (isDuplicate) { + log.debug( + 'UIO cutter2: Start intersection is duplicate of existing point, removing first point instead' + ); + points.shift(); // Remove the first point instead of replacing it + } else { + log.debug( + 'UIO cutter2: Replacing first point', + points[0], + 'with intersection', + startIntersection + ); + points[0] = startIntersection; + } + } + } + + // Calculate intersection with end node + // Need to recalculate indices since we may have removed the first point + let outsidePointForEnd = null; + let outsideIndexForEnd = -1; + + // Find the last point that's outside the end node in the current points array + for (let i = points.length - 1; i >= 0; i--) { + if (outsideNode(endBounds, points[i])) { + outsidePointForEnd = points[i]; + outsideIndexForEnd = i; + log.debug('UIO cutter2: Found point outside end node at current index:', i, points[i]); + break; + } + } + + if (!outsidePointForEnd && points.length > 1) { + // No points outside end node, try using the second-to-last point + log.debug('UIO cutter2: No points outside end node, trying second-to-last point'); + outsidePointForEnd = points[points.length - 2]; + outsideIndexForEnd = points.length - 2; + } + + if (outsidePointForEnd) { + // Check if the outside point is actually on the boundary (distance = 0 from intersection) + // If so, we need to create a truly outside point + let actualOutsidePoint = outsidePointForEnd; + + // Quick check: if the point is very close to the node boundary, move it further out + const dx = Math.abs(outsidePointForEnd.x - endBounds.x); + const dy = Math.abs(outsidePointForEnd.y - endBounds.y); + const w = endBounds.width / 2; + const h = endBounds.height / 2; + + log.debug('UIO cutter2: Checking if outside point is truly outside:', { + outsidePoint: outsidePointForEnd, + dx, + dy, + w, + h, + isOnBoundary: Math.abs(dx - w) < 1 || Math.abs(dy - h) < 1, + }); + + // If the point is on or very close to the boundary, move it further out + if (Math.abs(dx - w) < 1 || Math.abs(dy - h) < 1) { + log.debug('UIO cutter2: Outside point is on boundary, creating truly outside point'); + // Move the point further away from the node center + const directionX = outsidePointForEnd.x - endBounds.x; + const directionY = outsidePointForEnd.y - endBounds.y; + const length = Math.sqrt(directionX * directionX + directionY * directionY); + + if (length > 0) { + // Move the point 10 pixels further out in the same direction + actualOutsidePoint = { + x: endBounds.x + (directionX / length) * (length + 10), + y: endBounds.y + (directionY / length) * (length + 10), + }; + log.debug('UIO cutter2: Created truly outside point:', actualOutsidePoint); + } + } + + let endIntersection; + + // Try using the node's intersect method first + if (endNode.intersect) { + endIntersection = endNode.intersect(actualOutsidePoint); + log.debug('UIO cutter2: endNode.intersect result:', endIntersection); + + // Check if the intersection is on the wrong side of the node + const isWrongSide = + (actualOutsidePoint.x < endBounds.x && endIntersection.x > endBounds.x) || + (actualOutsidePoint.x > endBounds.x && endIntersection.x < endBounds.x); + + if (isWrongSide) { + log.debug('UIO cutter2: endNode.intersect returned wrong side, setting to null'); + endIntersection = null; + } else { + // Check if the intersection is valid (distance > 1) + const distance = Math.sqrt( + (actualOutsidePoint.x - endIntersection.x) ** 2 + + (actualOutsidePoint.y - endIntersection.y) ** 2 + ); + log.debug('UIO cutter2: Distance from outside point to intersection:', distance); + if (distance <= 1) { + log.debug('UIO cutter2: endNode.intersect distance too small, setting to null'); + endIntersection = null; + } + } + } else { + log.debug('UIO cutter2: endNode.intersect method not available'); + } + + // Fallback to intersection function + if (!endIntersection) { + // Create a proper inside point that's on the correct side of the node + // The inside point should be between the outside point and the far edge + const insidePoint = { + x: + actualOutsidePoint.x < endBounds.x + ? endBounds.x - endBounds.width / 4 + : endBounds.x + endBounds.width / 4, + y: endCenter.y, + }; + + log.debug('UIO cutter2: Using fallback intersection function with:', { + endBounds, + actualOutsidePoint, + insidePoint, + endCenter, + }); + endIntersection = intersection(endBounds, actualOutsidePoint, insidePoint); + log.debug('UIO cutter2: Fallback intersection result:', endIntersection); + } + + // Replace the last point with the intersection + if (endIntersection) { + // Check if the intersection is the same as any existing point + const isDuplicate = points.some( + (p, index) => + index < points.length - 1 && + Math.abs(p.x - endIntersection.x) < 0.1 && + Math.abs(p.y - endIntersection.y) < 0.1 + ); + + if (isDuplicate) { + log.debug( + 'UIO cutter2: End intersection is duplicate of existing point, removing last point instead' + ); + points.pop(); // Remove the last point instead of replacing it + } else { + log.debug( + 'UIO cutter2: Replacing last point', + points[points.length - 1], + 'with intersection', + endIntersection, + 'using outside point at index', + outsideIndexForEnd + ); + points[points.length - 1] = endIntersection; + } + } + } else { + log.debug('UIO cutter2: No suitable outside point found for end node intersection'); + } + + log.debug('UIO cutter2: Final points:', points); + + // Debug: Check which side of the end node we're ending at + if (points.length > 0) { + const finalPoint = points[points.length - 1]; + const endNodeCenter = endBounds.x; + const endNodeLeftEdge = endNodeCenter - endBounds.width / 2; + const endNodeRightEdge = endNodeCenter + endBounds.width / 2; + + log.debug('UIO cutter2: End node analysis:', { + finalPoint, + endNodeCenter, + endNodeLeftEdge, + endNodeRightEdge, + endingSide: finalPoint.x < endNodeCenter ? 'LEFT' : 'RIGHT', + distanceFromLeftEdge: Math.abs(finalPoint.x - endNodeLeftEdge), + distanceFromRightEdge: Math.abs(finalPoint.x - endNodeRightEdge), + }); + } + + return points; + }; + // @ts-ignore - ELK is not typed const elk = new ELK(); const element = svg.select('g'); @@ -482,7 +844,6 @@ export const render = async ( id: 'root', layoutOptions: { 'elk.hierarchyHandling': 'INCLUDE_CHILDREN', - 'elk.layered.crossingMinimization.forceNodeModelOrder': true, 'elk.algorithm': algorithm, 'nodePlacement.strategy': data4Layout.config.elk?.nodePlacementStrategy, 'elk.layered.mergeEdges': data4Layout.config.elk?.mergeEdges, @@ -497,6 +858,7 @@ export const render = async ( // 'spacing.edgeEdge': 10, // 'spacing.edgeEdgeBetweenLayers': 20, // 'spacing.nodeSelfLoop': 20, + // Tweaking options // 'elk.layered.nodePlacement.favorStraightEdges': true, // 'nodePlacement.feedbackEdges': true, @@ -682,46 +1044,69 @@ export const render = async ( startNode.innerHTML ); } - - if (startNode.calcIntersect) { - const intersection = startNode.calcIntersect( - { - x: startNode.offset.posX + startNode.width / 2, - y: startNode.offset.posY + startNode.height / 2, - width: startNode.width, - height: startNode.height, - }, - edge.points[0] - ); - - if (distance(intersection, edge.points[0]) > epsilon) { - edge.points.unshift(intersection); - } - } else { - log.warn('UIO no intersect', startNode.id, startNode); - const intersection = startNode.intersect(edge.points); - - if (distance(intersection, edge.points[0]) > epsilon) { - edge.points.unshift(intersection); - } + startNode.x = startNode.offset.posX + startNode.width / 2; + startNode.y = startNode.offset.posY + startNode.height / 2; + endNode.x = endNode.offset.posX + endNode.width / 2; + endNode.y = endNode.offset.posY + endNode.height / 2; + if (startNode.shape !== 'rect33') { + edge.points.unshift({ + x: startNode.x, + y: startNode.y, + }); } - log.warn('UIO here', startNode.id, startNode); - if (endNode.calcIntersect) { - const intersection = endNode.calcIntersect( - { - x: endNode.offset.posX + endNode.width / 2, - y: endNode.offset.posY + endNode.height / 2, - width: endNode.width, - height: endNode.height, - }, - edge.points[edge.points.length - 1] - ); - - if (distance(intersection, edge.points[edge.points.length - 1]) > epsilon) { - edge.points.push(intersection); - } + if (endNode.shape !== 'rect33') { + edge.points.push({ + x: endNode.x, + y: endNode.y, + }); } + // edge.points = cutPathAtIntersect2(startNode, edge.points.reverse(), offset, { + // x: startNode.x + startNode.width / 2 + offset.x, + // y: startNode.y + startNode.height / 2 + offset.y, + // width: sw, + // height: startNode.height, + // padding: startNode.padding, + // }).reverse(); + + // edge.points = cutPathAtIntersect2(endNode, edge.points, offset, { + // x: endNode.x + ew / 2 + endNode.offset.x, + // y: endNode.y + endNode.height / 2 + endNode.offset.y, + // width: ew, + // height: endNode.height, + // padding: endNode.padding, + // }); + + // edge.points = cutPathAtIntersect( + // edge.points.reverse(), + // { + // x: startNode.offset.posX + startNode.width / 2, + // y: startNode.offset.posY + startNode.height / 2, + // width: sw, + // height: startNode.height, + // padding: startNode.padding, + // }, + // true // startNode.shape === 'diamond' || startNode.shape === 'diam' + // ).reverse(); + + // console.log('UIO width', sw, startNode.width); + + // edge.points = cutPathAtIntersect( + // edge.points, + // { + // x: endNode.offset.posX + endNode.width / 2, + // y: endNode.offset.posY + endNode.height / 2, + // width: ew, + // height: endNode.height, + // padding: endNode.padding, + // }, + // endNode.shape === 'diamond' || endNode.shape === 'diam' + // ); + // startNode.intersect = undefined; + // endNode.intersect = undefined; + log.debug('UIO cutter2: Points before cutter2:', edge.points); + edge.points = cutter2(startNode, endNode, edge.points); + log.debug('UIO cutter2: Points after cutter2:', edge.points); const paths = insertEdge( edgesEl, edge, @@ -729,8 +1114,10 @@ export const render = async ( data4Layout.type, startNode, endNode, - data4Layout.diagramId + data4Layout.diagramId, + true ); + log.info('APA12 edge points after insert', JSON.stringify(edge.points)); edge.x = edge.labels[0].x + offset.x + edge.labels[0].width / 2; edge.y = edge.labels[0].y + offset.y + edge.labels[0].height / 2; diff --git a/packages/mermaid/src/rendering-util/rendering-elements/edges.js b/packages/mermaid/src/rendering-util/rendering-elements/edges.js index a8d9522fe..afcd65360 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/edges.js +++ b/packages/mermaid/src/rendering-util/rendering-elements/edges.js @@ -1,9 +1,13 @@ import { getConfig } from '../../diagram-api/diagramAPI.js'; -import { evaluate, getUrl } from '../../diagrams/common/common.js'; +import { evaluate } from '../../diagrams/common/common.js'; import { log } from '../../logger.js'; import { createText } from '../createText.js'; import utils from '../../utils.js'; -import { getLineFunctionsWithOffset } from '../../utils/lineWithOffset.js'; +import { + getLineFunctionsWithOffset, + markerOffsets, + markerOffsets2, +} from '../../utils/lineWithOffset.js'; import { getSubGraphTitleMargins } from '../../utils/subGraphTitleMargins.js'; import { @@ -27,8 +31,8 @@ import createLabel from './createLabel.js'; import { addEdgeMarkers } from './edgeMarker.ts'; import { isLabelStyle } from './shapes/handDrawnShapeStyles.js'; -const edgeLabels = new Map(); -const terminalLabels = new Map(); +export const edgeLabels = new Map(); +export const terminalLabels = new Map(); export const clear = () => { edgeLabels.clear(); @@ -55,7 +59,7 @@ export const insertEdgeLabel = async (elem, edge) => { const edgeLabel = elem.insert('g').attr('class', 'edgeLabel'); // Create inner g, label, this will be positioned now for centering the text - const label = edgeLabel.insert('g').attr('class', 'label'); + const label = edgeLabel.insert('g').attr('class', 'label').attr('data-id', edge.id); label.node().appendChild(labelElement); // Center the label @@ -352,39 +356,33 @@ const cutPathAtIntersect = (_points, boundaryNode) => { return points; }; -const adjustForArrowHeads = function (lineData, size = 5) { - const newLineData = [...lineData]; - const lastPoint = lineData[lineData.length - 1]; - const secondLastPoint = lineData[lineData.length - 2]; +const generateDashArray = (len, oValueS, oValueE) => { + const middleLength = len - oValueS - oValueE; + const dashLength = 2; // Length of each dash + const gapLength = 2; // Length of each gap + const dashGapPairLength = dashLength + gapLength; - const distanceBetweenLastPoints = Math.sqrt( - (lastPoint.x - secondLastPoint.x) ** 2 + (lastPoint.y - secondLastPoint.y) ** 2 - ); + // Calculate number of complete dash-gap pairs that can fit + const numberOfPairs = Math.floor(middleLength / dashGapPairLength); - if (distanceBetweenLastPoints < size) { - // Calculate the direction vector from the last point to the second last point - const directionX = secondLastPoint.x - lastPoint.x; - const directionY = secondLastPoint.y - lastPoint.y; + // Generate the middle pattern array + const middlePattern = Array(numberOfPairs).fill(`${dashLength} ${gapLength}`).join(' '); - // Normalize the direction vector - const magnitude = Math.sqrt(directionX ** 2 + directionY ** 2); - const normalizedX = directionX / magnitude; - const normalizedY = directionY / magnitude; + // Combine all parts + const dashArray = `0 ${oValueS} ${middlePattern} ${oValueE}`; - // Calculate the new position for the second last point - const adjustedSecondLastPoint = { - x: lastPoint.x + normalizedX * size, - y: lastPoint.y + normalizedY * size, - }; - - // Replace the second last point in the new line data - newLineData[newLineData.length - 2] = adjustedSecondLastPoint; - } - - return newLineData; + return dashArray; }; - -export const insertEdge = function (elem, edge, clusterDb, diagramType, startNode, endNode, id) { +export const insertEdge = function ( + elem, + edge, + clusterDb, + diagramType, + startNode, + endNode, + id, + skipIntersect = false +) { const { handDrawnSeed } = getConfig(); let points = edge.points; let pointsHasChanged = false; @@ -398,11 +396,12 @@ export const insertEdge = function (elem, edge, clusterDb, diagramType, startNod edgeClassStyles.push(edge.cssCompiledStyles[key]); } - if (head.intersect && tail.intersect) { + log.debug('UIO intersect check', edge.points, head.x, tail.x); + if (head.intersect && tail.intersect && !skipIntersect) { points = points.slice(1, edge.points.length - 1); points.unshift(tail.intersect(points[0])); log.debug( - 'Last point APA12', + 'Last point UIO', edge.start, '-->', edge.end, @@ -412,6 +411,7 @@ export const insertEdge = function (elem, edge, clusterDb, diagramType, startNod ); points.push(head.intersect(points[points.length - 1])); } + const pointsStr = btoa(JSON.stringify(points)); if (edge.toCluster) { log.info('to cluster abc88', clusterDb.get(edge.toCluster)); points = cutPathAtIntersect(edge.points, clusterDb.get(edge.toCluster).node); @@ -431,8 +431,7 @@ export const insertEdge = function (elem, edge, clusterDb, diagramType, startNod } let lineData = points.filter((p) => !Number.isNaN(p.y)); - lineData = adjustForArrowHeads(lineData); - // lineData = fixCorners(lineData); + //lineData = fixCorners(lineData); let curve = curveBasis; curve = curveLinear; switch (edge.curve) { @@ -476,6 +475,10 @@ export const insertEdge = function (elem, edge, clusterDb, diagramType, startNod curve = curveBasis; } + // if (edge.curve) { + // curve = edge.curve; + // } + const { x, y } = getLineFunctionsWithOffset(edge); const lineFunction = line().x(x).y(y).curve(curve); @@ -507,10 +510,14 @@ export const insertEdge = function (elem, edge, clusterDb, diagramType, startNod strokeClasses += ' edge-pattern-solid'; } let svgPath; - let linePath = lineFunction(lineData); - const edgeStyles = Array.isArray(edge.style) ? edge.style : edge.style ? [edge.style] : []; + let linePath = + edge.curve === 'rounded' + ? generateRoundedPath(applyMarkerOffsetsToPoints(lineData, edge), 5) + : lineFunction(lineData); + const edgeStyles = Array.isArray(edge.style) ? edge.style : [edge.style]; let strokeColor = edgeStyles.find((style) => style?.startsWith('stroke:')); + let animatedEdge = false; if (edge.look === 'handDrawn') { const rc = rough.svg(elem); Object.assign([], lineData); @@ -541,7 +548,10 @@ export const insertEdge = function (elem, edge, clusterDb, diagramType, startNod animationClass = ' edge-animation-' + edge.animation; } - const pathStyle = stylesFromClasses ? stylesFromClasses + ';' + styles + ';' : styles; + const pathStyle = + (stylesFromClasses ? stylesFromClasses + ';' + styles + ';' : styles) + + ';' + + (edgeStyles ? edgeStyles.reduce((acc, style) => acc + ';' + style, '') : ''); svgPath = elem .append('path') .attr('d', linePath) @@ -551,11 +561,38 @@ export const insertEdge = function (elem, edge, clusterDb, diagramType, startNod ' ' + strokeClasses + (edge.classes ? ' ' + edge.classes : '') + (animationClass ?? '') ) .attr('style', pathStyle); + + //eslint-disable-next-line @typescript-eslint/prefer-regexp-exec strokeColor = pathStyle.match(/stroke:([^;]+)/)?.[1]; + + // Possible fix to remove eslint-disable-next-line + //strokeColor = /stroke:([^;]+)/.exec(pathStyle)?.[1]; + + animatedEdge = + edge.animate === true || !!edge.animation || stylesFromClasses.includes('animation'); + const len = svgPath.node().getTotalLength(); + const oValueS = markerOffsets2[edge.arrowTypeStart] || 0; + const oValueE = markerOffsets2[edge.arrowTypeEnd] || 0; + + if (edge.look === 'neo' && !animatedEdge) { + const dashArray = + edge.pattern === 'dotted' || edge.pattern === 'dashed' + ? generateDashArray(len, oValueS, oValueE) + : `0 ${oValueS} ${len - oValueS - oValueE} ${oValueE}`; + + // No offset needed because we already start with a zero-length dash that effectively sets us up for a gap at the start. + const mOffset = `stroke-dasharray: ${dashArray}; stroke-dashoffset: 0;`; + svgPath.attr('style', mOffset + svgPath.attr('style')); + } } - // DEBUG code, DO NOT REMOVE - // adds a red circle at each edge coordinate + // MC Special + svgPath.attr('data-edge', true); + svgPath.attr('data-et', 'edge'); + svgPath.attr('data-id', edge.id); + svgPath.attr('data-points', pointsStr); + + // DEBUG code, adds a red circle at each edge coordinate // cornerPoints.forEach((point) => { // elem // .append('circle') @@ -565,24 +602,33 @@ export const insertEdge = function (elem, edge, clusterDb, diagramType, startNod // .attr('cx', point.x) // .attr('cy', point.y); // }); - // lineData.forEach((point) => { - // elem - // .append('circle') - // .style('stroke', 'red') - // .style('fill', 'red') - // .attr('r', 1) - // .attr('cx', point.x) - // .attr('cy', point.y); - // }); + if (edge.showPoints || true) { + lineData.forEach((point) => { + elem + .append('circle') + .style('stroke', 'red') + .style('fill', 'red') + .attr('r', 1) + .attr('cx', point.x) + .attr('cy', point.y); + }); + } let url = ''; if (getConfig().flowchart.arrowMarkerAbsolute || getConfig().state.arrowMarkerAbsolute) { - url = getUrl(true); + url = + window.location.protocol + + '//' + + window.location.host + + window.location.pathname + + window.location.search; + url = url.replace(/\(/g, '\\(').replace(/\)/g, '\\)'); } log.info('arrowTypeStart', edge.arrowTypeStart); log.info('arrowTypeEnd', edge.arrowTypeEnd); - addEdgeMarkers(svgPath, edge, url, id, diagramType, strokeColor); + const useMargin = !animatedEdge && edge?.look === 'neo'; + addEdgeMarkers(svgPath, edge, url, id, diagramType, useMargin, strokeColor); let paths = {}; if (pointsHasChanged) { @@ -591,3 +637,134 @@ export const insertEdge = function (elem, edge, clusterDb, diagramType, startNod paths.originalPath = edge.points; return paths; }; + +/** + * Generates SVG path data with rounded corners from an array of points. + * @param {Array} points - Array of points in the format [{x: Number, y: Number}, ...] + * @param {Number} radius - The radius of the rounded corners + * @returns {String} - SVG path data string + */ +function generateRoundedPath(points, radius) { + if (points.length < 2) { + return ''; + } + + let path = ''; + const size = points.length; + const epsilon = 1e-5; + + for (let i = 0; i < size; i++) { + const currPoint = points[i]; + const prevPoint = points[i - 1]; + const nextPoint = points[i + 1]; + + if (i === 0) { + // Move to the first point + path += `M${currPoint.x},${currPoint.y}`; + } else if (i === size - 1) { + // Last point, draw a straight line to the final point + path += `L${currPoint.x},${currPoint.y}`; + } else { + // Calculate vectors for incoming and outgoing segments + const dx1 = currPoint.x - prevPoint.x; + const dy1 = currPoint.y - prevPoint.y; + const dx2 = nextPoint.x - currPoint.x; + const dy2 = nextPoint.y - currPoint.y; + + const len1 = Math.hypot(dx1, dy1); + const len2 = Math.hypot(dx2, dy2); + + // Prevent division by zero + if (len1 < epsilon || len2 < epsilon) { + path += `L${currPoint.x},${currPoint.y}`; + continue; + } + + // Normalize the vectors + const nx1 = dx1 / len1; + const ny1 = dy1 / len1; + const nx2 = dx2 / len2; + const ny2 = dy2 / len2; + + // Calculate the angle between the vectors + const dot = nx1 * nx2 + ny1 * ny2; + // Clamp the dot product to avoid numerical issues with acos + const clampedDot = Math.max(-1, Math.min(1, dot)); + const angle = Math.acos(clampedDot); + + // Skip rounding if the angle is too small or too close to 180 degrees + if (angle < epsilon || Math.abs(Math.PI - angle) < epsilon) { + path += `L${currPoint.x},${currPoint.y}`; + continue; + } + + // Calculate the distance to offset the control point + const cutLen = Math.min(radius / Math.sin(angle / 2), len1 / 2, len2 / 2); + + // Calculate the start and end points of the curve + const startX = currPoint.x - nx1 * cutLen; + const startY = currPoint.y - ny1 * cutLen; + const endX = currPoint.x + nx2 * cutLen; + const endY = currPoint.y + ny2 * cutLen; + + // Draw the line to the start of the curve + path += `L${startX},${startY}`; + + // Draw the quadratic Bezier curve + path += `Q${currPoint.x},${currPoint.y} ${endX},${endY}`; + } + } + + return path; +} +// Helper function to calculate delta and angle between two points +function calculateDeltaAndAngle(point1, point2) { + if (!point1 || !point2) { + return { angle: 0, deltaX: 0, deltaY: 0 }; + } + const deltaX = point2.x - point1.x; + const deltaY = point2.y - point1.y; + const angle = Math.atan2(deltaY, deltaX); + return { angle, deltaX, deltaY }; +} + +// Function to adjust the first and last points of the points array +function applyMarkerOffsetsToPoints(points, edge) { + // Copy the points array to avoid mutating the original data + const newPoints = points.map((point) => ({ ...point })); + + // Handle the first point (start of the edge) + if (points.length >= 2 && markerOffsets[edge.arrowTypeStart]) { + const offsetValue = markerOffsets[edge.arrowTypeStart]; + + const point1 = points[0]; + const point2 = points[1]; + + const { angle } = calculateDeltaAndAngle(point1, point2); + + const offsetX = offsetValue * Math.cos(angle); + const offsetY = offsetValue * Math.sin(angle); + + newPoints[0].x = point1.x + offsetX; + newPoints[0].y = point1.y + offsetY; + } + + // Handle the last point (end of the edge) + const n = points.length; + if (n >= 2 && markerOffsets[edge.arrowTypeEnd]) { + const offsetValue = markerOffsets[edge.arrowTypeEnd]; + + const point1 = points[n - 1]; + const point2 = points[n - 2]; + + const { angle } = calculateDeltaAndAngle(point2, point1); + + const offsetX = offsetValue * Math.cos(angle); + const offsetY = offsetValue * Math.sin(angle); + + newPoints[n - 1].x = point1.x - offsetX; + newPoints[n - 1].y = point1.y - offsetY; + } + + return newPoints; +} diff --git a/packages/mermaid/src/utils/lineWithOffset.ts b/packages/mermaid/src/utils/lineWithOffset.ts index 057944325..e5d9b41f4 100644 --- a/packages/mermaid/src/utils/lineWithOffset.ts +++ b/packages/mermaid/src/utils/lineWithOffset.ts @@ -4,12 +4,22 @@ import type { EdgeData, Point } from '../types.js'; // under any transparent markers. // The offsets are calculated from the markers' dimensions. export const markerOffsets = { - aggregation: 18, - extension: 18, - composition: 18, + aggregation: 17.25, + extension: 17.25, + composition: 17.25, dependency: 6, lollipop: 13.5, arrow_point: 4, + //arrow_cross: 24, +} as const; + +// We need to draw the lines a bit shorter to avoid drawing +// under any transparent markers. +// The offsets are calculated from the markers' dimensions. +export const markerOffsets2 = { + arrow_point: 9, + arrow_cross: 12.5, + arrow_circle: 12.5, } as const; /** @@ -104,6 +114,7 @@ export const getLineFunctionsWithOffset = ( adjustment *= DIRECTION === 'right' ? -1 : 1; offset += adjustment; } + return pointTransformer(d).x + offset; }, y: function (