import { select } from 'd3'; import utils, { getEdgeId } from '../../utils.js'; import { getConfig, defaultConfig } from '../../diagram-api/diagramAPI.js'; import common from '../common/common.js'; import type { Node, Edge } from '../../rendering-util/types.js'; import { log } from '../../logger.js'; import { setAccTitle, getAccTitle, getAccDescription, setAccDescription, clear as commonClear, setDiagramTitle, getDiagramTitle, } from '../common/commonDb.js'; import type { FlowVertex, FlowClass, FlowSubGraph, FlowText, FlowEdge, FlowLink } from './types.js'; const MERMAID_DOM_ID_PREFIX = 'flowchart-'; let vertexCounter = 0; let config = getConfig(); let vertices = new Map(); let edges: FlowEdge[] & { defaultInterpolate?: string; defaultStyle?: string[] } = []; let classes = new Map(); let subGraphs: FlowSubGraph[] = []; let subGraphLookup = new Map(); let tooltips = new Map(); let subCount = 0; let firstGraphFlag = true; let direction: string; let version: string; // As in graph // Functions to be run after graph rendering let funs: ((element: Element) => void)[] = []; // cspell:ignore funs const sanitizeText = (txt: string) => common.sanitizeText(txt, config); /** * Function to lookup domId from id in the graph definition. * * @param id - id of the node */ export const lookUpDomId = function (id: string) { for (const vertex of vertices.values()) { if (vertex.id === id) { return vertex.domId; } } return id; }; /** * Function called by parser when a node definition has been found * */ export const addVertex = function ( id: string, textObj: FlowText, type: 'group', style: string[], classes: string[], dir: string, props = {} ) { if (!id || id.trim().length === 0) { return; } let txt; let vertex = vertices.get(id); if (vertex === undefined) { vertex = { id, labelType: 'text', domId: MERMAID_DOM_ID_PREFIX + id + '-' + vertexCounter, styles: [], classes: [], }; vertices.set(id, vertex); } vertexCounter++; if (textObj !== undefined) { config = getConfig(); txt = sanitizeText(textObj.text.trim()); vertex.labelType = textObj.type; // strip quotes if string starts and ends with a quote if (txt.startsWith('"') && txt.endsWith('"')) { txt = txt.substring(1, txt.length - 1); } vertex.text = txt; } else { if (vertex.text === undefined) { vertex.text = id; } } if (type !== undefined) { vertex.type = type; } if (style !== undefined && style !== null) { style.forEach(function (s) { vertex.styles.push(s); }); } if (classes !== undefined && classes !== null) { classes.forEach(function (s) { vertex.classes.push(s); }); } if (dir !== undefined) { vertex.dir = dir; } if (vertex.props === undefined) { vertex.props = props; } else if (props !== undefined) { Object.assign(vertex.props, props); } }; /** * Function called by parser when a link/edge definition has been found * */ export const addSingleLink = function (_start: string, _end: string, type: any) { const start = _start; const end = _end; const edge: FlowEdge = { start: start, end: end, type: undefined, text: '', labelType: 'text' }; log.info('abc78 Got edge...', edge); const linkTextObj = type.text; if (linkTextObj !== undefined) { edge.text = sanitizeText(linkTextObj.text.trim()); // strip quotes if string starts and ends with a quote if (edge.text.startsWith('"') && edge.text.endsWith('"')) { edge.text = edge.text.substring(1, edge.text.length - 1); } edge.labelType = linkTextObj.type; } if (type !== undefined) { edge.type = type.type; edge.stroke = type.stroke; edge.length = type.length > 10 ? 10 : type.length; } if (edges.length < (config.maxEdges ?? 500)) { log.info('Pushing edge...'); edges.push(edge); } else { throw new Error( `Edge limit exceeded. ${edges.length} edges found, but the limit is ${config.maxEdges}. Initialize mermaid with maxEdges set to a higher number to allow more edges. You cannot set this config via configuration inside the diagram as it is a secure config. You have to call mermaid.initialize.` ); } }; export const addLink = function (_start: string[], _end: string[], type: unknown) { log.info('addLink', _start, _end, type); for (const start of _start) { for (const end of _end) { addSingleLink(start, end, type); } } }; /** * Updates a link's line interpolation algorithm * */ export const updateLinkInterpolate = function ( positions: ('default' | number)[], interpolate: string ) { positions.forEach(function (pos) { if (pos === 'default') { edges.defaultInterpolate = interpolate; } else { edges[pos].interpolate = interpolate; } }); }; /** * Updates a link with a style * */ export const updateLink = function (positions: ('default' | number)[], style: string[]) { positions.forEach(function (pos) { if (typeof pos === 'number' && pos >= edges.length) { throw new Error( `The index ${pos} for linkStyle is out of bounds. Valid indices for linkStyle are between 0 and ${ edges.length - 1 }. (Help: Ensure that the index is within the range of existing edges.)` ); } if (pos === 'default') { edges.defaultStyle = style; } else { // if (utils.isSubstringInArray('fill', style) === -1) { // style.push('fill:none'); // } edges[pos].style = style; // if edges[pos].style does have fill not set, set it to none if ( (edges[pos]?.style?.length ?? 0) > 0 && !edges[pos]?.style?.some((s) => s?.startsWith('fill')) ) { edges[pos]?.style?.push('fill:none'); } } }); }; export const addClass = function (ids: string, style: string[]) { ids.split(',').forEach(function (id) { let classNode = classes.get(id); if (classNode === undefined) { classNode = { id, styles: [], textStyles: [] }; classes.set(id, classNode); } if (style !== undefined && style !== null) { style.forEach(function (s) { if (/color/.exec(s)) { const newStyle = s.replace('fill', 'bgFill'); // .replace('color', 'fill'); classNode.textStyles.push(newStyle); } classNode.styles.push(s); }); } }); }; /** * Called by parser when a graph definition is found, stores the direction of the chart. * */ export const setDirection = function (dir: string) { direction = dir; if (/.*/.exec(direction)) { direction = 'LR'; } if (/.*v/.exec(direction)) { direction = 'TB'; } if (direction === 'TD') { direction = 'TB'; } }; /** * Called by parser when a special node is found, e.g. a clickable element. * * @param ids - Comma separated list of ids * @param className - Class to add */ export const setClass = function (ids: string, className: string) { for (const id of ids.split(',')) { const vertex = vertices.get(id); if (vertex) { vertex.classes.push(className); } const subGraph = subGraphLookup.get(id); if (subGraph) { subGraph.classes.push(className); } } }; const setTooltip = function (ids: string, tooltip: string) { if (tooltip === undefined) { return; } tooltip = sanitizeText(tooltip); for (const id of ids.split(',')) { tooltips.set(version === 'gen-1' ? lookUpDomId(id) : id, tooltip); } }; const setClickFun = function (id: string, functionName: string, functionArgs: string) { const domId = lookUpDomId(id); // if (_id[0].match(/\d/)) id = MERMAID_DOM_ID_PREFIX + id; if (getConfig().securityLevel !== 'loose') { return; } if (functionName === undefined) { return; } let argList: string[] = []; if (typeof functionArgs === 'string') { /* Splits functionArgs by ',', ignoring all ',' in double quoted strings */ argList = functionArgs.split(/,(?=(?:(?:[^"]*"){2})*[^"]*$)/); for (let i = 0; i < argList.length; i++) { let item = argList[i].trim(); /* Removes all double quotes at the start and end of an argument */ /* This preserves all starting and ending whitespace inside */ if (item.startsWith('"') && item.endsWith('"')) { item = item.substr(1, item.length - 2); } argList[i] = item; } } /* if no arguments passed into callback, default to passing in id */ if (argList.length === 0) { argList.push(id); } const vertex = vertices.get(id); if (vertex) { vertex.haveCallback = true; funs.push(function () { const elem = document.querySelector(`[id="${domId}"]`); if (elem !== null) { elem.addEventListener( 'click', function () { utils.runFunc(functionName, ...argList); }, false ); } }); } }; /** * Called by parser when a link is found. Adds the URL to the vertex data. * * @param ids - Comma separated list of ids * @param linkStr - URL to create a link for * @param target - Target attribute for the link */ export const setLink = function (ids: string, linkStr: string, target: string) { ids.split(',').forEach(function (id) { const vertex = vertices.get(id); if (vertex !== undefined) { vertex.link = utils.formatUrl(linkStr, config); vertex.linkTarget = target; } }); setClass(ids, 'clickable'); }; export const getTooltip = function (id: string) { return tooltips.get(id); }; /** * Called by parser when a click definition is found. Registers an event handler. * * @param ids - Comma separated list of ids * @param functionName - Function to be called on click * @param functionArgs - Arguments to be passed to the function */ export const setClickEvent = function (ids: string, functionName: string, functionArgs: string) { ids.split(',').forEach(function (id) { setClickFun(id, functionName, functionArgs); }); setClass(ids, 'clickable'); }; export const bindFunctions = function (element: Element) { funs.forEach(function (fun) { fun(element); }); }; export const getDirection = function () { return direction.trim(); }; /** * Retrieval function for fetching the found nodes after parsing has completed. * */ export const getVertices = function () { return vertices; }; /** * Retrieval function for fetching the found links after parsing has completed. * */ export const getEdges = function () { return edges; }; /** * Retrieval function for fetching the found class definitions after parsing has completed. * */ export const getClasses = function () { return classes; }; const setupToolTips = function (element: Element) { let tooltipElem = select('.mermaidTooltip'); // @ts-ignore TODO: fix this if ((tooltipElem._groups || tooltipElem)[0][0] === null) { // @ts-ignore TODO: fix this tooltipElem = select('body').append('div').attr('class', 'mermaidTooltip').style('opacity', 0); } const svg = select(element).select('svg'); const nodes = svg.selectAll('g.node'); nodes .on('mouseover', function () { const el = select(this); const title = el.attr('title'); // Don't try to draw a tooltip if no data is provided if (title === null) { return; } const rect = (this as Element)?.getBoundingClientRect(); // @ts-ignore TODO: fix this tooltipElem.transition().duration(200).style('opacity', '.9'); tooltipElem .text(el.attr('title')) .style('left', window.scrollX + rect.left + (rect.right - rect.left) / 2 + 'px') .style('top', window.scrollY + rect.bottom + 'px'); tooltipElem.html(tooltipElem.html().replace(/<br\/>/g, '
')); el.classed('hover', true); }) .on('mouseout', function () { // @ts-ignore TODO: fix this tooltipElem.transition().duration(500).style('opacity', 0); const el = select(this); el.classed('hover', false); }); }; funs.push(setupToolTips); /** * Clears the internal graph db so that a new graph can be parsed. * */ export const clear = function (ver = 'gen-1') { vertices = new Map(); classes = new Map(); edges = []; funs = [setupToolTips]; subGraphs = []; subGraphLookup = new Map(); subCount = 0; tooltips = new Map(); firstGraphFlag = true; version = ver; config = getConfig(); commonClear(); }; export const setGen = (ver: string) => { version = ver || 'gen-2'; }; export const defaultStyle = function () { return 'fill:#ffa;stroke: #f66; stroke-width: 3px; stroke-dasharray: 5, 5;fill:#ffa;stroke: #666;'; }; export const addSubGraph = function ( _id: { text: string }, list: string[], _title: { text: string; type: string } ) { let id: string | undefined = _id.text.trim(); let title = _title.text; if (_id === _title && /\s/.exec(_title.text)) { id = undefined; } function uniq(a: any[]) { const prims: any = { boolean: {}, number: {}, string: {} }; const objs: any[] = []; let dir; // = undefined; direction.trim(); const nodeList = a.filter(function (item) { const type = typeof item; if (item.stmt && item.stmt === 'dir') { dir = item.value; return false; } if (item.trim() === '') { return false; } if (type in prims) { return prims[type].hasOwnProperty(item) ? false : (prims[type][item] = true); } else { return objs.includes(item) ? false : objs.push(item); } }); return { nodeList, dir }; } const { nodeList, dir } = uniq(list.flat()); if (version === 'gen-1') { for (let i = 0; i < nodeList.length; i++) { nodeList[i] = lookUpDomId(nodeList[i]); } } id = id ?? 'subGraph' + subCount; title = title || ''; title = sanitizeText(title); subCount = subCount + 1; const subGraph = { id: id, nodes: nodeList, title: title.trim(), classes: [], dir, labelType: _title.type, }; log.info('Adding', subGraph.id, subGraph.nodes, subGraph.dir); // Remove the members in the new subgraph if they already belong to another subgraph subGraph.nodes = makeUniq(subGraph, subGraphs).nodes; subGraphs.push(subGraph); subGraphLookup.set(id, subGraph); return id; }; const getPosForId = function (id: string) { for (const [i, subGraph] of subGraphs.entries()) { if (subGraph.id === id) { return i; } } return -1; }; let secCount = -1; const posCrossRef: number[] = []; const indexNodes2 = function (id: string, pos: number): { result: boolean; count: number } { const nodes = subGraphs[pos].nodes; secCount = secCount + 1; if (secCount > 2000) { return { result: false, count: 0, }; } posCrossRef[secCount] = pos; // Check if match if (subGraphs[pos].id === id) { return { result: true, count: 0, }; } let count = 0; let posCount = 1; while (count < nodes.length) { const childPos = getPosForId(nodes[count]); // Ignore regular nodes (pos will be -1) if (childPos >= 0) { const res = indexNodes2(id, childPos); if (res.result) { return { result: true, count: posCount + res.count, }; } else { posCount = posCount + res.count; } } count = count + 1; } return { result: false, count: posCount, }; }; export const getDepthFirstPos = function (pos: number) { return posCrossRef[pos]; }; export const indexNodes = function () { secCount = -1; if (subGraphs.length > 0) { indexNodes2('none', subGraphs.length - 1); } }; export const getSubGraphs = function () { return subGraphs; }; export const firstGraph = () => { if (firstGraphFlag) { firstGraphFlag = false; return true; } return false; }; const destructStartLink = (_str: string): FlowLink => { let str = _str.trim(); let type = 'arrow_open'; switch (str[0]) { case '<': type = 'arrow_point'; str = str.slice(1); break; case 'x': type = 'arrow_cross'; str = str.slice(1); break; case 'o': type = 'arrow_circle'; str = str.slice(1); break; } let stroke = 'normal'; if (str.includes('=')) { stroke = 'thick'; } if (str.includes('.')) { stroke = 'dotted'; } return { type, stroke }; }; const countChar = (char: string, str: string) => { const length = str.length; let count = 0; for (let i = 0; i < length; ++i) { if (str[i] === char) { ++count; } } return count; }; const destructEndLink = (_str: string) => { const str = _str.trim(); let line = str.slice(0, -1); let type = 'arrow_open'; switch (str.slice(-1)) { case 'x': type = 'arrow_cross'; if (str.startsWith('x')) { type = 'double_' + type; line = line.slice(1); } break; case '>': type = 'arrow_point'; if (str.startsWith('<')) { type = 'double_' + type; line = line.slice(1); } break; case 'o': type = 'arrow_circle'; if (str.startsWith('o')) { type = 'double_' + type; line = line.slice(1); } break; } let stroke = 'normal'; let length = line.length - 1; if (line.startsWith('=')) { stroke = 'thick'; } if (line.startsWith('~')) { stroke = 'invisible'; } const dots = countChar('.', line); if (dots) { stroke = 'dotted'; length = dots; } return { type, stroke, length }; }; export const destructLink = (_str: string, _startStr: string) => { const info = destructEndLink(_str); let startInfo; if (_startStr) { startInfo = destructStartLink(_startStr); if (startInfo.stroke !== info.stroke) { return { type: 'INVALID', stroke: 'INVALID' }; } if (startInfo.type === 'arrow_open') { // -- xyz --> - take arrow type from ending startInfo.type = info.type; } else { // x-- xyz --> - not supported if (startInfo.type !== info.type) { return { type: 'INVALID', stroke: 'INVALID' }; } startInfo.type = 'double_' + startInfo.type; } if (startInfo.type === 'double_arrow') { startInfo.type = 'double_arrow_point'; } startInfo.length = info.length; return startInfo; } return info; }; // Todo optimizer this by caching existing nodes const exists = (allSgs: FlowSubGraph[], _id: string) => { for (const sg of allSgs) { if (sg.nodes.includes(_id)) { return true; } } return false; }; /** * Deletes an id from all subgraphs * */ const makeUniq = (sg: FlowSubGraph, allSubgraphs: FlowSubGraph[]) => { const res: string[] = []; sg.nodes.forEach((_id, pos) => { if (!exists(allSubgraphs, _id)) { res.push(sg.nodes[pos]); } }); return { nodes: res }; }; export const lex = { firstGraph, }; const getTypeFromVertex = (vertex: FlowVertex) => { if (vertex.type === 'square') { return 'squareRect'; } if (vertex.type === 'round') { return 'roundedRect'; } return vertex.type ?? 'squareRect'; }; const findNode = (nodes: Node[], id: string) => nodes.find((node) => node.id === id); const destructEdgeType = (type: string | undefined) => { let arrowTypeStart = 'none'; let arrowTypeEnd = 'arrow_point'; switch (type) { case 'arrow_point': case 'arrow_circle': case 'arrow_cross': arrowTypeEnd = type; break; case 'double_arrow_point': case 'double_arrow_circle': case 'double_arrow_cross': arrowTypeStart = type.replace('double_', ''); arrowTypeEnd = arrowTypeStart; break; } return { arrowTypeStart, arrowTypeEnd }; }; const addNodeFromVertex = ( vertex: FlowVertex, nodes: Node[], parentDB: Map, subGraphDB: Map, config: any, look: string ) => { const parentId = parentDB.get(vertex.id); const isGroup = subGraphDB.get(vertex.id) ?? false; const node = findNode(nodes, vertex.id); if (node) { node.cssStyles = vertex.styles; node.cssCompiledStyles = getCompiledStyles(vertex.classes); node.cssClasses = vertex.classes.join(' '); } else { nodes.push({ id: vertex.id, label: vertex.text, labelStyle: '', parentId, padding: config.flowchart?.padding || 8, cssStyles: vertex.styles, cssCompiledStyles: getCompiledStyles(['default', 'node', ...vertex.classes]), cssClasses: 'default ' + vertex.classes.join(' '), shape: getTypeFromVertex(vertex), dir: vertex.dir, domId: vertex.domId, isGroup, look, link: vertex.link, linkTarget: vertex.linkTarget, tooltip: getTooltip(vertex.id), }); } }; function getCompiledStyles(classDefs: string[]) { let compiledStyles: string[] = []; for (const customClass of classDefs) { const cssClass = classes.get(customClass); if (cssClass?.styles) { compiledStyles = [...compiledStyles, ...(cssClass.styles ?? [])].map((s) => s.trim()); } if (cssClass?.textStyles) { compiledStyles = [...compiledStyles, ...(cssClass.textStyles ?? [])].map((s) => s.trim()); } } return compiledStyles; } export const getData = () => { const config = getConfig(); const nodes: Node[] = []; const edges: Edge[] = []; const subGraphs = getSubGraphs(); const parentDB = new Map(); const subGraphDB = new Map(); // Setup the subgraph data for adding nodes for (let i = subGraphs.length - 1; i >= 0; i--) { const subGraph = subGraphs[i]; if (subGraph.nodes.length > 0) { subGraphDB.set(subGraph.id, true); } for (const id of subGraph.nodes) { parentDB.set(id, subGraph.id); } } // Data is setup, add the nodes for (let i = subGraphs.length - 1; i >= 0; i--) { const subGraph = subGraphs[i]; nodes.push({ id: subGraph.id, label: subGraph.title, labelStyle: '', parentId: parentDB.get(subGraph.id), padding: 8, cssCompiledStyles: getCompiledStyles(subGraph.classes), cssClasses: subGraph.classes.join(' '), shape: 'rect', dir: subGraph.dir, isGroup: true, look: config.look, }); } const n = getVertices(); n.forEach((vertex) => { addNodeFromVertex(vertex, nodes, parentDB, subGraphDB, config, config.look || 'classic'); }); const e = getEdges(); e.forEach((rawEdge, index) => { const { arrowTypeStart, arrowTypeEnd } = destructEdgeType(rawEdge.type); const styles = [...(e.defaultStyle ?? [])]; if (rawEdge.style) { styles.push(...rawEdge.style); } const edge: Edge = { id: getEdgeId(rawEdge.start, rawEdge.end, { counter: index, prefix: 'L' }), start: rawEdge.start, end: rawEdge.end, type: rawEdge.type ?? 'normal', label: rawEdge.text, labelpos: 'c', thickness: rawEdge.stroke, minlen: rawEdge.length, classes: rawEdge?.stroke === 'invisible' ? '' : 'edge-thickness-normal edge-pattern-solid flowchart-link', arrowTypeStart: rawEdge?.stroke === 'invisible' ? 'none' : arrowTypeStart, arrowTypeEnd: rawEdge?.stroke === 'invisible' ? 'none' : arrowTypeEnd, arrowheadStyle: 'fill: #333', labelStyle: styles, style: styles, pattern: rawEdge.stroke, look: config.look, }; edges.push(edge); }); return { nodes, edges, other: {}, config }; }; export default { defaultConfig: () => defaultConfig.flowchart, setAccTitle, getAccTitle, getAccDescription, getData, setAccDescription, addVertex, lookUpDomId, addLink, updateLinkInterpolate, updateLink, addClass, setDirection, setClass, setTooltip, getTooltip, setClickEvent, setLink, bindFunctions, getDirection, getVertices, getEdges, getClasses, clear, setGen, defaultStyle, addSubGraph, getDepthFirstPos, indexNodes, getSubGraphs, destructLink, lex, exists, makeUniq, setDiagramTitle, getDiagramTitle, };