diff --git a/packages/mermaid/src/diagrams/state/stateDiagram-v2.ts b/packages/mermaid/src/diagrams/state/stateDiagram-v2.ts index e71a9c7f5..9d0a82a87 100644 --- a/packages/mermaid/src/diagrams/state/stateDiagram-v2.ts +++ b/packages/mermaid/src/diagrams/state/stateDiagram-v2.ts @@ -3,8 +3,8 @@ import type { DiagramDefinition } from '../../diagram-api/types.js'; import parser from './parser/stateDiagram.jison'; import db from './stateDb.js'; import styles from './styles.js'; -import renderer from './stateRenderer-v2.js'; -// import renderer from './stateRenderer-v3-unified.js'; +// import renderer from './stateRenderer-v2.js'; +import renderer from './stateRenderer-v3-unified.js'; export const diagram: DiagramDefinition = { parser, diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/dagre.js b/packages/mermaid/src/rendering-util/layout-algorithms/dagre/index.js similarity index 80% rename from packages/mermaid/src/rendering-util/layout-algorithms/dagre.js rename to packages/mermaid/src/rendering-util/layout-algorithms/dagre/index.js index 76685dd7b..f6760f96f 100644 --- a/packages/mermaid/src/rendering-util/layout-algorithms/dagre.js +++ b/packages/mermaid/src/rendering-util/layout-algorithms/dagre/index.js @@ -1,7 +1,8 @@ import { layout as dagreLayout } from 'dagre-d3-es/src/dagre/index.js'; import * as graphlibJson from 'dagre-d3-es/src/graphlib/json.js'; -import insertMarkers from './markers.js'; -import { updateNodeBounds } from './shapes/util.js'; +import * as graphlib from 'dagre-d3-es/src/graphlib/index.js'; +import insertMarkers from '../../rendering-elements/markers.js'; +import { updateNodeBounds } from '../../rendering-elements/shapes/util.js'; import { clear as clearGraphlib, clusterDb, @@ -9,12 +10,22 @@ import { findNonClusterChild, sortNodesByHierarchy, } from './mermaid-graphlib.js'; -import { insertNode, positionNode, clear as clearNodes, setNodeElem } from './nodes.js'; -import { insertCluster, clear as clearClusters } from './clusters.js'; -import { insertEdgeLabel, positionEdgeLabel, insertEdge, clear as clearEdges } from './edges.js'; -import { log } from '../logger.js'; -import { getSubGraphTitleMargins } from '../utils/subGraphTitleMargins.js'; -import { getConfig } from '../diagram-api/diagramAPI.js'; +import { + insertNode, + positionNode, + clear as clearNodes, + setNodeElem, +} from '../../rendering-elements/nodes.js'; +import { insertCluster, clear as clearClusters } from '../../rendering-elements/clusters.js'; +import { + insertEdgeLabel, + positionEdgeLabel, + insertEdge, + clear as clearEdges, +} from '../../rendering-elements/edges.js'; +import { log } from '$root/logger.js'; +import { getSubGraphTitleMargins } from '../../../utils/subGraphTitleMargins.js'; +import { getConfig } from '../../../diagram-api/diagramAPI.js'; const recursiveRender = async (_elem, graph, diagramtype, id, parentCluster, siteConfig) => { log.info('Graph in recursive render: XXX', graphlibJson.write(graph), parentCluster); @@ -161,19 +172,49 @@ const recursiveRender = async (_elem, graph, diagramtype, id, parentCluster, sit return { elem, diff }; }; -export const render = async (elem, graph, markers, diagramtype, id) => { - insertMarkers(elem, markers, diagramtype, id); +export const render = async (data4Layout, svg, element) => { + console.warn('HERERERERERER'); + // Create the input mermaid.graph + const graph = new graphlib.Graph({ + multigraph: true, + compound: true, + }) + .setGraph({ + rankdir: data4Layout.direction, + nodesep: data4Layout.nodeSpacing, + ranksep: data4Layout.rankSpacing, + marginx: 8, + marginy: 8, + }) + .setDefaultEdgeLabel(function () { + return {}; + }); + + // Org + + insertMarkers(element, data4Layout.markers, data4Layout.type, data4Layout.diagramId); clearNodes(); clearEdges(); clearClusters(); clearGraphlib(); + // Add the nodes and edges to the graph + data4Layout.nodes.forEach((node) => { + graph.setNode(node.id, { ...node }); + }); + log.warn('Graph at first:', JSON.stringify(graphlibJson.write(graph))); adjustClustersAndEdges(graph); log.warn('Graph after:', JSON.stringify(graphlibJson.write(graph))); - // log.warn('Graph ever after:', graphlibJson.write(graph.node('A').graph)); const siteConfig = getConfig(); - await recursiveRender(elem, graph, diagramtype, id, undefined, siteConfig); + await recursiveRender( + element, + graph, + data4Layout.type, + data4Layout.diagramId, + undefined, + siteConfig + ); }; // const shapeDefinitions = {}; diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/dagre/mermaid-graphlib.js b/packages/mermaid/src/rendering-util/layout-algorithms/dagre/mermaid-graphlib.js new file mode 100644 index 000000000..ee2df03c8 --- /dev/null +++ b/packages/mermaid/src/rendering-util/layout-algorithms/dagre/mermaid-graphlib.js @@ -0,0 +1,474 @@ +/** Decorates with functions required by mermaids dagre-wrapper. */ +import { log } from '$root/logger.js'; +import * as graphlibJson from 'dagre-d3-es/src/graphlib/json.js'; +import * as graphlib from 'dagre-d3-es/src/graphlib/index.js'; + +export let clusterDb = {}; +let descendants = {}; +let parents = {}; + +export const clear = () => { + descendants = {}; + parents = {}; + clusterDb = {}; +}; + +const isDescendant = (id, ancenstorId) => { + // if (id === ancenstorId) return true; + + log.trace('In isDecendant', ancenstorId, ' ', id, ' = ', descendants[ancenstorId].includes(id)); + if (descendants[ancenstorId].includes(id)) { + return true; + } + + return false; +}; + +const edgeInCluster = (edge, clusterId) => { + log.info('Decendants of ', clusterId, ' is ', descendants[clusterId]); + log.info('Edge is ', edge); + // Edges to/from the cluster is not in the cluster, they are in the parent + if (edge.v === clusterId) { + return false; + } + if (edge.w === clusterId) { + return false; + } + + if (!descendants[clusterId]) { + log.debug('Tilt, ', clusterId, ',not in decendants'); + return false; + } + return ( + descendants[clusterId].includes(edge.v) || + isDescendant(edge.v, clusterId) || + isDescendant(edge.w, clusterId) || + descendants[clusterId].includes(edge.w) + ); +}; + +const copy = (clusterId, graph, newGraph, rootId) => { + log.warn( + 'Copying children of ', + clusterId, + 'root', + rootId, + 'data', + graph.node(clusterId), + rootId + ); + const nodes = graph.children(clusterId) || []; + + // Include cluster node if it is not the root + if (clusterId !== rootId) { + nodes.push(clusterId); + } + + log.warn('Copying (nodes) clusterId', clusterId, 'nodes', nodes); + + nodes.forEach((node) => { + if (graph.children(node).length > 0) { + copy(node, graph, newGraph, rootId); + } else { + const data = graph.node(node); + log.info('cp ', node, ' to ', rootId, ' with parent ', clusterId); //,node, data, ' parent is ', clusterId); + newGraph.setNode(node, data); + if (rootId !== graph.parent(node)) { + log.warn('Setting parent', node, graph.parent(node)); + newGraph.setParent(node, graph.parent(node)); + } + + if (clusterId !== rootId && node !== clusterId) { + log.debug('Setting parent', node, clusterId); + newGraph.setParent(node, clusterId); + } else { + log.info('In copy ', clusterId, 'root', rootId, 'data', graph.node(clusterId), rootId); + log.debug( + 'Not Setting parent for node=', + node, + 'cluster!==rootId', + clusterId !== rootId, + 'node!==clusterId', + node !== clusterId + ); + } + const edges = graph.edges(node); + log.debug('Copying Edges', edges); + edges.forEach((edge) => { + log.info('Edge', edge); + const data = graph.edge(edge.v, edge.w, edge.name); + log.info('Edge data', data, rootId); + try { + // Do not copy edges in and out of the root cluster, they belong to the parent graph + if (edgeInCluster(edge, rootId)) { + log.info('Copying as ', edge.v, edge.w, data, edge.name); + newGraph.setEdge(edge.v, edge.w, data, edge.name); + log.info('newGraph edges ', newGraph.edges(), newGraph.edge(newGraph.edges()[0])); + } else { + log.info( + 'Skipping copy of edge ', + edge.v, + '-->', + edge.w, + ' rootId: ', + rootId, + ' clusterId:', + clusterId + ); + } + } catch (e) { + log.error(e); + } + }); + } + log.debug('Removing node', node); + graph.removeNode(node); + }); +}; +export const extractDescendants = (id, graph) => { + // log.debug('Extracting ', id); + const children = graph.children(id); + let res = [...children]; + + for (const child of children) { + parents[child] = id; + res = [...res, ...extractDescendants(child, graph)]; + } + + return res; +}; + +/** + * Validates the graph, checking that all parent child relation points to existing nodes and that + * edges between nodes also ia correct. When not correct the function logs the discrepancies. + * + * @param graph + */ +export const validate = (graph) => { + const edges = graph.edges(); + log.trace('Edges: ', edges); + for (const edge of edges) { + if (graph.children(edge.v).length > 0) { + log.trace('The node ', edge.v, ' is part of and edge even though it has children'); + return false; + } + if (graph.children(edge.w).length > 0) { + log.trace('The node ', edge.w, ' is part of and edge even though it has children'); + return false; + } + } + return true; +}; + +/** + * Finds a child that is not a cluster. When faking an edge between a node and a cluster. + * + * @param id + * @param {any} graph + */ +export const findNonClusterChild = (id, graph) => { + // const node = graph.node(id); + log.trace('Searching', id); + // const children = graph.children(id).reverse(); + const children = graph.children(id); //.reverse(); + log.trace('Searching children of id ', id, children); + if (children.length < 1) { + log.trace('This is a valid node', id); + return id; + } + for (const child of children) { + const _id = findNonClusterChild(child, graph); + if (_id) { + log.trace('Found replacement for', id, ' => ', _id); + return _id; + } + } +}; + +const getAnchorId = (id) => { + if (!clusterDb[id]) { + return id; + } + // If the cluster has no external connections + if (!clusterDb[id].externalConnections) { + return id; + } + + // Return the replacement node + if (clusterDb[id]) { + return clusterDb[id].id; + } + return id; +}; + +export const adjustClustersAndEdges = (graph, depth) => { + if (!graph || depth > 10) { + log.debug('Opting out, no graph '); + return; + } else { + log.debug('Opting in, graph '); + } + // Go through the nodes and for each cluster found, save a replacement node, this can be used when + // faking a link to a cluster + graph.nodes().forEach(function (id) { + const children = graph.children(id); + if (children.length > 0) { + log.warn( + 'Cluster identified', + id, + ' Replacement id in edges: ', + findNonClusterChild(id, graph) + ); + descendants[id] = extractDescendants(id, graph); + clusterDb[id] = { id: findNonClusterChild(id, graph), clusterData: graph.node(id) }; + } + }); + + // Check incoming and outgoing edges for each cluster + graph.nodes().forEach(function (id) { + const children = graph.children(id); + const edges = graph.edges(); + if (children.length > 0) { + log.debug('Cluster identified', id, descendants); + edges.forEach((edge) => { + // log.debug('Edge, descendants: ', edge, descendants[id]); + + // Check if any edge leaves the cluster (not the actual cluster, that's a link from the box) + if (edge.v !== id && edge.w !== id) { + // Any edge where either the one of the nodes is descending to the cluster but not the other + // if (descendants[id].indexOf(edge.v) < 0 && descendants[id].indexOf(edge.w) < 0) { + + const d1 = isDescendant(edge.v, id); + const d2 = isDescendant(edge.w, id); + + // d1 xor d2 - if either d1 is true and d2 is false or the other way around + if (d1 ^ d2) { + log.warn('Edge: ', edge, ' leaves cluster ', id); + log.warn('Decendants of XXX ', id, ': ', descendants[id]); + clusterDb[id].externalConnections = true; + } + } + }); + } else { + log.debug('Not a cluster ', id, descendants); + } + }); + + for (let id of Object.keys(clusterDb)) { + const nonClusterChild = clusterDb[id].id; + const parent = graph.parent(nonClusterChild); + + // Change replacement node of id to parent of current replacement node if valid + if (parent !== id && clusterDb[parent] && !clusterDb[parent].externalConnections) { + clusterDb[id].id = parent; + } + } + + // For clusters with incoming and/or outgoing edges translate those edges to a real node + // in the cluster in order to fake the edge + graph.edges().forEach(function (e) { + const edge = graph.edge(e); + log.warn('Edge ' + e.v + ' -> ' + e.w + ': ' + JSON.stringify(e)); + log.warn('Edge ' + e.v + ' -> ' + e.w + ': ' + JSON.stringify(graph.edge(e))); + + let v = e.v; + let w = e.w; + // Check if link is either from or to a cluster + log.warn( + 'Fix XXX', + clusterDb, + 'ids:', + e.v, + e.w, + 'Translating: ', + clusterDb[e.v], + ' --- ', + clusterDb[e.w] + ); + if (clusterDb[e.v] && clusterDb[e.w] && clusterDb[e.v] === clusterDb[e.w]) { + log.warn('Fixing and trixing link to self - removing XXX', e.v, e.w, e.name); + log.warn('Fixing and trixing - removing XXX', e.v, e.w, e.name); + v = getAnchorId(e.v); + w = getAnchorId(e.w); + graph.removeEdge(e.v, e.w, e.name); + const specialId = e.w + '---' + e.v; + graph.setNode(specialId, { + domId: specialId, + id: specialId, + labelStyle: '', + labelText: edge.label, + padding: 0, + shape: 'labelRect', + style: '', + }); + const edge1 = structuredClone(edge); + const edge2 = structuredClone(edge); + edge1.label = ''; + edge1.arrowTypeEnd = 'none'; + edge2.label = ''; + edge1.fromCluster = e.v; + edge2.toCluster = e.v; + + graph.setEdge(v, specialId, edge1, e.name + '-cyclic-special'); + graph.setEdge(specialId, w, edge2, e.name + '-cyclic-special'); + } else if (clusterDb[e.v] || clusterDb[e.w]) { + log.warn('Fixing and trixing - removing XXX', e.v, e.w, e.name); + v = getAnchorId(e.v); + w = getAnchorId(e.w); + graph.removeEdge(e.v, e.w, e.name); + if (v !== e.v) { + const parent = graph.parent(v); + clusterDb[parent].externalConnections = true; + edge.fromCluster = e.v; + } + if (w !== e.w) { + const parent = graph.parent(w); + clusterDb[parent].externalConnections = true; + edge.toCluster = e.w; + } + log.warn('Fix Replacing with XXX', v, w, e.name); + graph.setEdge(v, w, edge, e.name); + } + }); + log.warn('Adjusted Graph', graphlibJson.write(graph)); + extractor(graph, 0); + + log.trace(clusterDb); + + // Remove references to extracted cluster + // graph.edges().forEach(edge => { + // if (isDecendant(edge.v, clusterId) || isDecendant(edge.w, clusterId)) { + // graph.removeEdge(edge); + // } + // }); +}; + +export const extractor = (graph, depth) => { + log.warn('extractor - ', depth, graphlibJson.write(graph), graph.children('D')); + if (depth > 10) { + log.error('Bailing out'); + return; + } + // For clusters without incoming and/or outgoing edges, create a new cluster-node + // containing the nodes and edges in the custer in a new graph + // for (let i = 0;) + let nodes = graph.nodes(); + let hasChildren = false; + for (const node of nodes) { + const children = graph.children(node); + hasChildren = hasChildren || children.length > 0; + } + + if (!hasChildren) { + log.debug('Done, no node has children', graph.nodes()); + return; + } + // const clusters = Object.keys(clusterDb); + // clusters.forEach(clusterId => { + log.debug('Nodes = ', nodes, depth); + for (const node of nodes) { + log.debug( + 'Extracting node', + node, + clusterDb, + clusterDb[node] && !clusterDb[node].externalConnections, + !graph.parent(node), + graph.node(node), + graph.children('D'), + ' Depth ', + depth + ); + // Note that the node might have been removed after the Object.keys call so better check + // that it still is in the game + if (!clusterDb[node]) { + // Skip if the node is not a cluster + log.debug('Not a cluster', node, depth); + // break; + } else if ( + !clusterDb[node].externalConnections && + // !graph.parent(node) && + graph.children(node) && + graph.children(node).length > 0 + ) { + log.warn( + 'Cluster without external connections, without a parent and with children', + node, + depth + ); + + const graphSettings = graph.graph(); + let dir = graphSettings.rankdir === 'TB' ? 'LR' : 'TB'; + if (clusterDb[node] && clusterDb[node].clusterData && clusterDb[node].clusterData.dir) { + dir = clusterDb[node].clusterData.dir; + log.warn('Fixing dir', clusterDb[node].clusterData.dir, dir); + } + + const clusterGraph = new graphlib.Graph({ + multigraph: true, + compound: true, + }) + .setGraph({ + rankdir: dir, // Todo: set proper spacing + nodesep: 50, + ranksep: 50, + marginx: 8, + marginy: 8, + }) + .setDefaultEdgeLabel(function () { + return {}; + }); + + log.warn('Old graph before copy', graphlibJson.write(graph)); + copy(node, graph, clusterGraph, node); + graph.setNode(node, { + clusterNode: true, + id: node, + clusterData: clusterDb[node].clusterData, + labelText: clusterDb[node].labelText, + graph: clusterGraph, + }); + log.warn('New graph after copy node: (', node, ')', graphlibJson.write(clusterGraph)); + log.debug('Old graph after copy', graphlibJson.write(graph)); + } else { + log.warn( + 'Cluster ** ', + node, + ' **not meeting the criteria !externalConnections:', + !clusterDb[node].externalConnections, + ' no parent: ', + !graph.parent(node), + ' children ', + graph.children(node) && graph.children(node).length > 0, + graph.children('D'), + depth + ); + log.debug(clusterDb); + } + } + + nodes = graph.nodes(); + log.warn('New list of nodes', nodes); + for (const node of nodes) { + const data = graph.node(node); + log.warn(' Now next level', node, data); + if (data.clusterNode) { + extractor(data.graph, depth + 1); + } + } +}; + +const sorter = (graph, nodes) => { + if (nodes.length === 0) { + return []; + } + let result = Object.assign(nodes); + nodes.forEach((node) => { + const children = graph.children(node); + const sorted = sorter(graph, children); + result = [...result, ...sorted]; + }); + + return result; +}; + +export const sortNodesByHierarchy = (graph) => sorter(graph, graph.children()); diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/dagre/mermaid-graphlib.spec.js b/packages/mermaid/src/rendering-util/layout-algorithms/dagre/mermaid-graphlib.spec.js new file mode 100644 index 000000000..d44e54391 --- /dev/null +++ b/packages/mermaid/src/rendering-util/layout-algorithms/dagre/mermaid-graphlib.spec.js @@ -0,0 +1,508 @@ +import * as graphlibJson from 'dagre-d3-es/src/graphlib/json.js'; +import * as graphlib from 'dagre-d3-es/src/graphlib/index.js'; +import { + validate, + adjustClustersAndEdges, + extractDescendants, + sortNodesByHierarchy, +} from './mermaid-graphlib.js'; +import { setLogLevel, log } from '../logger.js'; + +describe('Graphlib decorations', () => { + let g; + beforeEach(function () { + setLogLevel(1); + g = new graphlib.Graph({ + multigraph: true, + compound: true, + }); + g.setGraph({ + rankdir: 'TB', + nodesep: 10, + ranksep: 10, + marginx: 8, + marginy: 8, + }); + g.setDefaultEdgeLabel(function () { + return {}; + }); + }); + + describe('validate', function () { + it('Validate should detect edges between clusters', function () { + /* + subgraph C1 + a --> b + end + subgraph C2 + c + end + C1 --> C2 + */ + g.setNode('a', { data: 1 }); + g.setNode('b', { data: 2 }); + g.setNode('c', { data: 3 }); + g.setParent('a', 'C1'); + g.setParent('b', 'C1'); + g.setParent('c', 'C2'); + g.setEdge('a', 'b'); + g.setEdge('C1', 'C2'); + + expect(validate(g)).toBe(false); + }); + it('Validate should not detect edges between clusters after adjustment', function () { + /* + subgraph C1 + a --> b + end + subgraph C2 + c + end + C1 --> C2 + */ + g.setNode('a', {}); + g.setNode('b', {}); + g.setNode('c', {}); + g.setParent('a', 'C1'); + g.setParent('b', 'C1'); + g.setParent('c', 'C2'); + g.setEdge('a', 'b'); + g.setEdge('C1', 'C2'); + + adjustClustersAndEdges(g); + log.info(g.edges()); + expect(validate(g)).toBe(true); + }); + + it('Validate should detect edges between clusters and transform clusters GLB4', function () { + /* + a --> b + subgraph C1 + subgraph C2 + a + end + b + end + C1 --> c + */ + g.setNode('a', { data: 1 }); + g.setNode('b', { data: 2 }); + g.setNode('c', { data: 3 }); + g.setNode('C1', { data: 4 }); + g.setNode('C2', { data: 5 }); + g.setParent('a', 'C2'); + g.setParent('b', 'C1'); + g.setParent('C2', 'C1'); + g.setEdge('a', 'b', { name: 'C1-internal-link' }); + g.setEdge('C1', 'c', { name: 'C1-external-link' }); + + adjustClustersAndEdges(g); + log.info(g.nodes()); + expect(g.nodes().length).toBe(2); + expect(validate(g)).toBe(true); + }); + it('Validate should detect edges between clusters and transform clusters GLB5', function () { + /* + a --> b + subgraph C1 + a + end + subgraph C2 + b + end + C1 --> + */ + g.setNode('a', { data: 1 }); + g.setNode('b', { data: 2 }); + g.setParent('a', 'C1'); + g.setParent('b', 'C2'); + // g.setEdge('a', 'b', { name: 'C1-internal-link' }); + g.setEdge('C1', 'C2', { name: 'C1-external-link' }); + + log.info(g.nodes()); + adjustClustersAndEdges(g); + log.info(g.nodes()); + expect(g.nodes().length).toBe(2); + expect(validate(g)).toBe(true); + }); + it('adjustClustersAndEdges GLB6', function () { + /* + subgraph C1 + a + end + C1 --> b + */ + g.setNode('a', { data: 1 }); + g.setNode('b', { data: 2 }); + g.setNode('C1', { data: 3 }); + g.setParent('a', 'C1'); + g.setEdge('C1', 'b', { data: 'link1' }, '1'); + + // log.info(g.edges()) + adjustClustersAndEdges(g); + log.info(g.edges()); + expect(g.nodes()).toEqual(['b', 'C1']); + expect(g.edges().length).toBe(1); + expect(validate(g)).toBe(true); + expect(g.node('C1').clusterNode).toBe(true); + + const C1Graph = g.node('C1').graph; + expect(C1Graph.nodes()).toEqual(['a']); + }); + it('adjustClustersAndEdges GLB7', function () { + /* + subgraph C1 + a + end + C1 --> b + C1 --> c + */ + g.setNode('a', { data: 1 }); + g.setNode('b', { data: 2 }); + g.setNode('c', { data: 3 }); + g.setParent('a', 'C1'); + g.setNode('C1', { data: 4 }); + g.setEdge('C1', 'b', { data: 'link1' }, '1'); + g.setEdge('C1', 'c', { data: 'link2' }, '2'); + + log.info(g.node('C1')); + adjustClustersAndEdges(g); + log.info(g.edges()); + expect(g.nodes()).toEqual(['b', 'c', 'C1']); + expect(g.nodes().length).toBe(3); + expect(g.edges().length).toBe(2); + + expect(g.edges().length).toBe(2); + const edgeData = g.edge(g.edges()[1]); + expect(edgeData.data).toBe('link2'); + expect(validate(g)).toBe(true); + + const C1Graph = g.node('C1').graph; + expect(C1Graph.nodes()).toEqual(['a']); + }); + it('adjustClustersAndEdges GLB8', function () { + /* + subgraph A + a + end + subgraph B + b + end + subgraph C + c + end + A --> B + A --> C + */ + g.setNode('a', { data: 1 }); + g.setNode('b', { data: 2 }); + g.setNode('c', { data: 3 }); + g.setParent('a', 'A'); + g.setParent('b', 'B'); + g.setParent('c', 'C'); + g.setEdge('A', 'B', { data: 'link1' }, '1'); + g.setEdge('A', 'C', { data: 'link2' }, '2'); + + // log.info(g.edges()) + adjustClustersAndEdges(g); + expect(g.nodes()).toEqual(['A', 'B', 'C']); + expect(g.edges().length).toBe(2); + + expect(g.edges().length).toBe(2); + const edgeData = g.edge(g.edges()[1]); + expect(edgeData.data).toBe('link2'); + expect(validate(g)).toBe(true); + + const CGraph = g.node('C').graph; + expect(CGraph.nodes()).toEqual(['c']); + }); + + it('adjustClustersAndEdges the extracted graphs shall contain the correct data GLB10', function () { + /* + subgraph C + subgraph D + d + end + end + */ + + g.setNode('C', { data: 1 }); + g.setNode('D', { data: 2 }); + g.setNode('d', { data: 3 }); + g.setParent('d', 'D'); + g.setParent('D', 'C'); + + // log.info('Graph before', g.node('D')) + // log.info('Graph before', graphlibJson.write(g)) + adjustClustersAndEdges(g); + // log.info('Graph after', graphlibJson.write(g), g.node('C').graph) + + const CGraph = g.node('C').graph; + const DGraph = CGraph.node('D').graph; + + expect(CGraph.nodes()).toEqual(['D']); + expect(DGraph.nodes()).toEqual(['d']); + + expect(g.nodes()).toEqual(['C']); + expect(g.nodes().length).toBe(1); + }); + + it('adjustClustersAndEdges the extracted graphs shall contain the correct data GLB11', function () { + /* + subgraph A + a + end + subgraph B + b + end + subgraph C + subgraph D + d + end + end + A --> B + A --> C + */ + + g.setNode('C', { data: 1 }); + g.setNode('D', { data: 2 }); + g.setNode('d', { data: 3 }); + g.setNode('B', { data: 4 }); + g.setNode('b', { data: 5 }); + g.setNode('A', { data: 6 }); + g.setNode('a', { data: 7 }); + g.setParent('a', 'A'); + g.setParent('b', 'B'); + g.setParent('d', 'D'); + g.setParent('D', 'C'); + g.setEdge('A', 'B', { data: 'link1' }, '1'); + g.setEdge('A', 'C', { data: 'link2' }, '2'); + + log.info('Graph before', g.node('D')); + log.info('Graph before', graphlibJson.write(g)); + adjustClustersAndEdges(g); + log.trace('Graph after', graphlibJson.write(g)); + expect(g.nodes()).toEqual(['C', 'B', 'A']); + expect(g.nodes().length).toBe(3); + expect(g.edges().length).toBe(2); + + const AGraph = g.node('A').graph; + const BGraph = g.node('B').graph; + const CGraph = g.node('C').graph; + // log.info(CGraph.nodes()); + const DGraph = CGraph.node('D').graph; + // log.info('DG', CGraph.children('D')); + + log.info('A', AGraph.nodes()); + expect(AGraph.nodes().length).toBe(1); + expect(AGraph.nodes()).toEqual(['a']); + log.trace('Nodes', BGraph.nodes()); + expect(BGraph.nodes().length).toBe(1); + expect(BGraph.nodes()).toEqual(['b']); + expect(CGraph.nodes()).toEqual(['D']); + expect(CGraph.nodes().length).toEqual(1); + + expect(AGraph.edges().length).toBe(0); + expect(BGraph.edges().length).toBe(0); + expect(CGraph.edges().length).toBe(0); + expect(DGraph.nodes()).toEqual(['d']); + expect(DGraph.edges().length).toBe(0); + // expect(CGraph.node('D')).toEqual({ data: 2 }); + expect(g.edges().length).toBe(2); + + // expect(g.edges().length).toBe(2); + // const edgeData = g.edge(g.edges()[1]); + // expect(edgeData.data).toBe('link2'); + // expect(validate(g)).toBe(true); + }); + it('adjustClustersAndEdges the extracted graphs shall contain the correct links GLB20', function () { + /* + a --> b + subgraph b [Test] + c --> d -->e + end + */ + g.setNode('a', { data: 1 }); + g.setNode('b', { data: 2 }); + g.setNode('c', { data: 3 }); + g.setNode('d', { data: 3 }); + g.setNode('e', { data: 3 }); + g.setParent('c', 'b'); + g.setParent('d', 'b'); + g.setParent('e', 'b'); + g.setEdge('a', 'b', { data: 'link1' }, '1'); + g.setEdge('c', 'd', { data: 'link2' }, '2'); + g.setEdge('d', 'e', { data: 'link2' }, '2'); + + log.info('Graph before', graphlibJson.write(g)); + adjustClustersAndEdges(g); + const bGraph = g.node('b').graph; + // log.trace('Graph after', graphlibJson.write(g)) + log.info('Graph after', graphlibJson.write(bGraph)); + expect(bGraph.nodes().length).toBe(3); + expect(bGraph.edges().length).toBe(2); + }); + it('adjustClustersAndEdges the extracted graphs shall contain the correct links GLB21', function () { + /* + state a { + state b { + state c { + e + } + } + } + */ + g.setNode('a', { data: 1 }); + g.setNode('b', { data: 2 }); + g.setNode('c', { data: 3 }); + g.setNode('e', { data: 3 }); + g.setParent('b', 'a'); + g.setParent('c', 'b'); + g.setParent('e', 'c'); + + log.info('Graph before', graphlibJson.write(g)); + adjustClustersAndEdges(g); + const aGraph = g.node('a').graph; + const bGraph = aGraph.node('b').graph; + log.info('Graph after', graphlibJson.write(aGraph)); + const cGraph = bGraph.node('c').graph; + // log.trace('Graph after', graphlibJson.write(g)) + expect(aGraph.nodes().length).toBe(1); + expect(bGraph.nodes().length).toBe(1); + expect(cGraph.nodes().length).toBe(1); + expect(bGraph.edges().length).toBe(0); + }); + }); + it('adjustClustersAndEdges should handle nesting GLB77', function () { + /* +flowchart TB + subgraph A + b-->B + a-->c + end + subgraph B + c + end + */ + + const exportedGraph = JSON.parse( + '{"options":{"directed":true,"multigraph":true,"compound":true},"nodes":[{"v":"A","value":{"labelStyle":"","shape":"rect","labelText":"A","rx":0,"ry":0,"class":"default","style":"","id":"A","width":500,"type":"group","padding":15}},{"v":"B","value":{"labelStyle":"","shape":"rect","labelText":"B","rx":0,"ry":0,"class":"default","style":"","id":"B","width":500,"type":"group","padding":15},"parent":"A"},{"v":"b","value":{"labelStyle":"","shape":"rect","labelText":"b","rx":0,"ry":0,"class":"default","style":"","id":"b","padding":15},"parent":"A"},{"v":"c","value":{"labelStyle":"","shape":"rect","labelText":"c","rx":0,"ry":0,"class":"default","style":"","id":"c","padding":15},"parent":"B"},{"v":"a","value":{"labelStyle":"","shape":"rect","labelText":"a","rx":0,"ry":0,"class":"default","style":"","id":"a","padding":15},"parent":"A"}],"edges":[{"v":"b","w":"B","name":"1","value":{"minlen":1,"arrowhead":"normal","arrowTypeStart":"arrow_open","arrowTypeEnd":"arrow_point","thickness":"normal","pattern":"solid","style":"fill:none","labelStyle":"","arrowheadStyle":"fill: #333","labelpos":"c","labelType":"text","label":"","id":"L-b-B","classes":"flowchart-link LS-b LE-B"}},{"v":"a","w":"c","name":"2","value":{"minlen":1,"arrowhead":"normal","arrowTypeStart":"arrow_open","arrowTypeEnd":"arrow_point","thickness":"normal","pattern":"solid","style":"fill:none","labelStyle":"","arrowheadStyle":"fill: #333","labelpos":"c","labelType":"text","label":"","id":"L-a-c","classes":"flowchart-link LS-a LE-c"}}],"value":{"rankdir":"TB","nodesep":50,"ranksep":50,"marginx":8,"marginy":8}}' + ); + const gr = graphlibJson.read(exportedGraph); + + log.info('Graph before', graphlibJson.write(gr)); + adjustClustersAndEdges(gr); + const aGraph = gr.node('A').graph; + const bGraph = aGraph.node('B').graph; + log.info('Graph after', graphlibJson.write(aGraph)); + // log.trace('Graph after', graphlibJson.write(g)) + expect(aGraph.parent('c')).toBe('B'); + expect(aGraph.parent('B')).toBe(undefined); + }); +}); +describe('extractDescendants', function () { + let g; + beforeEach(function () { + setLogLevel(1); + g = new graphlib.Graph({ + multigraph: true, + compound: true, + }); + g.setGraph({ + rankdir: 'TB', + nodesep: 10, + ranksep: 10, + marginx: 8, + marginy: 8, + }); + g.setDefaultEdgeLabel(function () { + return {}; + }); + }); + it('Simple case of one level descendants GLB9', function () { + /* + subgraph A + a + end + subgraph B + b + end + subgraph C + c + end + A --> B + A --> C + */ + g.setNode('a', { data: 1 }); + g.setNode('b', { data: 2 }); + g.setNode('c', { data: 3 }); + g.setParent('a', 'A'); + g.setParent('b', 'B'); + g.setParent('c', 'C'); + g.setEdge('A', 'B', { data: 'link1' }, '1'); + g.setEdge('A', 'C', { data: 'link2' }, '2'); + + // log.info(g.edges()) + const d1 = extractDescendants('A', g); + const d2 = extractDescendants('B', g); + const d3 = extractDescendants('C', g); + + expect(d1).toEqual(['a']); + expect(d2).toEqual(['b']); + expect(d3).toEqual(['c']); + }); +}); +describe('sortNodesByHierarchy', function () { + let g; + beforeEach(function () { + setLogLevel(1); + g = new graphlib.Graph({ + multigraph: true, + compound: true, + }); + g.setGraph({ + rankdir: 'TB', + nodesep: 10, + ranksep: 10, + marginx: 8, + marginy: 8, + }); + g.setDefaultEdgeLabel(function () { + return {}; + }); + }); + it('should sort proper en nodes are in reverse order', function () { + /* + a -->b + subgraph B + b + end + subgraph A + B + end + */ + g.setNode('a', { data: 1 }); + g.setNode('b', { data: 2 }); + g.setParent('b', 'B'); + g.setParent('B', 'A'); + g.setEdge('a', 'b', '1'); + expect(sortNodesByHierarchy(g)).toEqual(['a', 'A', 'B', 'b']); + }); + it('should sort proper en nodes are in correct order', function () { + /* + a -->b + subgraph B + b + end + subgraph A + B + end + */ + g.setNode('a', { data: 1 }); + g.setParent('B', 'A'); + g.setParent('b', 'B'); + g.setNode('b', { data: 2 }); + g.setEdge('a', 'b', '1'); + expect(sortNodesByHierarchy(g)).toEqual(['a', 'A', 'B', 'b']); + }); +}); diff --git a/packages/mermaid/src/rendering-util/render.js b/packages/mermaid/src/rendering-util/render.js index a235c31ff..f6fa82f51 100644 --- a/packages/mermaid/src/rendering-util/render.js +++ b/packages/mermaid/src/rendering-util/render.js @@ -1,6 +1,9 @@ export const render = async (data4Layout, svg, element) => { if (data4Layout.layoutAlgorithm === 'dagre-wrapper') { - const layoutRenderer = await import('../dagre-wrapper/index-refactored.js'); + console.warn('THERERERERERER'); + // const layoutRenderer = await import('../dagre-wrapper/index-refactored.js'); + + const layoutRenderer = await import('./layout-algorithms/dagre/index.js'); return layoutRenderer.render(data4Layout, svg, element); } diff --git a/packages/mermaid/src/rendering-util/rendering-elements/clusters.js b/packages/mermaid/src/rendering-util/rendering-elements/clusters.js new file mode 100644 index 000000000..0b1ecd572 --- /dev/null +++ b/packages/mermaid/src/rendering-util/rendering-elements/clusters.js @@ -0,0 +1,261 @@ +import intersectRect from '../rendering-elements/intersect/intersect-rect.js'; +import { log } from '$root/logger.js'; +import createLabel from './createLabel.js'; +import { createText } from '../createText.ts'; +import { select } from 'd3'; +import { getConfig } from '$root/diagram-api/diagramAPI.js'; +import { evaluate } from '$root/diagrams/common/common.js'; +import { getSubGraphTitleMargins } from '$root/utils/subGraphTitleMargins.js'; + +const rect = (parent, node) => { + log.info('Creating subgraph rect for ', node.id, node); + const siteConfig = getConfig(); + + // Add outer g element + const shapeSvg = parent + .insert('g') + .attr('class', 'cluster' + (node.class ? ' ' + node.class : '')) + .attr('id', node.id); + + // add the rect + const rect = shapeSvg.insert('rect', ':first-child'); + + const useHtmlLabels = evaluate(siteConfig.flowchart.htmlLabels); + + // Create the label and insert it after the rect + const label = shapeSvg.insert('g').attr('class', 'cluster-label'); + + // const text = label + // .node() + // .appendChild(createLabel(node.labelText, node.labelStyle, undefined, true)); + const text = + node.labelType === 'markdown' + ? createText(label, node.labelText, { style: node.labelStyle, useHtmlLabels }) + : label.node().appendChild(createLabel(node.labelText, node.labelStyle, undefined, true)); + + // Get the size of the label + let bbox = text.getBBox(); + + if (evaluate(siteConfig.flowchart.htmlLabels)) { + const div = text.children[0]; + const dv = select(text); + bbox = div.getBoundingClientRect(); + dv.attr('width', bbox.width); + dv.attr('height', bbox.height); + } + + const padding = 0 * node.padding; + const halfPadding = padding / 2; + + const width = node.width <= bbox.width + padding ? bbox.width + padding : node.width; + if (node.width <= bbox.width + padding) { + node.diff = (bbox.width - node.width) / 2 - node.padding / 2; + } else { + node.diff = -node.padding / 2; + } + + log.trace('Data ', node, JSON.stringify(node)); + // center the rect around its coordinate + rect + .attr('style', node.style) + .attr('rx', node.rx) + .attr('ry', node.ry) + .attr('x', node.x - width / 2) + .attr('y', node.y - node.height / 2 - halfPadding) + .attr('width', width) + .attr('height', node.height + padding); + + const { subGraphTitleTopMargin } = getSubGraphTitleMargins(siteConfig); + if (useHtmlLabels) { + label.attr( + 'transform', + // This puts the labal on top of the box instead of inside it + `translate(${node.x - bbox.width / 2}, ${node.y - node.height / 2 + subGraphTitleTopMargin})` + ); + } else { + label.attr( + 'transform', + // This puts the labal on top of the box instead of inside it + `translate(${node.x}, ${node.y - node.height / 2 + subGraphTitleTopMargin})` + ); + } + // Center the label + + const rectBox = rect.node().getBBox(); + node.width = rectBox.width; + node.height = rectBox.height; + + node.intersect = function (point) { + return intersectRect(node, point); + }; + + return shapeSvg; +}; + +/** + * Non visible cluster where the note is group with its + * + * @param {any} parent + * @param {any} node + * @returns {any} ShapeSvg + */ +const noteGroup = (parent, node) => { + // Add outer g element + const shapeSvg = parent.insert('g').attr('class', 'note-cluster').attr('id', node.id); + + // add the rect + const rect = shapeSvg.insert('rect', ':first-child'); + + const padding = 0 * node.padding; + const halfPadding = padding / 2; + + // center the rect around its coordinate + rect + .attr('rx', node.rx) + .attr('ry', node.ry) + .attr('x', node.x - node.width / 2 - halfPadding) + .attr('y', node.y - node.height / 2 - halfPadding) + .attr('width', node.width + padding) + .attr('height', node.height + padding) + .attr('fill', 'none'); + + const rectBox = rect.node().getBBox(); + node.width = rectBox.width; + node.height = rectBox.height; + + node.intersect = function (point) { + return intersectRect(node, point); + }; + + return shapeSvg; +}; +const roundedWithTitle = (parent, node) => { + const siteConfig = getConfig(); + + // Add outer g element + const shapeSvg = parent.insert('g').attr('class', node.classes).attr('id', node.id); + + // add the rect + const rect = shapeSvg.insert('rect', ':first-child'); + + // Create the label and insert it after the rect + const label = shapeSvg.insert('g').attr('class', 'cluster-label'); + const innerRect = shapeSvg.append('rect'); + + const text = label + .node() + .appendChild(createLabel(node.labelText, node.labelStyle, undefined, true)); + + // Get the size of the label + let bbox = text.getBBox(); + if (evaluate(siteConfig.flowchart.htmlLabels)) { + const div = text.children[0]; + const dv = select(text); + bbox = div.getBoundingClientRect(); + dv.attr('width', bbox.width); + dv.attr('height', bbox.height); + } + bbox = text.getBBox(); + const padding = 0 * node.padding; + const halfPadding = padding / 2; + + const width = node.width <= bbox.width + node.padding ? bbox.width + node.padding : node.width; + if (node.width <= bbox.width + node.padding) { + node.diff = (bbox.width + node.padding * 0 - node.width) / 2; + } else { + node.diff = -node.padding / 2; + } + + // center the rect around its coordinate + rect + .attr('class', 'outer') + .attr('x', node.x - width / 2 - halfPadding) + .attr('y', node.y - node.height / 2 - halfPadding) + .attr('width', width + padding) + .attr('height', node.height + padding); + innerRect + .attr('class', 'inner') + .attr('x', node.x - width / 2 - halfPadding) + .attr('y', node.y - node.height / 2 - halfPadding + bbox.height - 1) + .attr('width', width + padding) + .attr('height', node.height + padding - bbox.height - 3); + + const { subGraphTitleTopMargin } = getSubGraphTitleMargins(siteConfig); + // Center the label + label.attr( + 'transform', + `translate(${node.x - bbox.width / 2}, ${ + node.y - + node.height / 2 - + node.padding / 3 + + (evaluate(siteConfig.flowchart.htmlLabels) ? 5 : 3) + + subGraphTitleTopMargin + })` + ); + + const rectBox = rect.node().getBBox(); + node.height = rectBox.height; + + node.intersect = function (point) { + return intersectRect(node, point); + }; + + return shapeSvg; +}; + +const divider = (parent, node) => { + // Add outer g element + const shapeSvg = parent.insert('g').attr('class', node.classes).attr('id', node.id); + + // add the rect + const rect = shapeSvg.insert('rect', ':first-child'); + + const padding = 0 * node.padding; + const halfPadding = padding / 2; + + // center the rect around its coordinate + rect + .attr('class', 'divider') + .attr('x', node.x - node.width / 2 - halfPadding) + .attr('y', node.y - node.height / 2) + .attr('width', node.width + padding) + .attr('height', node.height + padding); + + const rectBox = rect.node().getBBox(); + node.width = rectBox.width; + node.height = rectBox.height; + node.diff = -node.padding / 2; + node.intersect = function (point) { + return intersectRect(node, point); + }; + + return shapeSvg; +}; + +const shapes = { rect, roundedWithTitle, noteGroup, divider }; + +let clusterElems = {}; + +export const insertCluster = (elem, node) => { + log.trace('Inserting cluster'); + const shape = node.shape || 'rect'; + clusterElems[node.id] = shapes[shape](elem, node); +}; +export const getClusterTitleWidth = (elem, node) => { + const label = createLabel(node.labelText, node.labelStyle, undefined, true); + elem.node().appendChild(label); + const width = label.getBBox().width; + elem.node().removeChild(label); + return width; +}; + +export const clear = () => { + clusterElems = {}; +}; + +export const positionCluster = (node) => { + log.info('Position cluster (' + node.id + ', ' + node.x + ', ' + node.y + ')'); + const el = clusterElems[node.id]; + + el.attr('transform', 'translate(' + node.x + ', ' + node.y + ')'); +}; diff --git a/packages/mermaid/src/rendering-util/rendering-elements/createLabel.js b/packages/mermaid/src/rendering-util/rendering-elements/createLabel.js new file mode 100644 index 000000000..d62c1fc8c --- /dev/null +++ b/packages/mermaid/src/rendering-util/rendering-elements/createLabel.js @@ -0,0 +1,100 @@ +import { select } from 'd3'; +import { log } from '$root/logger.js'; +import { getConfig } from '$root/diagram-api/diagramAPI.js'; +import { evaluate } from '$root/diagrams/common/common.js'; +import { decodeEntities } from '$root/utils.js'; + +/** + * @param dom + * @param styleFn + */ +function applyStyle(dom, styleFn) { + if (styleFn) { + dom.attr('style', styleFn); + } +} + +/** + * @param {any} node + * @returns {SVGForeignObjectElement} Node + */ +function addHtmlLabel(node) { + const fo = select(document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject')); + const div = fo.append('xhtml:div'); + + const label = node.label; + const labelClass = node.isNode ? 'nodeLabel' : 'edgeLabel'; + div.html( + '' + + label + + '' + ); + + applyStyle(div, node.labelStyle); + div.style('display', 'inline-block'); + // Fix for firefox + div.style('white-space', 'nowrap'); + div.attr('xmlns', 'http://www.w3.org/1999/xhtml'); + return fo.node(); +} +/** + * @param _vertexText + * @param style + * @param isTitle + * @param isNode + * @deprecated svg-util/createText instead + */ +const createLabel = (_vertexText, style, isTitle, isNode) => { + let vertexText = _vertexText || ''; + if (typeof vertexText === 'object') { + vertexText = vertexText[0]; + } + if (evaluate(getConfig().flowchart.htmlLabels)) { + // TODO: addHtmlLabel accepts a labelStyle. Do we possibly have that? + vertexText = vertexText.replace(/\\n|\n/g, '
'); + log.info('vertexText' + vertexText); + const node = { + isNode, + label: decodeEntities(vertexText).replace( + /fa[blrs]?:fa-[\w-]+/g, + (s) => `` + ), + labelStyle: style.replace('fill:', 'color:'), + }; + let vertexNode = addHtmlLabel(node); + // vertexNode.parentNode.removeChild(vertexNode); + return vertexNode; + } else { + const svgLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + svgLabel.setAttribute('style', style.replace('color:', 'fill:')); + let rows = []; + if (typeof vertexText === 'string') { + rows = vertexText.split(/\\n|\n|/gi); + } else if (Array.isArray(vertexText)) { + rows = vertexText; + } else { + rows = []; + } + + for (const row of rows) { + const tspan = document.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', '0'); + if (isTitle) { + tspan.setAttribute('class', 'title-row'); + } else { + tspan.setAttribute('class', 'row'); + } + tspan.textContent = row.trim(); + svgLabel.appendChild(tspan); + } + return svgLabel; + } +}; + +export default createLabel; diff --git a/packages/mermaid/src/rendering-util/rendering-elements/edgeMarker.spec.ts b/packages/mermaid/src/rendering-util/rendering-elements/edgeMarker.spec.ts new file mode 100644 index 000000000..6cfb59fab --- /dev/null +++ b/packages/mermaid/src/rendering-util/rendering-elements/edgeMarker.spec.ts @@ -0,0 +1,79 @@ +import type { Mocked } from 'vitest'; +import type { SVG } from '../diagram-api/types.js'; +import { addEdgeMarkers } from './edgeMarker.js'; + +describe('addEdgeMarker', () => { + const svgPath = { + attr: vitest.fn(), + } as unknown as Mocked; + const url = 'http://example.com'; + const id = 'test'; + const diagramType = 'test'; + + beforeEach(() => { + svgPath.attr.mockReset(); + }); + + it('should add markers for arrow_cross:arrow_point', () => { + const arrowTypeStart = 'arrow_cross'; + const arrowTypeEnd = 'arrow_point'; + addEdgeMarkers(svgPath, { arrowTypeStart, arrowTypeEnd }, url, id, diagramType); + expect(svgPath.attr).toHaveBeenCalledWith( + 'marker-start', + `url(${url}#${id}_${diagramType}-crossStart)` + ); + expect(svgPath.attr).toHaveBeenCalledWith( + 'marker-end', + `url(${url}#${id}_${diagramType}-pointEnd)` + ); + }); + + it('should add markers for aggregation:arrow_point', () => { + const arrowTypeStart = 'aggregation'; + const arrowTypeEnd = 'arrow_point'; + addEdgeMarkers(svgPath, { arrowTypeStart, arrowTypeEnd }, url, id, diagramType); + expect(svgPath.attr).toHaveBeenCalledWith( + 'marker-start', + `url(${url}#${id}_${diagramType}-aggregationStart)` + ); + expect(svgPath.attr).toHaveBeenCalledWith( + 'marker-end', + `url(${url}#${id}_${diagramType}-pointEnd)` + ); + }); + + it('should add markers for arrow_point:aggregation', () => { + const arrowTypeStart = 'arrow_point'; + const arrowTypeEnd = 'aggregation'; + addEdgeMarkers(svgPath, { arrowTypeStart, arrowTypeEnd }, url, id, diagramType); + expect(svgPath.attr).toHaveBeenCalledWith( + 'marker-start', + `url(${url}#${id}_${diagramType}-pointStart)` + ); + expect(svgPath.attr).toHaveBeenCalledWith( + 'marker-end', + `url(${url}#${id}_${diagramType}-aggregationEnd)` + ); + }); + + it('should add markers for aggregation:composition', () => { + const arrowTypeStart = 'aggregation'; + const arrowTypeEnd = 'composition'; + addEdgeMarkers(svgPath, { arrowTypeStart, arrowTypeEnd }, url, id, diagramType); + expect(svgPath.attr).toHaveBeenCalledWith( + 'marker-start', + `url(${url}#${id}_${diagramType}-aggregationStart)` + ); + expect(svgPath.attr).toHaveBeenCalledWith( + 'marker-end', + `url(${url}#${id}_${diagramType}-compositionEnd)` + ); + }); + + it('should not add invalid markers', () => { + const arrowTypeStart = 'this is an invalid marker'; + const arrowTypeEnd = ') url(https://my-malicious-site.example)'; + addEdgeMarkers(svgPath, { arrowTypeStart, arrowTypeEnd }, url, id, diagramType); + expect(svgPath.attr).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/mermaid/src/rendering-util/rendering-elements/edgeMarker.ts b/packages/mermaid/src/rendering-util/rendering-elements/edgeMarker.ts new file mode 100644 index 000000000..ea748d8aa --- /dev/null +++ b/packages/mermaid/src/rendering-util/rendering-elements/edgeMarker.ts @@ -0,0 +1,57 @@ +import type { SVG } from '$root/diagram-api/types.js'; +import { log } from '$root/logger.js'; +import type { EdgeData } from '$root/types.js'; +/** + * Adds SVG markers to a path element based on the arrow types specified in the edge. + * + * @param svgPath - The SVG path element to add markers to. + * @param edge - The edge data object containing the arrow types. + * @param url - The URL of the SVG marker definitions. + * @param id - The ID prefix for the SVG marker definitions. + * @param diagramType - The type of diagram being rendered. + */ +export const addEdgeMarkers = ( + svgPath: SVG, + edge: Pick, + url: string, + id: string, + diagramType: string +) => { + if (edge.arrowTypeStart) { + addEdgeMarker(svgPath, 'start', edge.arrowTypeStart, url, id, diagramType); + } + if (edge.arrowTypeEnd) { + addEdgeMarker(svgPath, 'end', edge.arrowTypeEnd, url, id, diagramType); + } +}; + +const arrowTypesMap = { + arrow_cross: 'cross', + arrow_point: 'point', + arrow_barb: 'barb', + arrow_circle: 'circle', + aggregation: 'aggregation', + extension: 'extension', + composition: 'composition', + dependency: 'dependency', + lollipop: 'lollipop', +} as const; + +const addEdgeMarker = ( + svgPath: SVG, + position: 'start' | 'end', + arrowType: string, + url: string, + id: string, + diagramType: string +) => { + const endMarkerType = arrowTypesMap[arrowType as keyof typeof arrowTypesMap]; + + if (!endMarkerType) { + log.warn(`Unknown arrow type: ${arrowType}`); + return; // unknown arrow type, ignore + } + + const suffix = position === 'start' ? 'Start' : 'End'; + svgPath.attr(`marker-${position}`, `url(${url}#${id}_${diagramType}-${endMarkerType}${suffix})`); +}; diff --git a/packages/mermaid/src/rendering-util/rendering-elements/edges.js b/packages/mermaid/src/rendering-util/rendering-elements/edges.js new file mode 100644 index 000000000..a5f56266e --- /dev/null +++ b/packages/mermaid/src/rendering-util/rendering-elements/edges.js @@ -0,0 +1,521 @@ +import { log } from '$root/logger.js'; +import createLabel from './createLabel.js'; +import { createText } from '$root/rendering-util/createText.ts'; +import { line, curveBasis, select } from 'd3'; +import { getConfig } from '$root/diagram-api/diagramAPI.js'; +import utils from '$root/utils.js'; +import { evaluate } from '$root/diagrams/common/common.js'; +import { getLineFunctionsWithOffset } from '$root/utils/lineWithOffset.js'; +import { getSubGraphTitleMargins } from '$root/utils/subGraphTitleMargins.js'; +import { addEdgeMarkers } from './edgeMarker.ts'; + +let edgeLabels = {}; +let terminalLabels = {}; + +export const clear = () => { + edgeLabels = {}; + terminalLabels = {}; +}; + +export const insertEdgeLabel = (elem, edge) => { + const useHtmlLabels = evaluate(getConfig().flowchart.htmlLabels); + // Create the actual text element + const labelElement = + edge.labelType === 'markdown' + ? createText(elem, edge.label, { + style: edge.labelStyle, + useHtmlLabels, + addSvgBackground: true, + }) + : createLabel(edge.label, edge.labelStyle); + log.info('abc82', edge, edge.labelType); + + // Create outer g, edgeLabel, this will be positioned after graph layout + 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'); + label.node().appendChild(labelElement); + + // Center the label + let bbox = labelElement.getBBox(); + if (useHtmlLabels) { + const div = labelElement.children[0]; + const dv = select(labelElement); + bbox = div.getBoundingClientRect(); + dv.attr('width', bbox.width); + dv.attr('height', bbox.height); + } + label.attr('transform', 'translate(' + -bbox.width / 2 + ', ' + -bbox.height / 2 + ')'); + + // Make element accessible by id for positioning + edgeLabels[edge.id] = edgeLabel; + + // Update the abstract data of the edge with the new information about its width and height + edge.width = bbox.width; + edge.height = bbox.height; + + let fo; + if (edge.startLabelLeft) { + // Create the actual text element + const startLabelElement = createLabel(edge.startLabelLeft, edge.labelStyle); + const startEdgeLabelLeft = elem.insert('g').attr('class', 'edgeTerminals'); + const inner = startEdgeLabelLeft.insert('g').attr('class', 'inner'); + fo = inner.node().appendChild(startLabelElement); + const slBox = startLabelElement.getBBox(); + inner.attr('transform', 'translate(' + -slBox.width / 2 + ', ' + -slBox.height / 2 + ')'); + if (!terminalLabels[edge.id]) { + terminalLabels[edge.id] = {}; + } + terminalLabels[edge.id].startLeft = startEdgeLabelLeft; + setTerminalWidth(fo, edge.startLabelLeft); + } + if (edge.startLabelRight) { + // Create the actual text element + const startLabelElement = createLabel(edge.startLabelRight, edge.labelStyle); + const startEdgeLabelRight = elem.insert('g').attr('class', 'edgeTerminals'); + const inner = startEdgeLabelRight.insert('g').attr('class', 'inner'); + fo = startEdgeLabelRight.node().appendChild(startLabelElement); + inner.node().appendChild(startLabelElement); + const slBox = startLabelElement.getBBox(); + inner.attr('transform', 'translate(' + -slBox.width / 2 + ', ' + -slBox.height / 2 + ')'); + + if (!terminalLabels[edge.id]) { + terminalLabels[edge.id] = {}; + } + terminalLabels[edge.id].startRight = startEdgeLabelRight; + setTerminalWidth(fo, edge.startLabelRight); + } + if (edge.endLabelLeft) { + // Create the actual text element + const endLabelElement = createLabel(edge.endLabelLeft, edge.labelStyle); + const endEdgeLabelLeft = elem.insert('g').attr('class', 'edgeTerminals'); + const inner = endEdgeLabelLeft.insert('g').attr('class', 'inner'); + fo = inner.node().appendChild(endLabelElement); + const slBox = endLabelElement.getBBox(); + inner.attr('transform', 'translate(' + -slBox.width / 2 + ', ' + -slBox.height / 2 + ')'); + + endEdgeLabelLeft.node().appendChild(endLabelElement); + + if (!terminalLabels[edge.id]) { + terminalLabels[edge.id] = {}; + } + terminalLabels[edge.id].endLeft = endEdgeLabelLeft; + setTerminalWidth(fo, edge.endLabelLeft); + } + if (edge.endLabelRight) { + // Create the actual text element + const endLabelElement = createLabel(edge.endLabelRight, edge.labelStyle); + const endEdgeLabelRight = elem.insert('g').attr('class', 'edgeTerminals'); + const inner = endEdgeLabelRight.insert('g').attr('class', 'inner'); + + fo = inner.node().appendChild(endLabelElement); + const slBox = endLabelElement.getBBox(); + inner.attr('transform', 'translate(' + -slBox.width / 2 + ', ' + -slBox.height / 2 + ')'); + + endEdgeLabelRight.node().appendChild(endLabelElement); + if (!terminalLabels[edge.id]) { + terminalLabels[edge.id] = {}; + } + terminalLabels[edge.id].endRight = endEdgeLabelRight; + setTerminalWidth(fo, edge.endLabelRight); + } + return labelElement; +}; + +/** + * @param {any} fo + * @param {any} value + */ +function setTerminalWidth(fo, value) { + if (getConfig().flowchart.htmlLabels && fo) { + fo.style.width = value.length * 9 + 'px'; + fo.style.height = '12px'; + } +} + +export const positionEdgeLabel = (edge, paths) => { + log.info('Moving label abc78 ', edge.id, edge.label, edgeLabels[edge.id]); + let path = paths.updatedPath ? paths.updatedPath : paths.originalPath; + const siteConfig = getConfig(); + const { subGraphTitleTotalMargin } = getSubGraphTitleMargins(siteConfig); + if (edge.label) { + const el = edgeLabels[edge.id]; + let x = edge.x; + let y = edge.y; + if (path) { + // // debugger; + const pos = utils.calcLabelPosition(path); + log.info( + 'Moving label ' + edge.label + ' from (', + x, + ',', + y, + ') to (', + pos.x, + ',', + pos.y, + ') abc78' + ); + if (paths.updatedPath) { + x = pos.x; + y = pos.y; + } + } + el.attr('transform', `translate(${x}, ${y + subGraphTitleTotalMargin / 2})`); + } + + //let path = paths.updatedPath ? paths.updatedPath : paths.originalPath; + if (edge.startLabelLeft) { + const el = terminalLabels[edge.id].startLeft; + let x = edge.x; + let y = edge.y; + if (path) { + // debugger; + const pos = utils.calcTerminalLabelPosition(edge.arrowTypeStart ? 10 : 0, 'start_left', path); + x = pos.x; + y = pos.y; + } + el.attr('transform', `translate(${x}, ${y})`); + } + if (edge.startLabelRight) { + const el = terminalLabels[edge.id].startRight; + let x = edge.x; + let y = edge.y; + if (path) { + // debugger; + const pos = utils.calcTerminalLabelPosition( + edge.arrowTypeStart ? 10 : 0, + 'start_right', + path + ); + x = pos.x; + y = pos.y; + } + el.attr('transform', `translate(${x}, ${y})`); + } + if (edge.endLabelLeft) { + const el = terminalLabels[edge.id].endLeft; + let x = edge.x; + let y = edge.y; + if (path) { + // debugger; + const pos = utils.calcTerminalLabelPosition(edge.arrowTypeEnd ? 10 : 0, 'end_left', path); + x = pos.x; + y = pos.y; + } + el.attr('transform', `translate(${x}, ${y})`); + } + if (edge.endLabelRight) { + const el = terminalLabels[edge.id].endRight; + let x = edge.x; + let y = edge.y; + if (path) { + // debugger; + const pos = utils.calcTerminalLabelPosition(edge.arrowTypeEnd ? 10 : 0, 'end_right', path); + x = pos.x; + y = pos.y; + } + el.attr('transform', `translate(${x}, ${y})`); + } +}; + +const outsideNode = (node, point) => { + // log.warn('Checking bounds ', node, point); + 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; +}; + +export const intersection = (node, outsidePoint, insidePoint) => { + log.warn(`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 edges = { + // x1: x - w, + // x2: x + w, + // y1: y - h, + // y2: y + h + // }; + + // if ( + // outsidePoint.x === edges.x1 || + // outsidePoint.x === edges.x2 || + // outsidePoint.y === edges.y1 || + // outsidePoint.y === edges.y2 + // ) { + // log.warn('abc89 calc equals on edge', outsidePoint, edges); + // return outsidePoint; + // } + + const Q = Math.abs(outsidePoint.y - insidePoint.y); + const R = Math.abs(outsidePoint.x - insidePoint.x); + // log.warn(); + if (Math.abs(y - outsidePoint.y) * w > Math.abs(x - outsidePoint.x) * h) { + // Intersection is top or bottom of rect. + // let q = insidePoint.y < outsidePoint.y ? outsidePoint.y - h - y : y - h - outsidePoint.y; + let 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.warn(`abc89 topp/bott calc, Q ${Q}, q ${q}, R ${R}, r ${r}`, res); + + 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; + } + let 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.warn(`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 }; + } +}; +/** + * This function will page a path and node where the last point(s) in the path is inside the node + * and return an update path ending by the border of the node. + * + * @param {Array} _points + * @param {any} boundryNode + * @returns {Array} Points + */ +const cutPathAtIntersect = (_points, boundryNode) => { + log.warn('abc88 cutPathAtIntersect', _points, boundryNode); + let points = []; + let lastPointOutside = _points[0]; + let isInside = false; + _points.forEach((point) => { + // const node = clusterDb[edge.toCluster].node; + log.info('abc88 checking point', point, boundryNode); + + // check if point is inside the boundary rect + if (!outsideNode(boundryNode, point) && !isInside) { + // First point inside the rect found + // Calc the intersection coord between the point anf the last point outside the rect + const inter = intersection(boundryNode, lastPointOutside, point); + log.warn('abc88 inside', point, lastPointOutside, inter); + log.warn('abc88 intersection', inter); + + // // Check case where the intersection is the same as the last point + let pointPresent = false; + points.forEach((p) => { + pointPresent = pointPresent || (p.x === inter.x && p.y === inter.y); + }); + // // if (!pointPresent) { + if (!points.some((e) => e.x === inter.x && e.y === inter.y)) { + points.push(inter); + } else { + log.warn('abc88 no intersect', inter, points); + } + // points.push(inter); + isInside = true; + } else { + // Outside + log.warn('abc88 outside', point, lastPointOutside); + lastPointOutside = point; + // points.push(point); + if (!isInside) { + points.push(point); + } + } + }); + log.warn('abc88 returning points', points); + return points; +}; + +export const insertEdge = function (elem, e, edge, clusterDb, diagramType, graph, id) { + let points = edge.points; + let pointsHasChanged = false; + const tail = graph.node(e.v); + var head = graph.node(e.w); + + log.info('abc88 InsertEdge: ', edge); + if (head.intersect && tail.intersect) { + points = points.slice(1, edge.points.length - 1); + points.unshift(tail.intersect(points[0])); + log.info( + 'Last point', + points[points.length - 1], + head, + head.intersect(points[points.length - 1]) + ); + points.push(head.intersect(points[points.length - 1])); + } + if (edge.toCluster) { + log.info('to cluster abc88', clusterDb[edge.toCluster]); + points = cutPathAtIntersect(edge.points, clusterDb[edge.toCluster].node); + // log.trace('edge', edge); + // points = []; + // let lastPointOutside; // = edge.points[0]; + // let isInside = false; + // edge.points.forEach(point => { + // const node = clusterDb[edge.toCluster].node; + // log.warn('checking from', edge.fromCluster, point, node); + + // if (!outsideNode(node, point) && !isInside) { + // log.trace('inside', edge.toCluster, point, lastPointOutside); + + // // First point inside the rect + // const inter = intersection(node, lastPointOutside, point); + + // let pointPresent = false; + // points.forEach(p => { + // pointPresent = pointPresent || (p.x === inter.x && p.y === inter.y); + // }); + // // if (!pointPresent) { + // if (!points.find(e => e.x === inter.x && e.y === inter.y)) { + // points.push(inter); + // } else { + // log.warn('no intersect', inter, points); + // } + // isInside = true; + // } else { + // // outside + // lastPointOutside = point; + // if (!isInside) points.push(point); + // } + // }); + pointsHasChanged = true; + } + + if (edge.fromCluster) { + log.info('from cluster abc88', clusterDb[edge.fromCluster]); + points = cutPathAtIntersect(points.reverse(), clusterDb[edge.fromCluster].node).reverse(); + + pointsHasChanged = true; + } + + // The data for our line + const lineData = points.filter((p) => !Number.isNaN(p.y)); + + // This is the accessor function we talked about above + let curve = curveBasis; + // Currently only flowcharts get the curve from the settings, perhaps this should + // be expanded to a common setting? Restricting it for now in order not to cause side-effects that + // have not been thought through + if (edge.curve && (diagramType === 'graph' || diagramType === 'flowchart')) { + curve = edge.curve; + } + + const { x, y } = getLineFunctionsWithOffset(edge); + const lineFunction = line().x(x).y(y).curve(curve); + + // Construct stroke classes based on properties + let strokeClasses; + switch (edge.thickness) { + case 'normal': + strokeClasses = 'edge-thickness-normal'; + break; + case 'thick': + strokeClasses = 'edge-thickness-thick'; + break; + case 'invisible': + strokeClasses = 'edge-thickness-thick'; + break; + default: + strokeClasses = ''; + } + switch (edge.pattern) { + case 'solid': + strokeClasses += ' edge-pattern-solid'; + break; + case 'dotted': + strokeClasses += ' edge-pattern-dotted'; + break; + case 'dashed': + strokeClasses += ' edge-pattern-dashed'; + break; + } + + const svgPath = elem + .append('path') + .attr('d', lineFunction(lineData)) + .attr('id', edge.id) + .attr('class', ' ' + strokeClasses + (edge.classes ? ' ' + edge.classes : '')) + .attr('style', edge.style); + + // DEBUG code, adds a red circle at each edge coordinate + // edge.points.forEach((point) => { + // elem + // .append('circle') + // .style('stroke', 'red') + // .style('fill', 'red') + // .attr('r', 1) + // .attr('cx', point.x) + // .attr('cy', point.y); + // }); + + let url = ''; + // // TODO: Can we load this config only from the rendered graph type? + if (getConfig().flowchart.arrowMarkerAbsolute || getConfig().state.arrowMarkerAbsolute) { + url = + window.location.protocol + + '//' + + window.location.host + + window.location.pathname + + window.location.search; + url = url.replace(/\(/g, '\\('); + url = url.replace(/\)/g, '\\)'); + } + log.info('arrowTypeStart', edge.arrowTypeStart); + log.info('arrowTypeEnd', edge.arrowTypeEnd); + + addEdgeMarkers(svgPath, edge, url, id, diagramType); + + let paths = {}; + if (pointsHasChanged) { + paths.updatedPath = points; + } + paths.originalPath = edge.points; + return paths; +}; diff --git a/packages/mermaid/src/rendering-util/rendering-elements/intersect/index.js b/packages/mermaid/src/rendering-util/rendering-elements/intersect/index.js new file mode 100644 index 000000000..e33b6dd51 --- /dev/null +++ b/packages/mermaid/src/rendering-util/rendering-elements/intersect/index.js @@ -0,0 +1,17 @@ +/* + * Borrowed with love from from dagre-d3. Many thanks to cpettitt! + */ + +import node from './intersect-node.js'; +import circle from './intersect-circle.js'; +import ellipse from './intersect-ellipse.js'; +import polygon from './intersect-polygon.js'; +import rect from './intersect-rect.js'; + +export default { + node, + circle, + ellipse, + polygon, + rect, +}; diff --git a/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-circle.js b/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-circle.js new file mode 100644 index 000000000..8f5ba72df --- /dev/null +++ b/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-circle.js @@ -0,0 +1,12 @@ +import intersectEllipse from './intersect-ellipse.js'; + +/** + * @param node + * @param rx + * @param point + */ +function intersectCircle(node, rx, point) { + return intersectEllipse(node, rx, rx, point); +} + +export default intersectCircle; diff --git a/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-ellipse.js b/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-ellipse.js new file mode 100644 index 000000000..96f4a166e --- /dev/null +++ b/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-ellipse.js @@ -0,0 +1,30 @@ +/** + * @param node + * @param rx + * @param ry + * @param point + */ +function intersectEllipse(node, rx, ry, point) { + // Formulae from: https://mathworld.wolfram.com/Ellipse-LineIntersection.html + + var cx = node.x; + var cy = node.y; + + var px = cx - point.x; + var py = cy - point.y; + + var det = Math.sqrt(rx * rx * py * py + ry * ry * px * px); + + var dx = Math.abs((rx * ry * px) / det); + if (point.x < cx) { + dx = -dx; + } + var dy = Math.abs((rx * ry * py) / det); + if (point.y < cy) { + dy = -dy; + } + + return { x: cx + dx, y: cy + dy }; +} + +export default intersectEllipse; diff --git a/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-line.js b/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-line.js new file mode 100644 index 000000000..e97ae6f0d --- /dev/null +++ b/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-line.js @@ -0,0 +1,78 @@ +/** + * Returns the point at which two lines, p and q, intersect or returns undefined if they do not intersect. + * + * @param p1 + * @param p2 + * @param q1 + * @param q2 + */ +function intersectLine(p1, p2, q1, q2) { + // Algorithm from J. Avro, (ed.) Graphics Gems, No 2, Morgan Kaufmann, 1994, + // p7 and p473. + + var a1, a2, b1, b2, c1, c2; + var r1, r2, r3, r4; + var denom, offset, num; + var x, y; + + // Compute a1, b1, c1, where line joining points 1 and 2 is F(x,y) = a1 x + + // b1 y + c1 = 0. + a1 = p2.y - p1.y; + b1 = p1.x - p2.x; + c1 = p2.x * p1.y - p1.x * p2.y; + + // Compute r3 and r4. + r3 = a1 * q1.x + b1 * q1.y + c1; + r4 = a1 * q2.x + b1 * q2.y + c1; + + // Check signs of r3 and r4. If both point 3 and point 4 lie on + // same side of line 1, the line segments do not intersect. + if (r3 !== 0 && r4 !== 0 && sameSign(r3, r4)) { + return /*DON'T_INTERSECT*/; + } + + // Compute a2, b2, c2 where line joining points 3 and 4 is G(x,y) = a2 x + b2 y + c2 = 0 + a2 = q2.y - q1.y; + b2 = q1.x - q2.x; + c2 = q2.x * q1.y - q1.x * q2.y; + + // Compute r1 and r2 + r1 = a2 * p1.x + b2 * p1.y + c2; + r2 = a2 * p2.x + b2 * p2.y + c2; + + // Check signs of r1 and r2. If both point 1 and point 2 lie + // on same side of second line segment, the line segments do + // not intersect. + if (r1 !== 0 && r2 !== 0 && sameSign(r1, r2)) { + return /*DON'T_INTERSECT*/; + } + + // Line segments intersect: compute intersection point. + denom = a1 * b2 - a2 * b1; + if (denom === 0) { + return /*COLLINEAR*/; + } + + offset = Math.abs(denom / 2); + + // The denom/2 is to get rounding instead of truncating. It + // is added or subtracted to the numerator, depending upon the + // sign of the numerator. + num = b1 * c2 - b2 * c1; + x = num < 0 ? (num - offset) / denom : (num + offset) / denom; + + num = a2 * c1 - a1 * c2; + y = num < 0 ? (num - offset) / denom : (num + offset) / denom; + + return { x: x, y: y }; +} + +/** + * @param r1 + * @param r2 + */ +function sameSign(r1, r2) { + return r1 * r2 > 0; +} + +export default intersectLine; diff --git a/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-node.js b/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-node.js new file mode 100644 index 000000000..54a88ba61 --- /dev/null +++ b/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-node.js @@ -0,0 +1,10 @@ +/** + * @param node + * @param point + */ +function intersectNode(node, point) { + // console.info('Intersect Node'); + return node.intersect(point); +} + +export default intersectNode; diff --git a/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-polygon.js b/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-polygon.js new file mode 100644 index 000000000..6941372c7 --- /dev/null +++ b/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-polygon.js @@ -0,0 +1,70 @@ +/* eslint "no-console": off */ + +import intersectLine from './intersect-line.js'; + +export default intersectPolygon; + +/** + * Returns the point ({x, y}) at which the point argument intersects with the node argument assuming + * that it has the shape specified by polygon. + * + * @param node + * @param polyPoints + * @param point + */ +function intersectPolygon(node, polyPoints, point) { + var x1 = node.x; + var y1 = node.y; + + var intersections = []; + + var minX = Number.POSITIVE_INFINITY; + var minY = Number.POSITIVE_INFINITY; + if (typeof polyPoints.forEach === 'function') { + polyPoints.forEach(function (entry) { + minX = Math.min(minX, entry.x); + minY = Math.min(minY, entry.y); + }); + } else { + minX = Math.min(minX, polyPoints.x); + minY = Math.min(minY, polyPoints.y); + } + + var left = x1 - node.width / 2 - minX; + var top = y1 - node.height / 2 - minY; + + for (var i = 0; i < polyPoints.length; i++) { + var p1 = polyPoints[i]; + var p2 = polyPoints[i < polyPoints.length - 1 ? i + 1 : 0]; + var intersect = intersectLine( + node, + point, + { x: left + p1.x, y: top + p1.y }, + { x: left + p2.x, y: top + p2.y } + ); + if (intersect) { + intersections.push(intersect); + } + } + + if (!intersections.length) { + // console.log('NO INTERSECTION FOUND, RETURN NODE CENTER', node); + return node; + } + + if (intersections.length > 1) { + // More intersections, find the one nearest to edge end point + intersections.sort(function (p, q) { + var pdx = p.x - point.x; + var pdy = p.y - point.y; + var distp = Math.sqrt(pdx * pdx + pdy * pdy); + + var qdx = q.x - point.x; + var qdy = q.y - point.y; + var distq = Math.sqrt(qdx * qdx + qdy * qdy); + + return distp < distq ? -1 : distp === distq ? 0 : 1; + }); + } + return intersections[0]; +} diff --git a/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-rect.js b/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-rect.js new file mode 100644 index 000000000..daf6b5eea --- /dev/null +++ b/packages/mermaid/src/rendering-util/rendering-elements/intersect/intersect-rect.js @@ -0,0 +1,32 @@ +const intersectRect = (node, point) => { + var x = node.x; + var y = node.y; + + // Rectangle intersection algorithm from: + // https://math.stackexchange.com/questions/108113/find-edge-between-two-boxes + var dx = point.x - x; + var dy = point.y - y; + var w = node.width / 2; + var h = node.height / 2; + + var sx, sy; + if (Math.abs(dy) * w > Math.abs(dx) * h) { + // Intersection is top or bottom of rect. + if (dy < 0) { + h = -h; + } + sx = dy === 0 ? 0 : (h * dx) / dy; + sy = h; + } else { + // Intersection is left or right of rect. + if (dx < 0) { + w = -w; + } + sx = w; + sy = dx === 0 ? 0 : (w * dy) / dx; + } + + return { x: x + sx, y: y + sy }; +}; + +export default intersectRect; diff --git a/packages/mermaid/src/rendering-util/rendering-elements/markers.js b/packages/mermaid/src/rendering-util/rendering-elements/markers.js new file mode 100644 index 000000000..c7cfcfe7f --- /dev/null +++ b/packages/mermaid/src/rendering-util/rendering-elements/markers.js @@ -0,0 +1,293 @@ +/** Setup arrow head and define the marker. The result is appended to the svg. */ +import { log } from '$root/logger.js'; + +// Only add the number of markers that the diagram needs +const insertMarkers = (elem, markerArray, type, id) => { + markerArray.forEach((markerName) => { + markers[markerName](elem, type, id); + }); +}; + +const extension = (elem, type, id) => { + log.trace('Making markers for ', id); + elem + .append('defs') + .append('marker') + .attr('id', id + '_' + type + '-extensionStart') + .attr('class', 'marker extension ' + type) + .attr('refX', 18) + .attr('refY', 7) + .attr('markerWidth', 190) + .attr('markerHeight', 240) + .attr('orient', 'auto') + .append('path') + .attr('d', 'M 1,7 L18,13 V 1 Z'); + + elem + .append('defs') + .append('marker') + .attr('id', id + '_' + type + '-extensionEnd') + .attr('class', 'marker extension ' + type) + .attr('refX', 1) + .attr('refY', 7) + .attr('markerWidth', 20) + .attr('markerHeight', 28) + .attr('orient', 'auto') + .append('path') + .attr('d', 'M 1,1 V 13 L18,7 Z'); // this is actual shape for arrowhead +}; + +const composition = (elem, type, id) => { + elem + .append('defs') + .append('marker') + .attr('id', id + '_' + type + '-compositionStart') + .attr('class', 'marker composition ' + type) + .attr('refX', 18) + .attr('refY', 7) + .attr('markerWidth', 190) + .attr('markerHeight', 240) + .attr('orient', 'auto') + .append('path') + .attr('d', 'M 18,7 L9,13 L1,7 L9,1 Z'); + + elem + .append('defs') + .append('marker') + .attr('id', id + '_' + type + '-compositionEnd') + .attr('class', 'marker composition ' + type) + .attr('refX', 1) + .attr('refY', 7) + .attr('markerWidth', 20) + .attr('markerHeight', 28) + .attr('orient', 'auto') + .append('path') + .attr('d', 'M 18,7 L9,13 L1,7 L9,1 Z'); +}; +const aggregation = (elem, type, id) => { + elem + .append('defs') + .append('marker') + .attr('id', id + '_' + type + '-aggregationStart') + .attr('class', 'marker aggregation ' + type) + .attr('refX', 18) + .attr('refY', 7) + .attr('markerWidth', 190) + .attr('markerHeight', 240) + .attr('orient', 'auto') + .append('path') + .attr('d', 'M 18,7 L9,13 L1,7 L9,1 Z'); + + elem + .append('defs') + .append('marker') + .attr('id', id + '_' + type + '-aggregationEnd') + .attr('class', 'marker aggregation ' + type) + .attr('refX', 1) + .attr('refY', 7) + .attr('markerWidth', 20) + .attr('markerHeight', 28) + .attr('orient', 'auto') + .append('path') + .attr('d', 'M 18,7 L9,13 L1,7 L9,1 Z'); +}; +const dependency = (elem, type, id) => { + elem + .append('defs') + .append('marker') + .attr('id', id + '_' + type + '-dependencyStart') + .attr('class', 'marker dependency ' + type) + .attr('refX', 6) + .attr('refY', 7) + .attr('markerWidth', 190) + .attr('markerHeight', 240) + .attr('orient', 'auto') + .append('path') + .attr('d', 'M 5,7 L9,13 L1,7 L9,1 Z'); + + elem + .append('defs') + .append('marker') + .attr('id', id + '_' + type + '-dependencyEnd') + .attr('class', 'marker dependency ' + type) + .attr('refX', 13) + .attr('refY', 7) + .attr('markerWidth', 20) + .attr('markerHeight', 28) + .attr('orient', 'auto') + .append('path') + .attr('d', 'M 18,7 L9,13 L14,7 L9,1 Z'); +}; +const lollipop = (elem, type, id) => { + elem + .append('defs') + .append('marker') + .attr('id', id + '_' + type + '-lollipopStart') + .attr('class', 'marker lollipop ' + type) + .attr('refX', 13) + .attr('refY', 7) + .attr('markerWidth', 190) + .attr('markerHeight', 240) + .attr('orient', 'auto') + .append('circle') + .attr('stroke', 'black') + .attr('fill', 'transparent') + .attr('cx', 7) + .attr('cy', 7) + .attr('r', 6); + + elem + .append('defs') + .append('marker') + .attr('id', id + '_' + type + '-lollipopEnd') + .attr('class', 'marker lollipop ' + type) + .attr('refX', 1) + .attr('refY', 7) + .attr('markerWidth', 190) + .attr('markerHeight', 240) + .attr('orient', 'auto') + .append('circle') + .attr('stroke', 'black') + .attr('fill', 'transparent') + .attr('cx', 7) + .attr('cy', 7) + .attr('r', 6); +}; +const point = (elem, type, id) => { + elem + .append('marker') + .attr('id', id + '_' + type + '-pointEnd') + .attr('class', 'marker ' + type) + .attr('viewBox', '0 0 10 10') + .attr('refX', 6) + .attr('refY', 5) + .attr('markerUnits', 'userSpaceOnUse') + .attr('markerWidth', 12) + .attr('markerHeight', 12) + .attr('orient', 'auto') + .append('path') + .attr('d', 'M 0 0 L 10 5 L 0 10 z') + .attr('class', 'arrowMarkerPath') + .style('stroke-width', 1) + .style('stroke-dasharray', '1,0'); + elem + .append('marker') + .attr('id', id + '_' + type + '-pointStart') + .attr('class', 'marker ' + type) + .attr('viewBox', '0 0 10 10') + .attr('refX', 4.5) + .attr('refY', 5) + .attr('markerUnits', 'userSpaceOnUse') + .attr('markerWidth', 12) + .attr('markerHeight', 12) + .attr('orient', 'auto') + .append('path') + .attr('d', 'M 0 5 L 10 10 L 10 0 z') + .attr('class', 'arrowMarkerPath') + .style('stroke-width', 1) + .style('stroke-dasharray', '1,0'); +}; +const circle = (elem, type, id) => { + elem + .append('marker') + .attr('id', id + '_' + type + '-circleEnd') + .attr('class', 'marker ' + type) + .attr('viewBox', '0 0 10 10') + .attr('refX', 11) + .attr('refY', 5) + .attr('markerUnits', 'userSpaceOnUse') + .attr('markerWidth', 11) + .attr('markerHeight', 11) + .attr('orient', 'auto') + .append('circle') + .attr('cx', '5') + .attr('cy', '5') + .attr('r', '5') + .attr('class', 'arrowMarkerPath') + .style('stroke-width', 1) + .style('stroke-dasharray', '1,0'); + + elem + .append('marker') + .attr('id', id + '_' + type + '-circleStart') + .attr('class', 'marker ' + type) + .attr('viewBox', '0 0 10 10') + .attr('refX', -1) + .attr('refY', 5) + .attr('markerUnits', 'userSpaceOnUse') + .attr('markerWidth', 11) + .attr('markerHeight', 11) + .attr('orient', 'auto') + .append('circle') + .attr('cx', '5') + .attr('cy', '5') + .attr('r', '5') + .attr('class', 'arrowMarkerPath') + .style('stroke-width', 1) + .style('stroke-dasharray', '1,0'); +}; +const cross = (elem, type, id) => { + elem + .append('marker') + .attr('id', id + '_' + type + '-crossEnd') + .attr('class', 'marker cross ' + type) + .attr('viewBox', '0 0 11 11') + .attr('refX', 12) + .attr('refY', 5.2) + .attr('markerUnits', 'userSpaceOnUse') + .attr('markerWidth', 11) + .attr('markerHeight', 11) + .attr('orient', 'auto') + .append('path') + // .attr('stroke', 'black') + .attr('d', 'M 1,1 l 9,9 M 10,1 l -9,9') + .attr('class', 'arrowMarkerPath') + .style('stroke-width', 2) + .style('stroke-dasharray', '1,0'); + + elem + .append('marker') + .attr('id', id + '_' + type + '-crossStart') + .attr('class', 'marker cross ' + type) + .attr('viewBox', '0 0 11 11') + .attr('refX', -1) + .attr('refY', 5.2) + .attr('markerUnits', 'userSpaceOnUse') + .attr('markerWidth', 11) + .attr('markerHeight', 11) + .attr('orient', 'auto') + .append('path') + // .attr('stroke', 'black') + .attr('d', 'M 1,1 l 9,9 M 10,1 l -9,9') + .attr('class', 'arrowMarkerPath') + .style('stroke-width', 2) + .style('stroke-dasharray', '1,0'); +}; +const barb = (elem, type, id) => { + elem + .append('defs') + .append('marker') + .attr('id', id + '_' + type + '-barbEnd') + .attr('refX', 19) + .attr('refY', 7) + .attr('markerWidth', 20) + .attr('markerHeight', 14) + .attr('markerUnits', 'strokeWidth') + .attr('orient', 'auto') + .append('path') + .attr('d', 'M 19,7 L9,13 L14,7 L9,1 Z'); +}; + +// TODO rename the class diagram markers to something shape descriptive and semantic free +const markers = { + extension, + composition, + aggregation, + dependency, + lollipop, + point, + circle, + cross, + barb, +}; +export default insertMarkers; diff --git a/packages/mermaid/src/rendering-util/rendering-elements/nodes.js b/packages/mermaid/src/rendering-util/rendering-elements/nodes.js new file mode 100644 index 000000000..88db04393 --- /dev/null +++ b/packages/mermaid/src/rendering-util/rendering-elements/nodes.js @@ -0,0 +1,83 @@ +import { log } from '$root/logger.js'; +import { rect } from './shapes/rect.js'; +import { getConfig } from '$root/diagram-api/diagramAPI.js'; + +const formatClass = (str) => { + if (str) { + return ' ' + str; + } + return ''; +}; + +const shapes = { + rect, +}; + +let nodeElems = {}; + +export const insertNode = async (elem, node, dir) => { + let newEl; + let el; + + console.log('insertNode element', elem, elem.node(), rect); + // debugger; + // Add link when appropriate + if (node.link) { + let target; + if (getConfig().securityLevel === 'sandbox') { + target = '_top'; + } else if (node.linkTarget) { + target = node.linkTarget || '_blank'; + } + newEl = elem.insert('svg:a').attr('xlink:href', node.link).attr('target', target); + el = await shapes[node.shape](newEl, node, dir); + } else { + el = await shapes[node.shape](elem, node, dir); + newEl = el; + } + if (node.tooltip) { + el.attr('title', node.tooltip); + } + if (node.class) { + el.attr('class', 'node default ' + node.class); + } + + nodeElems[node.id] = newEl; + + if (node.haveCallback) { + nodeElems[node.id].attr('class', nodeElems[node.id].attr('class') + ' clickable'); + } + return newEl; +}; +export const setNodeElem = (elem, node) => { + nodeElems[node.id] = elem; +}; +export const clear = () => { + nodeElems = {}; +}; + +export const positionNode = (node) => { + const el = nodeElems[node.id]; + + log.trace( + 'Transforming node', + node.diff, + node, + 'translate(' + (node.x - node.width / 2 - 5) + ', ' + node.width / 2 + ')' + ); + const padding = 8; + const diff = node.diff || 0; + if (node.clusterNode) { + el.attr( + 'transform', + 'translate(' + + (node.x + diff - node.width / 2) + + ', ' + + (node.y - node.height / 2 - padding) + + ')' + ); + } else { + el.attr('transform', 'translate(' + node.x + ', ' + node.y + ')'); + } + return diff; +}; diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/rect.js b/packages/mermaid/src/rendering-util/rendering-elements/shapes/rect.js new file mode 100644 index 000000000..96b803fdb --- /dev/null +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/rect.js @@ -0,0 +1,126 @@ +import { log } from '$root/logger.js'; +import { labelHelper, updateNodeBounds } from './util.js'; +import intersect from '../intersect/index.js'; + +/** + * + * @param rect + * @param borders + * @param totalWidth + * @param totalHeight + */ +function applyNodePropertyBorders(rect, borders, totalWidth, totalHeight) { + const strokeDashArray = []; + const addBorder = (length) => { + strokeDashArray.push(length, 0); + }; + const skipBorder = (length) => { + strokeDashArray.push(0, length); + }; + if (borders.includes('t')) { + log.debug('add top border'); + addBorder(totalWidth); + } else { + skipBorder(totalWidth); + } + if (borders.includes('r')) { + log.debug('add right border'); + addBorder(totalHeight); + } else { + skipBorder(totalHeight); + } + if (borders.includes('b')) { + log.debug('add bottom border'); + addBorder(totalWidth); + } else { + skipBorder(totalWidth); + } + if (borders.includes('l')) { + log.debug('add left border'); + addBorder(totalHeight); + } else { + skipBorder(totalHeight); + } + rect.attr('stroke-dasharray', strokeDashArray.join(' ')); +} + +export const rect = async (parent, node) => { + const { shapeSvg, bbox, halfPadding } = await labelHelper( + parent, + node, + 'node ' + node.classes + ' ' + node.class, + true + ); + + console.log('rect node', node); + + // add the rect + const rect = shapeSvg.insert('rect', ':first-child'); + + const totalWidth = bbox.width + node.padding; + const totalHeight = bbox.height + node.padding; + + rect + .attr('class', 'basic label-container') + .attr('style', node.style) + .attr('rx', node.rx) + .attr('ry', node.ry) + // .attr('x', -bbox.width / 2 - node.padding) + // .attr('y', -bbox.height / 2 - node.padding) + .attr('x', -bbox.width / 2 - halfPadding) + .attr('y', -bbox.height / 2 - halfPadding) + .attr('width', totalWidth) + .attr('height', totalHeight); + + if (node.props) { + const propKeys = new Set(Object.keys(node.props)); + if (node.props.borders) { + applyNodePropertyBorders(rect, node.props.borders, totalWidth, totalHeight); + propKeys.delete('borders'); + } + propKeys.forEach((propKey) => { + log.warn(`Unknown node property ${propKey}`); + }); + } + + updateNodeBounds(node, rect); + + node.intersect = function (point) { + return intersect.rect(node, point); + }; + + return shapeSvg; +}; + +export const labelRect = async (parent, node) => { + const { shapeSvg } = await labelHelper(parent, node, 'label', true); + + log.trace('Classes = ', node.class); + // add the rect + const rect = shapeSvg.insert('rect', ':first-child'); + + // Hide the rect we are only after the label + const totalWidth = 0; + const totalHeight = 0; + rect.attr('width', totalWidth).attr('height', totalHeight); + shapeSvg.attr('class', 'label edgeLabel'); + + if (node.props) { + const propKeys = new Set(Object.keys(node.props)); + if (node.props.borders) { + applyNodePropertyBorders(rect, node.props.borders, totalWidth, totalHeight); + propKeys.delete('borders'); + } + propKeys.forEach((propKey) => { + log.warn(`Unknown node property ${propKey}`); + }); + } + + updateNodeBounds(node, rect); + + node.intersect = function (point) { + return intersect.rect(node, point); + }; + + return shapeSvg; +}; diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/util.js b/packages/mermaid/src/rendering-util/rendering-elements/shapes/util.js new file mode 100644 index 000000000..d9314aacb --- /dev/null +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/util.js @@ -0,0 +1,146 @@ +import createLabel from '../createLabel.js'; +import { createText } from '$root/rendering-util/createText.ts'; +import { getConfig } from '$root/diagram-api/diagramAPI.js'; +import { select } from 'd3'; +import { evaluate, sanitizeText } from '$root/diagrams/common/common.js'; +import { decodeEntities } from '$root/utils.js'; + +export const labelHelper = async (parent, node, _classes, isNode) => { + let classes; + const useHtmlLabels = node.useHtmlLabels || evaluate(getConfig().flowchart.htmlLabels); + if (!_classes) { + classes = 'node default'; + } else { + classes = _classes; + } + + // Add outer g element + const shapeSvg = parent + .insert('g') + .attr('class', classes) + .attr('id', node.domId || node.id); + + // Create the label and insert it after the rect + const label = shapeSvg.insert('g').attr('class', 'label').attr('style', node.labelStyle); + + // Replace labelText with default value if undefined + let labelText; + if (node.labelText === undefined) { + labelText = ''; + } else { + labelText = typeof node.labelText === 'string' ? node.labelText : node.labelText[0]; + } + + const textNode = label.node(); + let text; + if (node.labelType === 'markdown') { + // text = textNode; + text = createText(label, sanitizeText(decodeEntities(labelText), getConfig()), { + useHtmlLabels, + width: node.width || getConfig().flowchart.wrappingWidth, + classes: 'markdown-node-label', + }); + } else { + text = textNode.appendChild( + createLabel( + sanitizeText(decodeEntities(labelText), getConfig()), + node.labelStyle, + false, + isNode + ) + ); + } + // Get the size of the label + let bbox = text.getBBox(); + const halfPadding = node.padding / 2; + + if (evaluate(getConfig().flowchart.htmlLabels)) { + const div = text.children[0]; + const dv = select(text); + + // if there are images, need to wait for them to load before getting the bounding box + const images = div.getElementsByTagName('img'); + if (images) { + const noImgText = labelText.replace(/]*>/g, '').trim() === ''; + + await Promise.all( + [...images].map( + (img) => + new Promise((res) => { + /** + * + */ + function setupImage() { + img.style.display = 'flex'; + img.style.flexDirection = 'column'; + + if (noImgText) { + // default size if no text + const bodyFontSize = getConfig().fontSize + ? getConfig().fontSize + : window.getComputedStyle(document.body).fontSize; + const enlargingFactor = 5; + const width = parseInt(bodyFontSize, 10) * enlargingFactor + 'px'; + img.style.minWidth = width; + img.style.maxWidth = width; + } else { + img.style.width = '100%'; + } + res(img); + } + setTimeout(() => { + if (img.complete) { + setupImage(); + } + }); + img.addEventListener('error', setupImage); + img.addEventListener('load', setupImage); + }) + ) + ); + } + + bbox = div.getBoundingClientRect(); + dv.attr('width', bbox.width); + dv.attr('height', bbox.height); + } + + // Center the label + if (useHtmlLabels) { + label.attr('transform', 'translate(' + -bbox.width / 2 + ', ' + -bbox.height / 2 + ')'); + } else { + label.attr('transform', 'translate(' + 0 + ', ' + -bbox.height / 2 + ')'); + } + if (node.centerLabel) { + label.attr('transform', 'translate(' + -bbox.width / 2 + ', ' + -bbox.height / 2 + ')'); + } + label.insert('rect', ':first-child'); + return { shapeSvg, bbox, halfPadding, label }; +}; + +export const updateNodeBounds = (node, element) => { + const bbox = element.node().getBBox(); + node.width = bbox.width; + node.height = bbox.height; +}; + +/** + * @param parent + * @param w + * @param h + * @param points + */ +export function insertPolygonShape(parent, w, h, points) { + return parent + .insert('polygon', ':first-child') + .attr( + 'points', + points + .map(function (d) { + return d.x + ',' + d.y; + }) + .join(' ') + ) + .attr('class', 'label-container') + .attr('transform', 'translate(' + -w / 2 + ',' + h / 2 + ')'); +} diff --git a/packages/mermaid/tsconfig.json b/packages/mermaid/tsconfig.json index 78e3cf2de..0111d1647 100644 --- a/packages/mermaid/tsconfig.json +++ b/packages/mermaid/tsconfig.json @@ -3,7 +3,12 @@ "compilerOptions": { "rootDir": "./src", "outDir": "./dist", - "types": ["vitest/importMeta", "vitest/globals"] + "types": ["vitest/importMeta", "vitest/globals"], + "baseUrl": ".", // This must be set if "paths" is set + "paths": { + "$root/*": ["src/*"] + } + }, - "include": ["./src/**/*.ts", "./package.json"] + "include": ["./src/**/*.ts", "./package.json"], } diff --git a/vite.config.ts b/vite.config.ts index 87124b9bf..935e4e44b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,10 +2,15 @@ import jison from './.vite/jisonPlugin.js'; import jsonSchemaPlugin from './.vite/jsonSchemaPlugin.js'; import typescript from '@rollup/plugin-typescript'; import { defaultExclude, defineConfig } from 'vitest/config'; +import path from 'path'; export default defineConfig({ resolve: { extensions: ['.js'], + alias: { + // Define your alias here + '$root/*': path.resolve(__dirname, 'src/*'), + }, }, plugins: [ jison(),