From 2352b422db629ac50b77f39197e271c0a2a0b9d5 Mon Sep 17 00:00:00 2001 From: Knut Sveidqvist Date: Thu, 19 Dec 2024 16:12:44 +0100 Subject: [PATCH] #2028 Adding legacy code --- cypress/platform/knsv2.html | 5 +- cypress/platform/shape-tester.html | 8 +- .../swimlane/lost-and-found/detector.spec.ts | 55 ++ .../swimlane/lost-and-found/detector.ts | 29 + .../lost-and-found/render-utils.spec.ts | 40 ++ .../swimlane/lost-and-found/render-utils.ts | 25 + .../swimlane/lost-and-found/setup-graph.js | 395 ++++++++++++ .../swimlane/lost-and-found/styles.ts | 143 +++++ .../lost-and-found/swimlane-definition.ts | 13 + .../lost-and-found/swimlane-layout.js | 220 +++++++ .../lost-and-found/swimlane-layout.spec.ts | 129 ++++ .../lost-and-found/swimlaneRenderer.js | 596 ++++++++++++++++++ .../swimlane/swimlane-layout.js | 220 +++++++ .../swimlane/swimlane-layout.spec.ts | 129 ++++ 14 files changed, 2001 insertions(+), 6 deletions(-) create mode 100644 packages/mermaid/src/diagrams/swimlane/lost-and-found/detector.spec.ts create mode 100644 packages/mermaid/src/diagrams/swimlane/lost-and-found/detector.ts create mode 100644 packages/mermaid/src/diagrams/swimlane/lost-and-found/render-utils.spec.ts create mode 100644 packages/mermaid/src/diagrams/swimlane/lost-and-found/render-utils.ts create mode 100644 packages/mermaid/src/diagrams/swimlane/lost-and-found/setup-graph.js create mode 100644 packages/mermaid/src/diagrams/swimlane/lost-and-found/styles.ts create mode 100644 packages/mermaid/src/diagrams/swimlane/lost-and-found/swimlane-definition.ts create mode 100644 packages/mermaid/src/diagrams/swimlane/lost-and-found/swimlane-layout.js create mode 100644 packages/mermaid/src/diagrams/swimlane/lost-and-found/swimlane-layout.spec.ts create mode 100644 packages/mermaid/src/diagrams/swimlane/lost-and-found/swimlaneRenderer.js create mode 100644 packages/mermaid/src/rendering-util/layout-algorithms/swimlane/swimlane-layout.js create mode 100644 packages/mermaid/src/rendering-util/layout-algorithms/swimlane/swimlane-layout.spec.ts diff --git a/cypress/platform/knsv2.html b/cypress/platform/knsv2.html index 5c8939d29..ef130569b 100644 --- a/cypress/platform/knsv2.html +++ b/cypress/platform/knsv2.html @@ -97,14 +97,15 @@ swimlane TB %% swimlane 1 - A E %% swimlane 2 - B %% swimlane 3 - C D -lane First +%% lane First A -end +%% end A --> B(I am B, the wide one) --> C C --> D & F D --> E A --> E + E --> B B@{ shape: diam} diff --git a/cypress/platform/shape-tester.html b/cypress/platform/shape-tester.html index 21ab9ad85..ef6339a9f 100644 --- a/cypress/platform/shape-tester.html +++ b/cypress/platform/shape-tester.html @@ -56,9 +56,9 @@ logLevel: 1, }); - let shape = 'card'; - // let simplified = true; - let simplified = false; + let shape = 'circle'; + let simplified = true; + // let simplified = false; let algorithm = 'elk'; // let algorithm = 'dagre'; let code = `--- @@ -86,7 +86,7 @@ config: layout: ${algorithm} --- flowchart LR -A["Abrakadabra"] --> C["C"] & C & C & C & C +A["Abrakadabra"] --> C["I am the circle"] & C & C & C & C %% A["Abrakadabra"] --> C A@{ shape: ${shape}} C@{ shape: ${shape}} diff --git a/packages/mermaid/src/diagrams/swimlane/lost-and-found/detector.spec.ts b/packages/mermaid/src/diagrams/swimlane/lost-and-found/detector.spec.ts new file mode 100644 index 000000000..fb5541da7 --- /dev/null +++ b/packages/mermaid/src/diagrams/swimlane/lost-and-found/detector.spec.ts @@ -0,0 +1,55 @@ +import plugin from './detector.js'; +import { describe, it } from 'vitest'; + +const { detector } = plugin; + +describe('swimlane detector', () => { + it('should fail for dagre-d3', () => { + expect( + detector('swimlane', { + flowchart: { + defaultRenderer: 'dagre-d3', + }, + }) + ).toBe(false); + }); + it('should fail for dagre-wrapper', () => { + expect( + detector('flowchart', { + flowchart: { + defaultRenderer: 'dagre-wrapper', + }, + }) + ).toBe(false); + }); + it('should succeed for elk', () => { + expect( + detector('flowchart', { + flowchart: { + defaultRenderer: 'elk', + }, + }) + ).toBe(true); + expect( + detector('graph', { + flowchart: { + defaultRenderer: 'elk', + }, + }) + ).toBe(true); + }); + + it('should detect swimlane', () => { + expect(detector('swimlane')).toBe(true); + }); + + it('should not detect class with defaultRenderer set to elk', () => { + expect( + detector('class', { + flowchart: { + defaultRenderer: 'elk', + }, + }) + ).toBe(false); + }); +}); diff --git a/packages/mermaid/src/diagrams/swimlane/lost-and-found/detector.ts b/packages/mermaid/src/diagrams/swimlane/lost-and-found/detector.ts new file mode 100644 index 000000000..19e7292f3 --- /dev/null +++ b/packages/mermaid/src/diagrams/swimlane/lost-and-found/detector.ts @@ -0,0 +1,29 @@ + +import type { + ExternalDiagramDefinition, + DiagramDetector, + DiagramLoader, +} from '../../../diagram-api/types.js'; +const id = 'swimlane'; + + +const detector: DiagramDetector = (txt, config): boolean => { + if (txt.match(/^\s*swimlane/)) { + return true; + } + return false; +}; + + +const loader: DiagramLoader = async () => { + const { diagram } = await import('./swimlane-definition.js'); + return { id, diagram }; +}; + +const plugin: ExternalDiagramDefinition = { + id, + detector, + loader, +}; + +export default plugin; diff --git a/packages/mermaid/src/diagrams/swimlane/lost-and-found/render-utils.spec.ts b/packages/mermaid/src/diagrams/swimlane/lost-and-found/render-utils.spec.ts new file mode 100644 index 000000000..d048b07a3 --- /dev/null +++ b/packages/mermaid/src/diagrams/swimlane/lost-and-found/render-utils.spec.ts @@ -0,0 +1,40 @@ +import { findCommonAncestor, TreeData } from './render-utils.js'; +describe('when rendering a flowchart using elk ', () => { + let lookupDb: TreeData; + beforeEach(() => { + lookupDb = { + parentById: { + B4: 'inner', + B5: 'inner', + C4: 'inner2', + C5: 'inner2', + B2: 'Ugge', + B3: 'Ugge', + inner: 'Ugge', + inner2: 'Ugge', + B6: 'outer', + }, + childrenById: { + inner: ['B4', 'B5'], + inner2: ['C4', 'C5'], + Ugge: ['B2', 'B3', 'inner', 'inner2'], + outer: ['B6'], + }, + }; + }); + it('to find parent of siblings in a subgraph', () => { + expect(findCommonAncestor('B4', 'B5', lookupDb)).toBe('inner'); + }); + it('to find an uncle', () => { + expect(findCommonAncestor('B4', 'B2', lookupDb)).toBe('Ugge'); + }); + it('to find a cousin', () => { + expect(findCommonAncestor('B4', 'C4', lookupDb)).toBe('Ugge'); + }); + it('to find a grandparent', () => { + expect(findCommonAncestor('B4', 'B6', lookupDb)).toBe('root'); + }); + it('to find ancestor of siblings in the root', () => { + expect(findCommonAncestor('B1', 'outer', lookupDb)).toBe('root'); + }); +}); diff --git a/packages/mermaid/src/diagrams/swimlane/lost-and-found/render-utils.ts b/packages/mermaid/src/diagrams/swimlane/lost-and-found/render-utils.ts new file mode 100644 index 000000000..ebdc01cf7 --- /dev/null +++ b/packages/mermaid/src/diagrams/swimlane/lost-and-found/render-utils.ts @@ -0,0 +1,25 @@ +export interface TreeData { + parentById: Record; + childrenById: Record; +} + +export const findCommonAncestor = (id1: string, id2: string, treeData: TreeData) => { + const { parentById } = treeData; + const visited = new Set(); + let currentId = id1; + while (currentId) { + visited.add(currentId); + if (currentId === id2) { + return currentId; + } + currentId = parentById[currentId]; + } + currentId = id2; + while (currentId) { + if (visited.has(currentId)) { + return currentId; + } + currentId = parentById[currentId]; + } + return 'root'; +}; diff --git a/packages/mermaid/src/diagrams/swimlane/lost-and-found/setup-graph.js b/packages/mermaid/src/diagrams/swimlane/lost-and-found/setup-graph.js new file mode 100644 index 000000000..f19453249 --- /dev/null +++ b/packages/mermaid/src/diagrams/swimlane/lost-and-found/setup-graph.js @@ -0,0 +1,395 @@ +import * as graphlib from 'dagre-d3-es/src/graphlib/index.js'; +import { select, curveLinear, selectAll } from 'd3'; +import { getConfig } from '../../../config.js'; +import utils from '../../../utils.js'; + +import { addHtmlLabel } from 'dagre-d3-es/src/dagre-js/label/add-html-label.js'; +import { log } from '../../../logger.js'; +import common, { evaluate } from '../../common/common.js'; +import { interpolateToCurve, getStylesFromArray } from '../../../utils.js'; + +const conf = {}; +export const setConf = function (cnf) { + const keys = Object.keys(cnf); + for (const key of keys) { + conf[key] = cnf[key]; + } +}; + +/** + * Add edges to graph based on parsed graph definition + * + * @param {object} edges The edges to add to the graph + * @param {object} g The graph object + * @param diagObj + */ +export const addEdges = function (edges, g, diagObj,svg) { + log.info('abc78 edges = ', edges); + let cnt = 0; + let linkIdCnt = {}; + + let defaultStyle; + let defaultLabelStyle; + + if (edges.defaultStyle !== undefined) { + const defaultStyles = getStylesFromArray(edges.defaultStyle); + defaultStyle = defaultStyles.style; + defaultLabelStyle = defaultStyles.labelStyle; + } + + edges.forEach(function (edge) { + cnt++; + + // Identify Link + var linkIdBase = 'L-' + edge.start + '-' + edge.end; + // count the links from+to the same node to give unique id + if (linkIdCnt[linkIdBase] === undefined) { + linkIdCnt[linkIdBase] = 0; + log.info('abc78 new entry', linkIdBase, linkIdCnt[linkIdBase]); + } else { + linkIdCnt[linkIdBase]++; + log.info('abc78 new entry', linkIdBase, linkIdCnt[linkIdBase]); + } + let linkId = linkIdBase + '-' + linkIdCnt[linkIdBase]; + log.info('abc78 new link id to be used is', linkIdBase, linkId, linkIdCnt[linkIdBase]); + var linkNameStart = 'LS-' + edge.start; + var linkNameEnd = 'LE-' + edge.end; + + const edgeData = { style: '', labelStyle: '' }; + edgeData.minlen = edge.length || 1; + //edgeData.id = 'id' + cnt; + + // Set link type for rendering + if (edge.type === 'arrow_open') { + edgeData.arrowhead = 'none'; + } else { + edgeData.arrowhead = 'normal'; + } + + // Check of arrow types, placed here in order not to break old rendering + edgeData.arrowTypeStart = 'arrow_open'; + edgeData.arrowTypeEnd = 'arrow_open'; + + /* eslint-disable no-fallthrough */ + switch (edge.type) { + case 'double_arrow_cross': + edgeData.arrowTypeStart = 'arrow_cross'; + case 'arrow_cross': + edgeData.arrowTypeEnd = 'arrow_cross'; + break; + case 'double_arrow_point': + edgeData.arrowTypeStart = 'arrow_point'; + case 'arrow_point': + edgeData.arrowTypeEnd = 'arrow_point'; + break; + case 'double_arrow_circle': + edgeData.arrowTypeStart = 'arrow_circle'; + case 'arrow_circle': + edgeData.arrowTypeEnd = 'arrow_circle'; + break; + } + + let style = ''; + let labelStyle = ''; + + switch (edge.stroke) { + case 'normal': + style = 'fill:none;'; + if (defaultStyle !== undefined) { + style = defaultStyle; + } + if (defaultLabelStyle !== undefined) { + labelStyle = defaultLabelStyle; + } + edgeData.thickness = 'normal'; + edgeData.pattern = 'solid'; + break; + case 'dotted': + edgeData.thickness = 'normal'; + edgeData.pattern = 'dotted'; + edgeData.style = 'fill:none;stroke-width:2px;stroke-dasharray:3;'; + break; + case 'thick': + edgeData.thickness = 'thick'; + edgeData.pattern = 'solid'; + edgeData.style = 'stroke-width: 3.5px;fill:none;'; + break; + case 'invisible': + edgeData.thickness = 'invisible'; + edgeData.pattern = 'solid'; + edgeData.style = 'stroke-width: 0;fill:none;'; + break; + } + if (edge.style !== undefined) { + const styles = getStylesFromArray(edge.style); + style = styles.style; + labelStyle = styles.labelStyle; + } + + edgeData.style = edgeData.style += style; + edgeData.labelStyle = edgeData.labelStyle += labelStyle; + + if (edge.interpolate !== undefined) { + edgeData.curve = interpolateToCurve(edge.interpolate, curveLinear); + } else if (edges.defaultInterpolate !== undefined) { + edgeData.curve = interpolateToCurve(edges.defaultInterpolate, curveLinear); + } else { + edgeData.curve = interpolateToCurve(conf.curve, curveLinear); + } + + if (edge.text === undefined) { + if (edge.style !== undefined) { + edgeData.arrowheadStyle = 'fill: #333'; + } + } else { + edgeData.arrowheadStyle = 'fill: #333'; + edgeData.labelpos = 'c'; + } + + edgeData.labelType = edge.labelType; + edgeData.label = edge.text.replace(common.lineBreakRegex, '\n'); + + if (edge.style === undefined) { + edgeData.style = edgeData.style || 'stroke: #333; stroke-width: 1.5px;fill:none;'; + } + + edgeData.labelStyle = edgeData.labelStyle.replace('color:', 'fill:'); + + edgeData.id = linkId; + edgeData.classes = 'flowchart-link ' + linkNameStart + ' ' + linkNameEnd; + + // Add the edge to the graph + g.setEdge(edge.start, edge.end, edgeData, cnt); + }); +}; + +/** + * Function that adds the vertices found during parsing to the graph to be rendered. + * + * @param vert Object containing the vertices. + * @param g The graph that is to be drawn. + * @param svgId + * @param root + * @param doc + * @param diagObj + */ +export const addVertices = function (vert, g, svgId, root, doc, diagObj) { + const svg = root.select(`[id="${svgId}"]`); + const keys = Object.keys(vert); + + // Iterate through each item in the vertex object (containing all the vertices found) in the graph definition + keys.forEach(function (id) { + const vertex = vert[id]; + + /** + * Variable for storing the classes for the vertex + * + * @type {string} + */ + let classStr = 'default'; + if (vertex.classes.length > 0) { + classStr = vertex.classes.join(' '); + } + classStr = classStr + ' flowchart-label'; + const styles = getStylesFromArray(vertex.styles); + + // Use vertex id as text in the box if no text is provided by the graph definition + let vertexText = vertex.text !== undefined ? vertex.text : vertex.id; + + // We create a SVG label, either by delegating to addHtmlLabel or manually + let vertexNode; + log.info('vertex', vertex, vertex.labelType); + if (vertex.labelType === 'markdown') { + log.info('vertex', vertex, vertex.labelType); + } else { + if (evaluate(getConfig().flowchart.htmlLabels) && svg.html) { + // TODO: addHtmlLabel accepts a labelStyle. Do we possibly have that? + const node = { + label: vertexText.replace( + /fa[blrs]?:fa-[\w-]+/g, + (s) => `` + ), + }; + vertexNode = addHtmlLabel(svg, node).node(); + vertexNode.parentNode.removeChild(vertexNode); + } else { + const svgLabel = doc.createElementNS('http://www.w3.org/2000/svg', 'text'); + svgLabel.setAttribute('style', styles.labelStyle.replace('color:', 'fill:')); + + const rows = vertexText.split(common.lineBreakRegex); + + for (const row of rows) { + const tspan = doc.createElementNS('http://www.w3.org/2000/svg', 'tspan'); + tspan.setAttributeNS('http://www.w3.org/XML/1998/namespace', 'xml:space', 'preserve'); + tspan.setAttribute('dy', '1em'); + tspan.setAttribute('x', '1'); + tspan.textContent = row; + svgLabel.appendChild(tspan); + } + vertexNode = svgLabel; + } + } + + let radious = 0; + let _shape = ''; + // Set the shape based parameters + switch (vertex.type) { + case 'round': + radious = 5; + _shape = 'rect'; + break; + case 'square': + _shape = 'rect'; + break; + case 'diamond': + _shape = 'question'; + break; + case 'hexagon': + _shape = 'hexagon'; + break; + case 'odd': + _shape = 'rect_left_inv_arrow'; + break; + case 'lean_right': + _shape = 'lean_right'; + break; + case 'lean_left': + _shape = 'lean_left'; + break; + case 'trapezoid': + _shape = 'trapezoid'; + break; + case 'inv_trapezoid': + _shape = 'inv_trapezoid'; + break; + case 'odd_right': + _shape = 'rect_left_inv_arrow'; + break; + case 'circle': + _shape = 'circle'; + break; + case 'ellipse': + _shape = 'ellipse'; + break; + case 'stadium': + _shape = 'stadium'; + break; + case 'subroutine': + _shape = 'subroutine'; + break; + case 'cylinder': + _shape = 'cylinder'; + break; + case 'group': + _shape = 'rect'; + break; + case 'doublecircle': + _shape = 'doublecircle'; + break; + default: + _shape = 'rect'; + } + // Add the node + g.setNode(vertex.id, { + labelStyle: styles.labelStyle, + shape: _shape, + labelText: vertexText, + labelType: vertex.labelType, + rx: radious, + ry: radious, + class: classStr, + style: styles.style, + id: vertex.id, + link: vertex.link, + linkTarget: vertex.linkTarget, + tooltip: diagObj.db.getTooltip(vertex.id) || '', + domId: diagObj.db.lookUpDomId(vertex.id), + haveCallback: vertex.haveCallback, + width: vertex.type === 'group' ? 500 : undefined, + dir: vertex.dir, + type: vertex.type, + props: vertex.props, + padding: getConfig().flowchart.padding, + }); + + log.info('setNode', { + labelStyle: styles.labelStyle, + labelType: vertex.labelType, + shape: _shape, + labelText: vertexText, + rx: radious, + ry: radious, + class: classStr, + style: styles.style, + id: vertex.id, + domId: diagObj.db.lookUpDomId(vertex.id), + width: vertex.type === 'group' ? 500 : undefined, + type: vertex.type, + dir: vertex.dir, + props: vertex.props, + padding: getConfig().flowchart.padding, + }); + }); +}; + +/** + * + * @param diagObj + * @param id + * @param root + * @param doc + */ +function setupGraph(diagObj, id, root, doc) { + const { securityLevel, flowchart: conf } = getConfig(); + const nodeSpacing = conf.nodeSpacing || 50; + const rankSpacing = conf.rankSpacing || 50; + + // Fetch the default direction, use TD if none was found + let dir = diagObj.db.getDirection(); + if (dir === undefined) { + dir = 'TD'; + } + + // Create the input mermaid.graph + const g = new graphlib.Graph({ + multigraph: true, + compound: true, + }) + .setGraph({ + rankdir: dir, + nodesep: nodeSpacing, + ranksep: rankSpacing, + marginx: 0, + marginy: 0, + }) + .setDefaultEdgeLabel(function () { + return {}; + }); + + let subG; + const subGraphs = diagObj.db.getSubGraphs(); + + // Fetch the vertices/nodes and edges/links from the parsed graph definition + const vert = diagObj.db.getVertices(); + + const edges = diagObj.db.getEdges(); + + log.info('Edges', edges); + let i = 0; + // for (i = subGraphs.length - 1; i >= 0; i--) { + // // for (let i = 0; i < subGraphs.length; i++) { + // subG = subGraphs[i]; + + // selectAll('cluster').append('text'); + + // for (let j = 0; j < subG.nodes.length; j++) { + // log.info('Setting up subgraphs', subG.nodes[j], subG.id); + // g.setParent(subG.nodes[j], subG.id); + // } + // } + addVertices(vert, g, id, root, doc, diagObj); + addEdges(edges, g, diagObj); + return g; +} + +export default setupGraph; diff --git a/packages/mermaid/src/diagrams/swimlane/lost-and-found/styles.ts b/packages/mermaid/src/diagrams/swimlane/lost-and-found/styles.ts new file mode 100644 index 000000000..60659df45 --- /dev/null +++ b/packages/mermaid/src/diagrams/swimlane/lost-and-found/styles.ts @@ -0,0 +1,143 @@ +/** Returns the styles given options */ +export interface FlowChartStyleOptions { + arrowheadColor: string; + border2: string; + clusterBkg: string; + clusterBorder: string; + edgeLabelBackground: string; + fontFamily: string; + lineColor: string; + mainBkg: string; + nodeBorder: string; + nodeTextColor: string; + tertiaryColor: string; + textColor: string; + titleColor: string; + [key: string]: string; +} + +const genSections = (options: FlowChartStyleOptions) => { + let sections = ''; + + for (let i = 0; i < 5; i++) { + sections += ` + .subgraph-lvl-${i} { + fill: ${options[`surface${i}`]}; + stroke: ${options[`surfacePeer${i}`]}; + } + `; + } + return sections; +}; + +const getStyles = (options: FlowChartStyleOptions) => + `.label { + font-family: ${options.fontFamily}; + color: ${options.nodeTextColor || options.textColor}; + } + .cluster-label text { + fill: ${options.titleColor}; + } + .cluster-label span { + color: ${options.titleColor}; + } + + .label text,span { + fill: ${options.nodeTextColor || options.textColor}; + color: ${options.nodeTextColor || options.textColor}; + } + + .node rect, + .node circle, + .node ellipse, + .node polygon, + .node path { + fill: ${options.mainBkg}; + stroke: ${options.nodeBorder}; + stroke-width: 1px; + } + + .node .label { + text-align: center; + } + .node.clickable { + cursor: pointer; + } + + .arrowheadPath { + fill: ${options.arrowheadColor}; + } + + .edgePath .path { + stroke: ${options.lineColor}; + stroke-width: 2.0px; + } + + .flowchart-link { + stroke: ${options.lineColor}; + fill: none; + } + + .edgeLabel { + background-color: ${options.edgeLabelBackground}; + rect { + opacity: 0.85; + background-color: ${options.edgeLabelBackground}; + fill: ${options.edgeLabelBackground}; + } + text-align: center; + } + + .cluster rect { + fill: ${options.clusterBkg}; + stroke: ${options.clusterBorder}; + stroke-width: 1px; + } + + .cluster text { + fill: ${options.titleColor}; + } + + .cluster span { + color: ${options.titleColor}; + } + /* .cluster div { + color: ${options.titleColor}; + } */ + + div.mermaidTooltip { + position: absolute; + text-align: center; + max-width: 200px; + padding: 2px; + font-family: ${options.fontFamily}; + font-size: 12px; + background: ${options.tertiaryColor}; + border: 1px solid ${options.border2}; + border-radius: 2px; + pointer-events: none; + z-index: 100; + } + + .flowchartTitleText { + text-anchor: middle; + font-size: 18px; + fill: ${options.textColor}; + } + .subgraph { + stroke-width:2; + rx:3; + } + // .subgraph-lvl-1 { + // fill:#ccc; + // // stroke:black; + // } + + .flowchart-label text { + text-anchor: middle; + } + + ${genSections(options)} +`; + +export default getStyles; diff --git a/packages/mermaid/src/diagrams/swimlane/lost-and-found/swimlane-definition.ts b/packages/mermaid/src/diagrams/swimlane/lost-and-found/swimlane-definition.ts new file mode 100644 index 000000000..6e35c1253 --- /dev/null +++ b/packages/mermaid/src/diagrams/swimlane/lost-and-found/swimlane-definition.ts @@ -0,0 +1,13 @@ +// @ts-ignore: JISON typing missing +import parser from '../parser/flow.jison'; + +import * as db from '../flowDb.js'; +import renderer from './swimlaneRenderer.js'; +import styles from './styles.js'; + +export const diagram = { + db, + renderer, + parser, + styles, +}; diff --git a/packages/mermaid/src/diagrams/swimlane/lost-and-found/swimlane-layout.js b/packages/mermaid/src/diagrams/swimlane/lost-and-found/swimlane-layout.js new file mode 100644 index 000000000..074d9e6da --- /dev/null +++ b/packages/mermaid/src/diagrams/swimlane/lost-and-found/swimlane-layout.js @@ -0,0 +1,220 @@ +import { log } from '../../../logger.js'; +import flowDb from '../flowDb.js'; + +export const getSubgraphLookupTable = function (diagObj) { + const subGraphs = diagObj.db.getSubGraphs(); + const subgraphDb = {}; + log.info('Subgraphs - ', subGraphs); + for (let i = subGraphs.length - 1; i >= 0; i--) { + const subG = subGraphs[i]; + log.info('Subgraph - ', subG); + for (let j = 0; j < subG.nodes.length; j++) { + log.info('Setting up subgraphs', subG.nodes[j], subG.id); + subgraphDb[flowDb.lookUpId(subG.nodes[j])] = subG.id; + } + } + return subgraphDb; +}; + +/** + * + * @param graph + * @param subgraphLookupTable + */ +export function assignRanks(graph, subgraphLookupTable) { + let visited = new Set(); + const lock = new Map(); + const ranks = new Map(); + let cnt = 0; + let changesDetected = true; + + /** + * + * @param nodeId + * @param currentRank + */ + function dfs(nodeId, currentRank) { + if (visited.has(nodeId)) { + return; + } + + visited.add(nodeId); + const existingRank = ranks.get(nodeId) || 0; + + // console.log('APA444 DFS Base case for', nodeId, 'to', Math.max(existingRank, currentRank)); + if (lock.get(nodeId) !== 1) { + ranks.set(nodeId, Math.max(existingRank, currentRank)); + } else { + console.log( + 'APA444 ', + nodeId, + 'was locked to ', + existingRank, + 'so not changing it', + ranks.get(nodeId) + ); + } + + const currentRankAdjusted = ranks.get(nodeId) || currentRank; + graph.successors(nodeId).forEach((targetId) => { + if (subgraphLookupTable[targetId] !== subgraphLookupTable[nodeId]) { + dfs(targetId, currentRankAdjusted); + } else { + // In same line, easy increase + dfs(targetId, currentRankAdjusted + 1); + } + }); + } + + /** + * + */ + function adjustSuccessors() { + console.log('APA444 Adjusting successors'); + graph.nodes().forEach((nodeId) => { + console.log('APA444 Going through nodes', nodeId); + // if (graph.predecessors(nodeId).length === 0) { + console.log('APA444 has no predecessors', nodeId); + graph.successors(nodeId).forEach((successorNodeId) => { + console.log('APA444 has checking successor', successorNodeId); + if (subgraphLookupTable[successorNodeId] !== subgraphLookupTable[nodeId]) { + const newRank = ranks.get(successorNodeId); + ranks.set(nodeId, newRank); + console.log('APA444 POST-process case for', nodeId, 'to', newRank); + lock.set(nodeId, 1); + changesDetected = true; + // setRankFromTopNodes(); + + // Adjust ranks of successors in the same subgraph + graph.successors(nodeId).forEach((sameSubGraphSuccessorNodeId) => { + if (subgraphLookupTable[sameSubGraphSuccessorNodeId] === subgraphLookupTable[nodeId]) { + console.log( + 'APA444 Adjusting rank of', + sameSubGraphSuccessorNodeId, + 'to', + newRank + 1 + ); + ranks.set(sameSubGraphSuccessorNodeId, newRank + 1); + lock.set(sameSubGraphSuccessorNodeId, 1); + changesDetected = true; + // dfs(sameSubGraphSuccessorNodeId, newRank + 1); + // setRankFromTopNodes(); + } + }); + } else { + console.log('APA444 Node', nodeId, ' and ', successorNodeId, ' is in the same lane'); + } + }); + // } + }); + } + + /** + * + */ + function setRankFromTopNodes() { + visited = new Set(); + graph.nodes().forEach((nodeId) => { + if (graph.predecessors(nodeId).length === 0) { + dfs(nodeId, 0); + } + }); + adjustSuccessors(); + } + + while (changesDetected && cnt < 10) { + setRankFromTopNodes(); + cnt++; + } + // Post-process the ranks + + return ranks; +} + +/** + * + * @param graph + * @param subgraphLĂ–ookupTable + * @param ranks + * @param subgraphLookupTable + */ +export function assignAffinities(graph, ranks, subgraphLookupTable) { + const affinities = new Map(); + const swimlaneRankAffinities = new Map(); + const swimlaneMaxAffinity = new Map(); + + graph.nodes().forEach((nodeId) => { + const swimlane = subgraphLookupTable[nodeId]; + const rank = ranks.get(nodeId); + const key = swimlane + ':' + rank; + let currentAffinity = swimlaneRankAffinities.get(key); + if (currentAffinity === undefined) { + currentAffinity = -1; + } + const newAffinity = currentAffinity + 1; + swimlaneRankAffinities.set(key, newAffinity); + affinities.set(nodeId, newAffinity); + let currentMaxAffinity = swimlaneMaxAffinity.get(swimlane); + if (currentMaxAffinity === undefined) { + swimlaneMaxAffinity.set(swimlane, 0); + currentMaxAffinity = 0; + } + if (newAffinity > currentMaxAffinity) { + swimlaneMaxAffinity.set(swimlane, newAffinity); + } + }); + + // console.log('APA444 affinities', swimlaneRankAffinities); + + return { affinities, swimlaneMaxAffinity }; + //return affinities; +} + +/** + * + * @param graph + * @param diagObj + */ +export function swimlaneLayout(graph, diagObj) { + const subgraphLookupTable = getSubgraphLookupTable(diagObj); + const ranks = assignRanks(graph, subgraphLookupTable); + + const { affinities, swimlaneMaxAffinity } = assignAffinities(graph, ranks, subgraphLookupTable); + // const affinities = assignAffinities(graph, ranks, subgraphLookupTable); + + const subGraphs = diagObj.db.getSubGraphs(); + const lanes = []; + const laneDb = {}; + let xPos = 0; + for (const subG of subGraphs) { + const maxAffinity = swimlaneMaxAffinity.get(subG.id); + const lane = { + title: subG.title, + x: xPos, + width: 200 + maxAffinity * 150, + }; + xPos += lane.width; + lanes.push(lane); + laneDb[subG.id] = lane; + } + + const rankWidth = []; + // Basic layout, calculate the node positions based on rank + graph.nodes().forEach((nodeId) => { + const rank = ranks.get(nodeId); + + if (!rankWidth[rank]) { + const laneId = subgraphLookupTable[nodeId]; + const lane = laneDb[laneId]; + const n = graph.node(nodeId); + console.log('Node', nodeId, n); + const affinity = affinities.get(nodeId); + + console.log('APA444', nodeId, 'rank', rank, 'affinity', affinity); + graph.setNode(nodeId, { y: rank * 200 + 50, x: lane.x + 150 * affinity + 100 }); + // lane.width = Math.max(lane.width, lane.x + 150*affinity + lane.width / 4); + } + }); + + return { graph, lanes }; +} diff --git a/packages/mermaid/src/diagrams/swimlane/lost-and-found/swimlane-layout.spec.ts b/packages/mermaid/src/diagrams/swimlane/lost-and-found/swimlane-layout.spec.ts new file mode 100644 index 000000000..96420ee11 --- /dev/null +++ b/packages/mermaid/src/diagrams/swimlane/lost-and-found/swimlane-layout.spec.ts @@ -0,0 +1,129 @@ +import flowDb from '../flowDb.js'; +import { cleanupComments } from '../../../diagram-api/comments.js'; +import setupGraph from './setup-graph.js'; +import { swimlaneLayout, assignRanks, getSubgraphLookupTable } from './swimlane-layout.js'; +import { getDiagramFromText } from '../../../Diagram.js'; +import { addDiagrams } from '../../../diagram-api/diagram-orchestration.ts'; +import jsdom from 'jsdom'; + +const { JSDOM } = jsdom; + +addDiagrams(); +describe('When doing a assigning ranks specific for swim lanes ', () => { + let root; + let doc; + beforeEach(function () { + const dom = new JSDOM(`
My First JSDOM!
`); + root = select(dom.window.document.getElementById('swimmer')); + root.html = () => { + ' return
hello
'; + }; + + doc = dom.window.document; + }); + describe('Layout: ', () => { + // it('should rank the nodes:', async () => { + // const diagram = await getDiagramFromText(`swimlane LR + // subgraph "\`one\`" + // start --> cat --> rat + // end`); + // const g = setupGraph(diagram, 'swimmer', root, doc); + // const subgraphLookupTable = getSubgraphLookupTable(diagram); + // const ranks = assignRanks(g, subgraphLookupTable); + // expect(ranks.get('start')).toEqual(0); + // expect(ranks.get('cat')).toEqual(1); + // expect(ranks.get('rat')).toEqual(2); + // }); + + it('should rank the nodes:', async () => { + const diagram = await getDiagramFromText(`swimlane LR + subgraph "\`one\`" + start --> cat --> rat + end + subgraph "\`two\`" + monkey --> dog --> done + end + cat --> monkey`); + const g = setupGraph(diagram, 'swimmer', root, doc); + const subgraphLookupTable = getSubgraphLookupTable(diagram); + const ranks = assignRanks(g, subgraphLookupTable); + expect(ranks.get('start')).toEqual(0); + expect(ranks.get('cat')).toEqual(1); + expect(ranks.get('rat')).toEqual(2); + expect(ranks.get('monkey')).toEqual(1); + expect(ranks.get('dog')).toEqual(2); + expect(ranks.get('done')).toEqual(3); + }); + }); + describe('Layout: ', () => { + it('should rank the nodes:', async () => { + const diagram = await getDiagramFromText(`swimlane LR + subgraph "\`one\`" + start --> cat --> rat + end`); + const g = setupGraph(diagram, 'swimmer', root, doc); + const subgraphLookupTable = getSubgraphLookupTable(diagram); + const { graph, lanes } = swimlaneLayout(g, diagram); + expect(lanes.length).toBe(1); + const start = graph.node('start'); + const cat = graph.node('cat'); + const rat = graph.node('rat'); + expect(start.y).toBe(50); + expect(cat.y).toBe(250); + expect(rat.y).toBe(450); + expect(rat.x).toBe(100); + }); + + it('should rank the nodes:', async () => { + const diagram = await getDiagramFromText(`swimlane LR + subgraph "\`one\`" + start --> cat --> rat + end + subgraph "\`two\`" + monkey --> dog --> done + end + cat --> monkey`); + const g = setupGraph(diagram, 'swimmer', root, doc); + const subgraphLookupTable = getSubgraphLookupTable(diagram); + const { graph, lanes } = swimlaneLayout(g, diagram); + expect(lanes.length).toBe(2); + const start = graph.node('start'); + const cat = graph.node('cat'); + const rat = graph.node('rat'); + const monkey = graph.node('monkey'); + const dog = graph.node('dog'); + const done = graph.node('done'); + + expect(start.y).toBe(50); + expect(cat.y).toBe(250); + expect(rat.y).toBe(450); + expect(rat.x).toBe(100); + expect(monkey.y).toBe(250); + expect(dog.y).toBe(450); + expect(done.y).toBe(650); + expect(monkey.x).toBe(300); + }); + it.only('should rank the nodes:', async () => { + const diagram = await getDiagramFromText(`swimlane LR + subgraph "\`one\`" + start --> cat --> rat & hat + end + `); + const g = setupGraph(diagram, 'swimmer', root, doc); + const subgraphLookupTable = getSubgraphLookupTable(diagram); + const { graph, lanes } = swimlaneLayout(g, diagram); + expect(lanes.length).toBe(1); + const start = graph.node('start'); + const cat = graph.node('cat'); + const rat = graph.node('rat'); + const hat = graph.node('rat'); + + expect(start.y).toBe(50); + expect(cat.y).toBe(250); + expect(rat.y).toBe(450); + expect(rat.x).toBe(300); + expect(hat.y).toBe(450); + expect(hat.x).toBe(100); + }); + }); +}); diff --git a/packages/mermaid/src/diagrams/swimlane/lost-and-found/swimlaneRenderer.js b/packages/mermaid/src/diagrams/swimlane/lost-and-found/swimlaneRenderer.js new file mode 100644 index 000000000..6b1c9b2a5 --- /dev/null +++ b/packages/mermaid/src/diagrams/swimlane/lost-and-found/swimlaneRenderer.js @@ -0,0 +1,596 @@ +import * as graphlib from 'dagre-d3-es/src/graphlib/index.js'; +import { select, curveLinear, selectAll } from 'd3'; +import { swimlaneLayout } from './swimlane-layout.js'; +import insertMarkers from '../../../dagre-wrapper/markers.js'; +import { insertNode } from '../../../dagre-wrapper/nodes.js'; +import flowDb from '../flowDb.js'; +import { getConfig } from '../../../config.js'; +import {getStylesFromArray} from '../../../utils.js'; +import setupGraph, { addEdges, addVertices } from './setup-graph.js'; +import { render } from '../../../dagre-wrapper/index.js'; +import { log } from '../../../logger.js'; +import { setupGraphViewbox } from '../../../setupGraphViewbox.js'; +import common, { evaluate } from '../../common/common.js'; +import { addHtmlLabel } from 'dagre-d3-es/src/dagre-js/label/add-html-label.js'; +import { insertEdge,positionEdgeLabel } from '../../../dagre-wrapper/edges.js'; +import { + clear as clearGraphlib, + clusterDb, + adjustClustersAndEdges, + findNonClusterChild, + sortNodesByHierarchy, +} from '../../../dagre-wrapper/mermaid-graphlib.js'; + + +const conf = {}; +export const setConf = function (cnf) { + const keys = Object.keys(cnf); + for (const key of keys) { + conf[key] = cnf[key]; + } +}; + +/** + * + * @param element + * @param graph + * @param layout + * @param vert + * @param elem + * @param g + * @param id + * @param conf + */ +async function swimlaneRender(layout,vert, elem,g, id, conf) { + + let renderedNodes = []; + // draw nodes from layout.graph to element + const nodes = layout.graph.nodes(); + + // lanes are the swimlanes + const lanes = layout.lanes; + + + + const nodesElements = elem.insert('g').attr('class', 'nodes'); + // for each node, draw a rect, with a child text inside as label + for (const node of nodes) { + const nodeFromLayout = layout.graph.node(node); + const vertex = vert[node]; + //Initialise the node + /** + * Variable for storing the classes for the vertex + * + * @type {string} + */ + let classStr = 'default'; + if (vertex.classes.length > 0) { + classStr = vertex.classes.join(' '); + } + classStr = classStr + ' swimlane-label'; + const styles = getStylesFromArray(vertex.styles); + + // Use vertex id as text in the box if no text is provided by the graph definition + let vertexText = vertex.text !== undefined ? vertex.text : vertex.id; + + // We create a SVG label, either by delegating to addHtmlLabel or manually + let vertexNode; + log.info('vertex', vertex, vertex.labelType); + if (vertex.labelType === 'markdown') { + log.info('vertex', vertex, vertex.labelType); + } else { + if (evaluate(getConfig().flowchart.htmlLabels)) { + // TODO: addHtmlLabel accepts a labelStyle. Do we possibly have that? + const node = { + label: vertexText.replace( + /fa[blrs]?:fa-[\w-]+/g, + (s) => `` + ), + }; + vertexNode = addHtmlLabel(elem, node).node(); + vertexNode.parentNode.removeChild(vertexNode); + } else { + const svgLabel = doc.createElementNS('http://www.w3.org/2000/svg', 'text'); + svgLabel.setAttribute('style', styles.labelStyle.replace('color:', 'fill:')); + + const rows = vertexText.split(common.lineBreakRegex); + + for (const row of rows) { + const tspan = doc.createElementNS('http://www.w3.org/2000/svg', 'tspan'); + tspan.setAttributeNS('http://www.w3.org/XML/1998/namespace', 'xml:space', 'preserve'); + tspan.setAttribute('dy', '1em'); + tspan.setAttribute('x', '1'); + tspan.textContent = row; + svgLabel.appendChild(tspan); + } + vertexNode = svgLabel; + } + } + + let radious = 0; + let _shape = ''; + // Set the shape based parameters + switch (vertex.type) { + case 'round': + radious = 5; + _shape = 'rect'; + break; + case 'square': + _shape = 'rect'; + break; + case 'diamond': + _shape = 'question'; + break; + case 'hexagon': + _shape = 'hexagon'; + break; + case 'odd': + _shape = 'rect_left_inv_arrow'; + break; + case 'lean_right': + _shape = 'lean_right'; + break; + case 'lean_left': + _shape = 'lean_left'; + break; + case 'trapezoid': + _shape = 'trapezoid'; + break; + case 'inv_trapezoid': + _shape = 'inv_trapezoid'; + break; + case 'odd_right': + _shape = 'rect_left_inv_arrow'; + break; + case 'circle': + _shape = 'circle'; + break; + case 'ellipse': + _shape = 'ellipse'; + break; + case 'stadium': + _shape = 'stadium'; + break; + case 'subroutine': + _shape = 'subroutine'; + break; + case 'cylinder': + _shape = 'cylinder'; + break; + case 'group': + _shape = 'rect'; + break; + case 'doublecircle': + _shape = 'doublecircle'; + break; + default: + _shape = 'rect'; + } + // Add the node + let nodeObj ={ + labelStyle: styles.labelStyle, + shape: _shape, + labelText: vertexText, + labelType: vertex.labelType, + rx: radious, + ry: radious, + class: classStr, + style: styles.style, + id: vertex.id, + link: vertex.link, + linkTarget: vertex.linkTarget, + // tooltip: diagObj.db.getTooltip(vertex.id) || '', + // domId: diagObj.db.lookUpDomId(vertex.id), + haveCallback: vertex.haveCallback, + width: vertex.type === 'group' ? 500 : undefined, + dir: vertex.dir, + type: vertex.type, + props: vertex.props, + padding: getConfig().flowchart.padding, + x: nodeFromLayout.x, + y: nodeFromLayout.y, + }; + + let boundingBox; + let nodeEl; + + // Add the element to the DOM + + nodeEl = await insertNode(nodesElements, nodeObj, vertex.dir); + boundingBox = nodeEl.node().getBBox(); + nodeEl.attr('transform', `translate(${nodeObj.x}, ${nodeObj.y / 2})`); + + // add to rendered nodes + renderedNodes.push({id: vertex.id, nodeObj: nodeObj, boundingBox: boundingBox}); + + } + + + return renderedNodes; +} + +/** + * Returns the all the styles from classDef statements in the graph definition. + * + * @param text + * @param diagObj + * @returns {object} ClassDef styles + */ +// export const getClasses = function (text, diagObj) { +// log.info('Extracting classes'); +// diagObj.db.clear(); +// try { +// // Parse the graph definition +// diagObj.parse(text); +// return diagObj.db.getClasses(); +// } catch (e) { +// return; +// } +// }; + +/** + * Returns the all the styles from classDef statements in the graph definition. + * + * @param text + * @param diagObj + * @returns {Record} ClassDef styles + */ +export const getClasses = function (text, diagObj) { + log.info('Extracting classes'); + return diagObj.db.getClasses(); +}; + +/** + * Draws a flowchart in the tag with id: id based on the graph definition in text. + * + * @param text + * @param id + */ + +export const draw = async function (text, id, _version, diagObj) { + log.info('Drawing flowchart'); + diagObj.db.clear(); + flowDb.setGen('gen-2'); + // Parse the graph definition + diagObj.parser.parse(text); + + const { securityLevel, flowchart: conf } = getConfig(); + + // Handle root and document for when rendering in sandbox mode + let sandboxElement; + if (securityLevel === 'sandbox') { + sandboxElement = select('#i' + id); + } + const root = + securityLevel === 'sandbox' + ? select(sandboxElement.nodes()[0].contentDocument.body) + : select('body'); + const doc = securityLevel === 'sandbox' ? sandboxElement.nodes()[0].contentDocument : document; + +// create g as a graphlib graph using setupGraph from setup-graph.js + const g = setupGraph(diagObj, id, root, doc); + + + + let subG; + const subGraphs = diagObj.db.getSubGraphs(); + log.info('Subgraphs - ', subGraphs); + for (let i = subGraphs.length - 1; i >= 0; i--) { + subG = subGraphs[i]; + log.info('Subgraph - ', subG); + diagObj.db.addVertex( + subG.id, + { text: subG.title, type: subG.labelType }, + 'group', + undefined, + subG.classes, + subG.dir + ); + } + + // Fetch the vertices/nodes and edges/links from the parsed graph definition + const vert = diagObj.db.getVertices(); + + const edges = diagObj.db.getEdges(); + + log.info('Edges', edges); + + const svg = root.select('#' + id); + + svg.append('g'); + + // Run the renderer. This is what draws the final graph. + // const element = root.select('#' + id + ' g'); +console.log('diagObj',diagObj); + console.log('subGraphs', diagObj.db.getSubGraphs()); + const layout = swimlaneLayout(g, diagObj); + console.log('custom layout',layout); + + + // insert markers + // Define the supported markers for the diagram + const markers = ['point', 'circle', 'cross']; + insertMarkers(svg, markers, 'flowchart', id); + // draw lanes as vertical lines + const lanesElements = svg.insert('g').attr('class', 'lanes'); + + + let laneCount = 0; + + for (const lane of layout.lanes) { + + laneCount++; + + //draw lane header as rectangle with lane title centered in it + const laneHeader = document.createElementNS("http://www.w3.org/2000/svg", "rect"); + + // Set attributes for the rectangle + laneHeader.setAttribute("x",lane.x); // x-coordinate of the top-left corner + laneHeader.setAttribute("y", -50); // y-coordinate of the top-left corner + laneHeader.setAttribute("width", lane.width); // width of the rectangle + laneHeader.setAttribute("height", "50"); // height of the rectangle + if(laneCount % 2 == 0){ + //set light blue color for even lanes + laneHeader.setAttribute("fill", "blue"); // fill color of the rectangle + }else{ + //set white color odd lanes + laneHeader.setAttribute("fill", "grey"); // fill color of the rectangle + } + + laneHeader.setAttribute("stroke", "black"); // color of the stroke/border + laneHeader.setAttribute("stroke-width", "2"); // width of the stroke/border + + // Append the rectangle to the SVG element + lanesElements.node().appendChild(laneHeader); + + //draw lane title + const laneTitle = document.createElementNS("http://www.w3.org/2000/svg", "text"); + + // Set attributes for the rectangle + laneTitle.setAttribute("x",lane.x + lane.width/2); // x-coordinate of the top-left corner + laneTitle.setAttribute("y", -50 + 50/2); // y-coordinate of the top-left corner + laneTitle.setAttribute("width", lane.width); // width of the rectangle + laneTitle.setAttribute("height", "50"); // height of the rectangle + laneTitle.setAttribute("fill", "white"); // fill color of the rectangle + laneTitle.setAttribute("stroke-width", "1"); // width of the stroke/border + laneTitle.setAttribute("text-anchor", "middle"); // width of the stroke/border + laneTitle.setAttribute("alignment-baseline", "middle"); // width of the stroke/border + laneTitle.setAttribute("font-size", "20"); // width of the stroke/border + laneTitle.textContent = lane.title; + + // Append the rectangle to the SVG element + lanesElements.node().appendChild(laneTitle); + + //draw lane + + // Create a element + const rectangle = document.createElementNS("http://www.w3.org/2000/svg", "rect"); + + // Set attributes for the rectangle + rectangle.setAttribute("x",lane.x); // x-coordinate of the top-left corner + rectangle.setAttribute("y", 0); // y-coordinate of the top-left corner + rectangle.setAttribute("width", lane.width); // width of the rectangle + rectangle.setAttribute("height", "500"); // height of the rectangle + + if(laneCount % 2 == 0){ + //set light blue color for even lanes + rectangle.setAttribute("fill", "lightblue"); // fill color of the rectangle + }else{ + //set white color odd lanes + rectangle.setAttribute("fill", "#ffffff"); // fill color of the rectangle + } + + rectangle.setAttribute("stroke", "black"); // color of the stroke/border + rectangle.setAttribute("stroke-width", "2"); // width of the stroke/border + + // Append the rectangle to the SVG element + lanesElements.node().appendChild(rectangle); + } + + // append lanesElements to elem + svg.node().appendChild(lanesElements.node()); + + // add lane headers + const laneHeaders = svg.insert('g').attr('class', 'laneHeaders'); + + let drawnEdges =[]; + + //get edge markers + + + + + + + let renderedNodes = await swimlaneRender(layout,vert, svg,g,id, conf); +let renderedEdgePaths= []; + addEdges(edges, g, diagObj,svg); + + g.edges().forEach(function (e) { + const edge = g.edge(e); + log.info('Edge ' + e.v + ' -> ' + e.w + ': ' + JSON.stringify(edge), edge); + const edgePaths = svg.insert('g').attr('class', 'edgePaths'); + + + + //get start node x, y coordinates + + let sourceNode = {x:layout.graph.node(e.v).x, y:layout.graph.node(e.v).y/2, id: e.v}; + //get end node x, y coordinates= + const targetNode = {x:layout.graph.node(e.w).x, y:layout.graph.node(e.w).y/2, id: e.w}; + + + //create edge points based on start and end node + edge.points = getEdgePoints(sourceNode, targetNode, drawnEdges, renderedNodes,renderedEdgePaths); + + + // add to drawn edges + drawnEdges.push(edge); + + const paths = insertEdge(edgePaths, e, edge, clusterDb, 'flowchart', g); + //positionEdgeLabel(edge, paths); + }); + + + + + // utils.insertTitle(svg, 'flowchartTitleText', conf.titleTopMargin, diagObj.db.getDiagramTitle()); + + setupGraphViewbox(g, svg, conf.diagramPadding, conf.useMaxWidth); +}; + +// function to find edge path points based on start and end node +/** + * + * @param startNode + * @param endNode + * @param drawnEdges + * @param renderedNodes + */ +function getEdgePoints(startNode, endNode, drawnEdges, renderedNodes) { + + let potentialEdgePaths = []; + + for(let i=1;i<=3;i++){ + const points = []; + + // add start point + points.push({ x: startNode.x, y: startNode.y }) + + // Point in the middle, if both nodes do not have same x or y + if (startNode.x !== endNode.x && startNode.y !== endNode.y && i!=1) { + + if(i==2){ + points.push({ x: startNode.x, y: endNode.y }); + }else{ + points.push({ x: endNode.x, y: startNode.y }); + } + } + // add end point + points.push({ x: endNode.x, y: endNode.y }); + + + //print points + console.log('points before intersection', points); + + // get start and end node objects from array of rendered nodes + const startNodeObj = renderedNodes.find(node => node.id === startNode.id); + const endNodeObj = renderedNodes.find(node => node.id === endNode.id); + + console.log(" intersection startNodeObj", startNodeObj); + console.log(" intersection endNodeObj", endNodeObj); + startNodeObj.nodeObj.x = startNode.x; + startNodeObj.nodeObj.y = startNode.y; + // the first point should be the intersection of the start node and the edge + let startInsection = startNodeObj.nodeObj.intersect(points[1]); + points[0] = startInsection; + + //log intersection + console.log('start intersection', startInsection); + + endNodeObj.nodeObj.x = endNode.x; + endNodeObj.nodeObj.y = endNode.y; + // the last point should be the intersection of the end node and the edge + let endInsection = endNodeObj.nodeObj.intersect(points[points.length - 2]); + points[points.length - 1] = endInsection; + + //log intersection + console.log('end intersection', endInsection); + + //push points to potential edge paths + potentialEdgePaths.push({points: points}); + } + + // Create a new list of renderedNodes without the start and end node + const filteredRenderedNodes = renderedNodes.filter(node => node.id !== startNode.id && node.id !== endNode.id); + + //Rank the potential edge path + const rankedEdgePaths = rankEdgePaths(potentialEdgePaths, filteredRenderedNodes); + if(startNode.id==='sheep' && endNode.id === 'dog'){ + console.log('sheep--> dog rankedEdgePaths', rankedEdgePaths); + } + + return rankedEdgePaths[0].edgePath.points; + +} + +// Function to check if a point is inside a nodes bounding box +/** + * + * @param point + * @param nodes + */ +function isPointInsideNode(point, nodes) { + let isInside = false; + for (const node of nodes) { + if ( + point.x >= node.nodeObj.x && + point.x <= node.nodeObj.x + node.boundingBox.width && + point.y >= node.nodeObj.y && + point.y <= node.nodeObj.y + node.boundingBox.height + ) { + isInside = true; + } + } + return isInside; +} + +// Ranks edgePaths (points) based on the number of intersections with nodes +/** + * + * @param edgePaths + * @param nodes + */ +function rankEdgePaths(edgePaths, nodes) { + let rankedEdgePaths = []; + for (const edgePath of edgePaths) { + let rank = 10 + edgePath.points.length; + for (const point of edgePath.points) { + if (isPointInsideNode(point, nodes)) { + // remove edge path + + } + } + rankedEdgePaths.push({ rank: rank, edgePath: edgePath }); + } + + //sort on the basis of rank, highest rank first + rankedEdgePaths.sort((a, b) => (a.rank < b.rank ? 1 : -1)); + return rankedEdgePaths; +} + + +/** + * Function to find if edge path is intersecting with any other edge path + * @param edgePath + * @param renderedEdgePaths + * @returns {boolean} + */ +function isEdgePathIntersecting(edgePath, renderedEdgePaths) { + let isIntersecting = false; + for (const renderedEdgePath of renderedEdgePaths) { + // check if line drawn from start point of edge path to start point of rendered edge path is intersecting with any other edge path + + if ( + common.isLineIntersecting( + edgePath.points[0], + renderedEdgePath.points[0], + edgePath.points[1], + renderedEdgePath.points[1] + ) + ) { + isIntersecting = true; + } + } + return isIntersecting; +} + + + +export default { + setConf, + addVertices, + addEdges, + getClasses, + draw, +}; diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/swimlane/swimlane-layout.js b/packages/mermaid/src/rendering-util/layout-algorithms/swimlane/swimlane-layout.js new file mode 100644 index 000000000..074d9e6da --- /dev/null +++ b/packages/mermaid/src/rendering-util/layout-algorithms/swimlane/swimlane-layout.js @@ -0,0 +1,220 @@ +import { log } from '../../../logger.js'; +import flowDb from '../flowDb.js'; + +export const getSubgraphLookupTable = function (diagObj) { + const subGraphs = diagObj.db.getSubGraphs(); + const subgraphDb = {}; + log.info('Subgraphs - ', subGraphs); + for (let i = subGraphs.length - 1; i >= 0; i--) { + const subG = subGraphs[i]; + log.info('Subgraph - ', subG); + for (let j = 0; j < subG.nodes.length; j++) { + log.info('Setting up subgraphs', subG.nodes[j], subG.id); + subgraphDb[flowDb.lookUpId(subG.nodes[j])] = subG.id; + } + } + return subgraphDb; +}; + +/** + * + * @param graph + * @param subgraphLookupTable + */ +export function assignRanks(graph, subgraphLookupTable) { + let visited = new Set(); + const lock = new Map(); + const ranks = new Map(); + let cnt = 0; + let changesDetected = true; + + /** + * + * @param nodeId + * @param currentRank + */ + function dfs(nodeId, currentRank) { + if (visited.has(nodeId)) { + return; + } + + visited.add(nodeId); + const existingRank = ranks.get(nodeId) || 0; + + // console.log('APA444 DFS Base case for', nodeId, 'to', Math.max(existingRank, currentRank)); + if (lock.get(nodeId) !== 1) { + ranks.set(nodeId, Math.max(existingRank, currentRank)); + } else { + console.log( + 'APA444 ', + nodeId, + 'was locked to ', + existingRank, + 'so not changing it', + ranks.get(nodeId) + ); + } + + const currentRankAdjusted = ranks.get(nodeId) || currentRank; + graph.successors(nodeId).forEach((targetId) => { + if (subgraphLookupTable[targetId] !== subgraphLookupTable[nodeId]) { + dfs(targetId, currentRankAdjusted); + } else { + // In same line, easy increase + dfs(targetId, currentRankAdjusted + 1); + } + }); + } + + /** + * + */ + function adjustSuccessors() { + console.log('APA444 Adjusting successors'); + graph.nodes().forEach((nodeId) => { + console.log('APA444 Going through nodes', nodeId); + // if (graph.predecessors(nodeId).length === 0) { + console.log('APA444 has no predecessors', nodeId); + graph.successors(nodeId).forEach((successorNodeId) => { + console.log('APA444 has checking successor', successorNodeId); + if (subgraphLookupTable[successorNodeId] !== subgraphLookupTable[nodeId]) { + const newRank = ranks.get(successorNodeId); + ranks.set(nodeId, newRank); + console.log('APA444 POST-process case for', nodeId, 'to', newRank); + lock.set(nodeId, 1); + changesDetected = true; + // setRankFromTopNodes(); + + // Adjust ranks of successors in the same subgraph + graph.successors(nodeId).forEach((sameSubGraphSuccessorNodeId) => { + if (subgraphLookupTable[sameSubGraphSuccessorNodeId] === subgraphLookupTable[nodeId]) { + console.log( + 'APA444 Adjusting rank of', + sameSubGraphSuccessorNodeId, + 'to', + newRank + 1 + ); + ranks.set(sameSubGraphSuccessorNodeId, newRank + 1); + lock.set(sameSubGraphSuccessorNodeId, 1); + changesDetected = true; + // dfs(sameSubGraphSuccessorNodeId, newRank + 1); + // setRankFromTopNodes(); + } + }); + } else { + console.log('APA444 Node', nodeId, ' and ', successorNodeId, ' is in the same lane'); + } + }); + // } + }); + } + + /** + * + */ + function setRankFromTopNodes() { + visited = new Set(); + graph.nodes().forEach((nodeId) => { + if (graph.predecessors(nodeId).length === 0) { + dfs(nodeId, 0); + } + }); + adjustSuccessors(); + } + + while (changesDetected && cnt < 10) { + setRankFromTopNodes(); + cnt++; + } + // Post-process the ranks + + return ranks; +} + +/** + * + * @param graph + * @param subgraphLĂ–ookupTable + * @param ranks + * @param subgraphLookupTable + */ +export function assignAffinities(graph, ranks, subgraphLookupTable) { + const affinities = new Map(); + const swimlaneRankAffinities = new Map(); + const swimlaneMaxAffinity = new Map(); + + graph.nodes().forEach((nodeId) => { + const swimlane = subgraphLookupTable[nodeId]; + const rank = ranks.get(nodeId); + const key = swimlane + ':' + rank; + let currentAffinity = swimlaneRankAffinities.get(key); + if (currentAffinity === undefined) { + currentAffinity = -1; + } + const newAffinity = currentAffinity + 1; + swimlaneRankAffinities.set(key, newAffinity); + affinities.set(nodeId, newAffinity); + let currentMaxAffinity = swimlaneMaxAffinity.get(swimlane); + if (currentMaxAffinity === undefined) { + swimlaneMaxAffinity.set(swimlane, 0); + currentMaxAffinity = 0; + } + if (newAffinity > currentMaxAffinity) { + swimlaneMaxAffinity.set(swimlane, newAffinity); + } + }); + + // console.log('APA444 affinities', swimlaneRankAffinities); + + return { affinities, swimlaneMaxAffinity }; + //return affinities; +} + +/** + * + * @param graph + * @param diagObj + */ +export function swimlaneLayout(graph, diagObj) { + const subgraphLookupTable = getSubgraphLookupTable(diagObj); + const ranks = assignRanks(graph, subgraphLookupTable); + + const { affinities, swimlaneMaxAffinity } = assignAffinities(graph, ranks, subgraphLookupTable); + // const affinities = assignAffinities(graph, ranks, subgraphLookupTable); + + const subGraphs = diagObj.db.getSubGraphs(); + const lanes = []; + const laneDb = {}; + let xPos = 0; + for (const subG of subGraphs) { + const maxAffinity = swimlaneMaxAffinity.get(subG.id); + const lane = { + title: subG.title, + x: xPos, + width: 200 + maxAffinity * 150, + }; + xPos += lane.width; + lanes.push(lane); + laneDb[subG.id] = lane; + } + + const rankWidth = []; + // Basic layout, calculate the node positions based on rank + graph.nodes().forEach((nodeId) => { + const rank = ranks.get(nodeId); + + if (!rankWidth[rank]) { + const laneId = subgraphLookupTable[nodeId]; + const lane = laneDb[laneId]; + const n = graph.node(nodeId); + console.log('Node', nodeId, n); + const affinity = affinities.get(nodeId); + + console.log('APA444', nodeId, 'rank', rank, 'affinity', affinity); + graph.setNode(nodeId, { y: rank * 200 + 50, x: lane.x + 150 * affinity + 100 }); + // lane.width = Math.max(lane.width, lane.x + 150*affinity + lane.width / 4); + } + }); + + return { graph, lanes }; +} diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/swimlane/swimlane-layout.spec.ts b/packages/mermaid/src/rendering-util/layout-algorithms/swimlane/swimlane-layout.spec.ts new file mode 100644 index 000000000..96420ee11 --- /dev/null +++ b/packages/mermaid/src/rendering-util/layout-algorithms/swimlane/swimlane-layout.spec.ts @@ -0,0 +1,129 @@ +import flowDb from '../flowDb.js'; +import { cleanupComments } from '../../../diagram-api/comments.js'; +import setupGraph from './setup-graph.js'; +import { swimlaneLayout, assignRanks, getSubgraphLookupTable } from './swimlane-layout.js'; +import { getDiagramFromText } from '../../../Diagram.js'; +import { addDiagrams } from '../../../diagram-api/diagram-orchestration.ts'; +import jsdom from 'jsdom'; + +const { JSDOM } = jsdom; + +addDiagrams(); +describe('When doing a assigning ranks specific for swim lanes ', () => { + let root; + let doc; + beforeEach(function () { + const dom = new JSDOM(`
My First JSDOM!
`); + root = select(dom.window.document.getElementById('swimmer')); + root.html = () => { + ' return
hello
'; + }; + + doc = dom.window.document; + }); + describe('Layout: ', () => { + // it('should rank the nodes:', async () => { + // const diagram = await getDiagramFromText(`swimlane LR + // subgraph "\`one\`" + // start --> cat --> rat + // end`); + // const g = setupGraph(diagram, 'swimmer', root, doc); + // const subgraphLookupTable = getSubgraphLookupTable(diagram); + // const ranks = assignRanks(g, subgraphLookupTable); + // expect(ranks.get('start')).toEqual(0); + // expect(ranks.get('cat')).toEqual(1); + // expect(ranks.get('rat')).toEqual(2); + // }); + + it('should rank the nodes:', async () => { + const diagram = await getDiagramFromText(`swimlane LR + subgraph "\`one\`" + start --> cat --> rat + end + subgraph "\`two\`" + monkey --> dog --> done + end + cat --> monkey`); + const g = setupGraph(diagram, 'swimmer', root, doc); + const subgraphLookupTable = getSubgraphLookupTable(diagram); + const ranks = assignRanks(g, subgraphLookupTable); + expect(ranks.get('start')).toEqual(0); + expect(ranks.get('cat')).toEqual(1); + expect(ranks.get('rat')).toEqual(2); + expect(ranks.get('monkey')).toEqual(1); + expect(ranks.get('dog')).toEqual(2); + expect(ranks.get('done')).toEqual(3); + }); + }); + describe('Layout: ', () => { + it('should rank the nodes:', async () => { + const diagram = await getDiagramFromText(`swimlane LR + subgraph "\`one\`" + start --> cat --> rat + end`); + const g = setupGraph(diagram, 'swimmer', root, doc); + const subgraphLookupTable = getSubgraphLookupTable(diagram); + const { graph, lanes } = swimlaneLayout(g, diagram); + expect(lanes.length).toBe(1); + const start = graph.node('start'); + const cat = graph.node('cat'); + const rat = graph.node('rat'); + expect(start.y).toBe(50); + expect(cat.y).toBe(250); + expect(rat.y).toBe(450); + expect(rat.x).toBe(100); + }); + + it('should rank the nodes:', async () => { + const diagram = await getDiagramFromText(`swimlane LR + subgraph "\`one\`" + start --> cat --> rat + end + subgraph "\`two\`" + monkey --> dog --> done + end + cat --> monkey`); + const g = setupGraph(diagram, 'swimmer', root, doc); + const subgraphLookupTable = getSubgraphLookupTable(diagram); + const { graph, lanes } = swimlaneLayout(g, diagram); + expect(lanes.length).toBe(2); + const start = graph.node('start'); + const cat = graph.node('cat'); + const rat = graph.node('rat'); + const monkey = graph.node('monkey'); + const dog = graph.node('dog'); + const done = graph.node('done'); + + expect(start.y).toBe(50); + expect(cat.y).toBe(250); + expect(rat.y).toBe(450); + expect(rat.x).toBe(100); + expect(monkey.y).toBe(250); + expect(dog.y).toBe(450); + expect(done.y).toBe(650); + expect(monkey.x).toBe(300); + }); + it.only('should rank the nodes:', async () => { + const diagram = await getDiagramFromText(`swimlane LR + subgraph "\`one\`" + start --> cat --> rat & hat + end + `); + const g = setupGraph(diagram, 'swimmer', root, doc); + const subgraphLookupTable = getSubgraphLookupTable(diagram); + const { graph, lanes } = swimlaneLayout(g, diagram); + expect(lanes.length).toBe(1); + const start = graph.node('start'); + const cat = graph.node('cat'); + const rat = graph.node('rat'); + const hat = graph.node('rat'); + + expect(start.y).toBe(50); + expect(cat.y).toBe(250); + expect(rat.y).toBe(450); + expect(rat.x).toBe(300); + expect(hat.y).toBe(450); + expect(hat.x).toBe(100); + }); + }); +});