diff --git a/.gitignore b/.gitignore index 69f442484..58579d79b 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,5 @@ dist/classTest.html dist/sequenceTest.html .vscode/ -cypress/platform/current.html \ No newline at end of file +cypress/platform/current.html +cypress/platform/experimental.html \ No newline at end of file diff --git a/cypress/platform/current.html b/cypress/platform/current.html index 3ed964921..2f5ec32b4 100644 --- a/cypress/platform/current.html +++ b/cypress/platform/current.html @@ -20,11 +20,8 @@

info below

- stateDiagram - O --> A : ong line using
should work
should work
should work - A --> B : ong line using
should work - B --> C : Sing line - + flowchart LR + A --> B
diff --git a/src/diagrams/flowchart-v2/flowChartShapes.js b/src/diagrams/flowchart-v2/flowChartShapes.js new file mode 100644 index 000000000..23cb53049 --- /dev/null +++ b/src/diagrams/flowchart-v2/flowChartShapes.js @@ -0,0 +1,261 @@ +import dagreD3 from 'dagre-d3'; + +function question(parent, bbox, node) { + const w = bbox.width; + const h = bbox.height; + const s = (w + h) * 0.9; + const points = [ + { x: s / 2, y: 0 }, + { x: s, y: -s / 2 }, + { x: s / 2, y: -s }, + { x: 0, y: -s / 2 } + ]; + const shapeSvg = insertPolygonShape(parent, s, s, points); + node.intersect = function(point) { + return dagreD3.intersect.polygon(node, points, point); + }; + return shapeSvg; +} + +function hexagon(parent, bbox, node) { + const f = 4; + const h = bbox.height; + const m = h / f; + const w = bbox.width + 2 * m; + const points = [ + { x: m, y: 0 }, + { x: w - m, y: 0 }, + { x: w, y: -h / 2 }, + { x: w - m, y: -h }, + { x: m, y: -h }, + { x: 0, y: -h / 2 } + ]; + const shapeSvg = insertPolygonShape(parent, w, h, points); + node.intersect = function(point) { + return dagreD3.intersect.polygon(node, points, point); + }; + return shapeSvg; +} + +function rect_left_inv_arrow(parent, bbox, node) { + const w = bbox.width; + const h = bbox.height; + const points = [ + { x: -h / 2, y: 0 }, + { x: w, y: 0 }, + { x: w, y: -h }, + { x: -h / 2, y: -h }, + { x: 0, y: -h / 2 } + ]; + const shapeSvg = insertPolygonShape(parent, w, h, points); + node.intersect = function(point) { + return dagreD3.intersect.polygon(node, points, point); + }; + return shapeSvg; +} + +function lean_right(parent, bbox, node) { + const w = bbox.width; + const h = bbox.height; + const points = [ + { x: (-2 * h) / 6, y: 0 }, + { x: w - h / 6, y: 0 }, + { x: w + (2 * h) / 6, y: -h }, + { x: h / 6, y: -h } + ]; + const shapeSvg = insertPolygonShape(parent, w, h, points); + node.intersect = function(point) { + return dagreD3.intersect.polygon(node, points, point); + }; + return shapeSvg; +} + +function lean_left(parent, bbox, node) { + const w = bbox.width; + const h = bbox.height; + const points = [ + { x: (2 * h) / 6, y: 0 }, + { x: w + h / 6, y: 0 }, + { x: w - (2 * h) / 6, y: -h }, + { x: -h / 6, y: -h } + ]; + const shapeSvg = insertPolygonShape(parent, w, h, points); + node.intersect = function(point) { + return dagreD3.intersect.polygon(node, points, point); + }; + return shapeSvg; +} + +function trapezoid(parent, bbox, node) { + const w = bbox.width; + const h = bbox.height; + const points = [ + { x: (-2 * h) / 6, y: 0 }, + { x: w + (2 * h) / 6, y: 0 }, + { x: w - h / 6, y: -h }, + { x: h / 6, y: -h } + ]; + const shapeSvg = insertPolygonShape(parent, w, h, points); + node.intersect = function(point) { + return dagreD3.intersect.polygon(node, points, point); + }; + return shapeSvg; +} + +function inv_trapezoid(parent, bbox, node) { + const w = bbox.width; + const h = bbox.height; + const points = [ + { x: h / 6, y: 0 }, + { x: w - h / 6, y: 0 }, + { x: w + (2 * h) / 6, y: -h }, + { x: (-2 * h) / 6, y: -h } + ]; + const shapeSvg = insertPolygonShape(parent, w, h, points); + node.intersect = function(point) { + return dagreD3.intersect.polygon(node, points, point); + }; + return shapeSvg; +} + +function rect_right_inv_arrow(parent, bbox, node) { + const w = bbox.width; + const h = bbox.height; + const points = [ + { x: 0, y: 0 }, + { x: w + h / 2, y: 0 }, + { x: w, y: -h / 2 }, + { x: w + h / 2, y: -h }, + { x: 0, y: -h } + ]; + const shapeSvg = insertPolygonShape(parent, w, h, points); + node.intersect = function(point) { + return dagreD3.intersect.polygon(node, points, point); + }; + return shapeSvg; +} + +function stadium(parent, bbox, node) { + const h = bbox.height; + const w = bbox.width + h / 4; + + const shapeSvg = parent + .insert('rect', ':first-child') + .attr('rx', h / 2) + .attr('ry', h / 2) + .attr('x', -w / 2) + .attr('y', -h / 2) + .attr('width', w) + .attr('height', h); + + node.intersect = function(point) { + return dagreD3.intersect.rect(node, point); + }; + return shapeSvg; +} + +function cylinder(parent, bbox, node) { + const w = bbox.width; + const rx = w / 2; + const ry = rx / (2.5 + w / 50); + const h = bbox.height + ry; + + const shape = + 'M 0,' + + ry + + ' a ' + + rx + + ',' + + ry + + ' 0,0,0 ' + + w + + ' 0 a ' + + rx + + ',' + + ry + + ' 0,0,0 ' + + -w + + ' 0 l 0,' + + h + + ' a ' + + rx + + ',' + + ry + + ' 0,0,0 ' + + w + + ' 0 l 0,' + + -h; + + const shapeSvg = parent + .attr('label-offset-y', ry) + .insert('path', ':first-child') + .attr('d', shape) + .attr('transform', 'translate(' + -w / 2 + ',' + -(h / 2 + ry) + ')'); + + node.intersect = function(point) { + const pos = dagreD3.intersect.rect(node, point); + const x = pos.x - node.x; + + if ( + rx != 0 && + (Math.abs(x) < node.width / 2 || + (Math.abs(x) == node.width / 2 && Math.abs(pos.y - node.y) > node.height / 2 - ry)) + ) { + // ellipsis equation: x*x / a*a + y*y / b*b = 1 + // solve for y to get adjustion value for pos.y + let y = ry * ry * (1 - (x * x) / (rx * rx)); + if (y != 0) y = Math.sqrt(y); + y = ry - y; + if (point.y - node.y > 0) y = -y; + + pos.y += y; + } + + return pos; + }; + + return shapeSvg; +} + +export function addToRender(render) { + render.shapes().question = question; + render.shapes().hexagon = hexagon; + render.shapes().stadium = stadium; + render.shapes().cylinder = cylinder; + + // Add custom shape for box with inverted arrow on left side + render.shapes().rect_left_inv_arrow = rect_left_inv_arrow; + + // Add custom shape for box with inverted arrow on left side + render.shapes().lean_right = lean_right; + + // Add custom shape for box with inverted arrow on left side + render.shapes().lean_left = lean_left; + + // Add custom shape for box with inverted arrow on left side + render.shapes().trapezoid = trapezoid; + + // Add custom shape for box with inverted arrow on left side + render.shapes().inv_trapezoid = inv_trapezoid; + + // Add custom shape for box with inverted arrow on right side + render.shapes().rect_right_inv_arrow = rect_right_inv_arrow; +} + +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('transform', 'translate(' + -w / 2 + ',' + h / 2 + ')'); +} + +export default { + addToRender +}; diff --git a/src/diagrams/flowchart-v2/flowChartShapes.spec.js b/src/diagrams/flowchart-v2/flowChartShapes.spec.js new file mode 100644 index 000000000..61e876d4b --- /dev/null +++ b/src/diagrams/flowchart-v2/flowChartShapes.spec.js @@ -0,0 +1,131 @@ +import { addToRender } from './flowChartShapes'; + +describe('flowchart shapes', function() { + // rect-based shapes + [ + ['stadium', useWidth, useHeight] + ].forEach(function([shapeType, getW, getH]) { + it(`should add a ${shapeType} shape that renders a properly positioned rect element`, function() { + const mockRender = MockRender(); + const mockSvg = MockSvg(); + addToRender(mockRender); + + [[100, 100], [123, 45], [71, 300]].forEach(function([width, height]) { + const shape = mockRender.shapes()[shapeType](mockSvg, { width, height }, {}); + const w = width + height / 4; + const h = height; + const dx = -getW(w, h) / 2; + const dy = -getH(w, h) / 2; + expect(shape.__tag).toEqual('rect'); + expect(shape.__attrs).toHaveProperty('x', dx); + expect(shape.__attrs).toHaveProperty('y', dy); + }); + }); + }); + + // path-based shapes + [ + ['cylinder', useWidth, useHeight] + ].forEach(function([shapeType, getW, getH]) { + it(`should add a ${shapeType} shape that renders a properly positioned path element`, function() { + const mockRender = MockRender(); + const mockSvg = MockSvg(); + addToRender(mockRender); + + [[100, 100], [123, 45], [71, 300]].forEach(function([width, height]) { + const shape = mockRender.shapes()[shapeType](mockSvg, { width, height }, {}); + expect(shape.__tag).toEqual('path'); + expect(shape.__attrs).toHaveProperty('d'); + }); + }); + }); + + // polygon-based shapes + [ + [ + 'question', + 4, + function(w, h) { + return (w + h) * 0.9; + }, + function(w, h) { + return (w + h) * 0.9; + } + ], + [ + 'hexagon', + 6, + function(w, h) { + return w + h / 2; + }, + useHeight + ], + ['rect_left_inv_arrow', 5, useWidth, useHeight], + ['rect_right_inv_arrow', 5, useWidth, useHeight], + ['lean_right', 4, useWidth, useHeight], + ['lean_left', 4, useWidth, useHeight], + ['trapezoid', 4, useWidth, useHeight], + ['inv_trapezoid', 4, useWidth, useHeight] + ].forEach(function([shapeType, expectedPointCount, getW, getH]) { + it(`should add a ${shapeType} shape that renders a properly translated polygon element`, function() { + const mockRender = MockRender(); + const mockSvg = MockSvg(); + addToRender(mockRender); + + [[100, 100], [123, 45], [71, 300]].forEach(function([width, height]) { + const shape = mockRender.shapes()[shapeType](mockSvg, { width, height }, {}); + const dx = -getW(width, height) / 2; + const dy = getH(width, height) / 2; + const points = shape.__attrs.points.split(' '); + expect(shape.__tag).toEqual('polygon'); + expect(shape.__attrs).toHaveProperty('transform', `translate(${dx},${dy})`); + expect(points).toHaveLength(expectedPointCount); + }); + }); + }); +}); + +function MockRender() { + const shapes = {}; + return { + shapes() { + return shapes; + } + }; +} + +function MockSvg(tag, ...args) { + const children = []; + const attributes = {}; + return { + get __args() { + return args; + }, + get __tag() { + return tag; + }, + get __children() { + return children; + }, + get __attrs() { + return attributes; + }, + insert: function(tag, ...args) { + const child = MockSvg(tag, ...args); + children.push(child); + return child; + }, + attr(name, value) { + this.__attrs[name] = value; + return this; + } + }; +} + +function useWidth(w, h) { + return w; +} + +function useHeight(w, h) { + return h; +} diff --git a/src/diagrams/flowchart-v2/flowDb.js b/src/diagrams/flowchart-v2/flowDb.js new file mode 100644 index 000000000..4917a54a7 --- /dev/null +++ b/src/diagrams/flowchart-v2/flowDb.js @@ -0,0 +1,644 @@ +import * as d3 from 'd3'; +import { logger } from '../../logger'; +import utils from '../../utils'; +import { getConfig } from '../../config'; +import common from '../common/common'; + +// const MERMAID_DOM_ID_PREFIX = 'mermaid-dom-id-'; +const MERMAID_DOM_ID_PREFIX = ''; + +const config = getConfig(); +let vertices = {}; +let edges = []; +let classes = []; +let subGraphs = []; +let subGraphLookup = {}; +let tooltips = {}; +let subCount = 0; +let firstGraphFlag = true; +let direction; +// Functions to be run after graph rendering +let funs = []; + +/** + * Function called by parser when a node definition has been found + * @param id + * @param text + * @param type + * @param style + * @param classes + */ +export const addVertex = function(_id, text, type, style, classes) { + let txt; + let id = _id; + if (typeof id === 'undefined') { + return; + } + if (id.trim().length === 0) { + return; + } + + if (id[0].match(/\d/)) id = MERMAID_DOM_ID_PREFIX + id; + + if (typeof vertices[id] === 'undefined') { + vertices[id] = { id: id, styles: [], classes: [] }; + } + if (typeof text !== 'undefined') { + txt = common.sanitizeText(text.trim(), config); + + // strip quotes if string starts and ends with a quote + if (txt[0] === '"' && txt[txt.length - 1] === '"') { + txt = txt.substring(1, txt.length - 1); + } + + vertices[id].text = txt; + } else { + if (typeof vertices[id].text === 'undefined') { + vertices[id].text = _id; + } + } + if (typeof type !== 'undefined') { + vertices[id].type = type; + } + if (typeof style !== 'undefined') { + if (style !== null) { + style.forEach(function(s) { + vertices[id].styles.push(s); + }); + } + } + if (typeof classes !== 'undefined') { + if (classes !== null) { + classes.forEach(function(s) { + vertices[id].classes.push(s); + }); + } + } +}; + +/** + * Function called by parser when a link/edge definition has been found + * @param start + * @param end + * @param type + * @param linktext + */ +export const addSingleLink = function(_start, _end, type, linktext) { + let start = _start; + let end = _end; + if (start[0].match(/\d/)) start = MERMAID_DOM_ID_PREFIX + start; + if (end[0].match(/\d/)) end = MERMAID_DOM_ID_PREFIX + end; + logger.info('Got edge...', start, end); + + const edge = { start: start, end: end, type: undefined, text: '' }; + linktext = type.text; + + if (typeof linktext !== 'undefined') { + edge.text = common.sanitizeText(linktext.trim(), config); + + // strip quotes if string starts and exnds with a quote + if (edge.text[0] === '"' && edge.text[edge.text.length - 1] === '"') { + edge.text = edge.text.substring(1, edge.text.length - 1); + } + } + + if (typeof type !== 'undefined') { + edge.type = type.type; + edge.stroke = type.stroke; + } + edges.push(edge); +}; +export const addLink = function(_start, _end, type, linktext) { + let i, j; + for (i = 0; i < _start.length; i++) { + for (j = 0; j < _end.length; j++) { + addSingleLink(_start[i], _end[j], type, linktext); + } + } +}; + +/** + * Updates a link's line interpolation algorithm + * @param pos + * @param interpolate + */ +export const updateLinkInterpolate = function(positions, interp) { + positions.forEach(function(pos) { + if (pos === 'default') { + edges.defaultInterpolate = interp; + } else { + edges[pos].interpolate = interp; + } + }); +}; + +/** + * Updates a link with a style + * @param pos + * @param style + */ +export const updateLink = function(positions, style) { + positions.forEach(function(pos) { + if (pos === 'default') { + edges.defaultStyle = style; + } else { + if (utils.isSubstringInArray('fill', style) === -1) { + style.push('fill:none'); + } + edges[pos].style = style; + } + }); +}; + +export const addClass = function(id, style) { + if (typeof classes[id] === 'undefined') { + classes[id] = { id: id, styles: [], textStyles: [] }; + } + + if (typeof style !== 'undefined') { + if (style !== null) { + style.forEach(function(s) { + if (s.match('color')) { + const newStyle1 = s.replace('fill', 'bgFill'); + const newStyle2 = newStyle1.replace('color', 'fill'); + classes[id].textStyles.push(newStyle2); + } + classes[id].styles.push(s); + }); + } + } +}; + +/** + * Called by parser when a graph definition is found, stores the direction of the chart. + * @param dir + */ +export const setDirection = function(dir) { + direction = dir; + if (direction.match(/.*/)) { + direction = 'LR'; + } + if (direction.match(/.*v/)) { + 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, className) { + ids.split(',').forEach(function(_id) { + let id = _id; + if (_id[0].match(/\d/)) id = MERMAID_DOM_ID_PREFIX + id; + if (typeof vertices[id] !== 'undefined') { + vertices[id].classes.push(className); + } + + if (typeof subGraphLookup[id] !== 'undefined') { + subGraphLookup[id].classes.push(className); + } + }); +}; + +const setTooltip = function(ids, tooltip) { + ids.split(',').forEach(function(id) { + if (typeof tooltip !== 'undefined') { + tooltips[id] = common.sanitizeText(tooltip, config); + } + }); +}; + +const setClickFun = function(_id, functionName) { + let id = _id; + if (_id[0].match(/\d/)) id = MERMAID_DOM_ID_PREFIX + id; + if (config.securityLevel !== 'loose') { + return; + } + if (typeof functionName === 'undefined') { + return; + } + if (typeof vertices[id] !== 'undefined') { + funs.push(function() { + const elem = document.querySelector(`[id="${id}"]`); + if (elem !== null) { + elem.addEventListener( + 'click', + function() { + window[functionName](id); + }, + 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 tooltip Tooltip for the clickable element + */ +export const setLink = function(ids, linkStr, tooltip) { + ids.split(',').forEach(function(_id) { + let id = _id; + if (_id[0].match(/\d/)) id = MERMAID_DOM_ID_PREFIX + id; + if (typeof vertices[id] !== 'undefined') { + vertices[id].link = utils.formatUrl(linkStr, config); + } + }); + setTooltip(ids, tooltip); + setClass(ids, 'clickable'); +}; +export const getTooltip = function(id) { + return tooltips[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 tooltip Tooltip for the clickable element + */ +export const setClickEvent = function(ids, functionName, tooltip) { + ids.split(',').forEach(function(id) { + setClickFun(id, functionName); + }); + setTooltip(ids, tooltip); + setClass(ids, 'clickable'); +}; + +export const bindFunctions = function(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. + * @returns {{}|*|vertices} + */ +export const getVertices = function() { + return vertices; +}; + +/** + * Retrieval function for fetching the found links after parsing has completed. + * @returns {{}|*|edges} + */ +export const getEdges = function() { + return edges; +}; + +/** + * Retrieval function for fetching the found class definitions after parsing has completed. + * @returns {{}|*|classes} + */ +export const getClasses = function() { + return classes; +}; + +const setupToolTips = function(element) { + let tooltipElem = d3.select('.mermaidTooltip'); + if ((tooltipElem._groups || tooltipElem)[0][0] === null) { + tooltipElem = d3 + .select('body') + .append('div') + .attr('class', 'mermaidTooltip') + .style('opacity', 0); + } + + const svg = d3.select(element).select('svg'); + + const nodes = svg.selectAll('g.node'); + nodes + .on('mouseover', function() { + const el = d3.select(this); + const title = el.attr('title'); + // Dont try to draw a tooltip if no data is provided + if (title === null) { + return; + } + const rect = this.getBoundingClientRect(); + + tooltipElem + .transition() + .duration(200) + .style('opacity', '.9'); + tooltipElem + .html(el.attr('title')) + .style('left', rect.left + (rect.right - rect.left) / 2 + 'px') + .style('top', rect.top - 14 + document.body.scrollTop + 'px'); + el.classed('hover', true); + }) + .on('mouseout', function() { + tooltipElem + .transition() + .duration(500) + .style('opacity', 0); + const el = d3.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() { + vertices = {}; + classes = {}; + edges = []; + funs = []; + funs.push(setupToolTips); + subGraphs = []; + subGraphLookup = {}; + subCount = 0; + tooltips = []; + firstGraphFlag = true; +}; +/** + * + * @returns {string} + */ +export const defaultStyle = function() { + return 'fill:#ffa;stroke: #f66; stroke-width: 3px; stroke-dasharray: 5, 5;fill:#ffa;stroke: #666;'; +}; + +/** + * Clears the internal graph db so that a new graph can be parsed. + */ +export const addSubGraph = function(_id, list, _title) { + let id = _id.trim(); + let title = _title; + if (_id === _title && _title.match(/\s/)) { + id = undefined; + } + function uniq(a) { + const prims = { boolean: {}, number: {}, string: {} }; + const objs = []; + + return a.filter(function(item) { + const type = typeof item; + if (item.trim() === '') { + return false; + } + if (type in prims) { + return prims[type].hasOwnProperty(item) ? false : (prims[type][item] = true); // eslint-disable-line + } else { + return objs.indexOf(item) >= 0 ? false : objs.push(item); + } + }); + } + + let nodeList = []; + + nodeList = uniq(nodeList.concat.apply(nodeList, list)); + for (let i = 0; i < nodeList.length; i++) { + if (nodeList[i][0].match(/\d/)) nodeList[i] = MERMAID_DOM_ID_PREFIX + nodeList[i]; + } + + id = id || 'subGraph' + subCount; + if (id[0].match(/\d/)) id = MERMAID_DOM_ID_PREFIX + id; + title = title || ''; + title = common.sanitizeText(title, config); + subCount = subCount + 1; + const subGraph = { id: id, nodes: nodeList, title: title.trim(), classes: [] }; + subGraphs.push(subGraph); + subGraphLookup[id] = subGraph; + return id; +}; + +const getPosForId = function(id) { + for (let i = 0; i < subGraphs.length; i++) { + if (subGraphs[i].id === id) { + return i; + } + } + return -1; +}; +let secCount = -1; +const posCrossRef = []; +const indexNodes2 = function(id, pos) { + const nodes = subGraphs[pos].nodes; + secCount = secCount + 1; + if (secCount > 2000) { + return; + } + 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) { + return posCrossRef[pos]; +}; +export const indexNodes = function() { + secCount = -1; + if (subGraphs.length > 0) { + indexNodes2('none', subGraphs.length - 1, 0); + } +}; + +export const getSubGraphs = function() { + return subGraphs; +}; + +export const firstGraph = () => { + if (firstGraphFlag) { + firstGraphFlag = false; + return true; + } + return false; +}; + +const destructStartLink = _str => { + const str = _str.trim(); + + switch (str) { + case '<--': + return { type: 'arrow', stroke: 'normal' }; + case 'x--': + return { type: 'arrow_cross', stroke: 'normal' }; + case 'o--': + return { type: 'arrow_circle', stroke: 'normal' }; + case '<-.': + return { type: 'arrow', stroke: 'dotted' }; + case 'x-.': + return { type: 'arrow_cross', stroke: 'dotted' }; + case 'o-.': + return { type: 'arrow_circle', stroke: 'dotted' }; + case '<==': + return { type: 'arrow', stroke: 'thick' }; + case 'x==': + return { type: 'arrow_cross', stroke: 'thick' }; + case 'o==': + return { type: 'arrow_circle', stroke: 'thick' }; + case '--': + return { type: 'arrow_open', stroke: 'normal' }; + case '==': + return { type: 'arrow_open', stroke: 'thick' }; + case '-.': + return { type: 'arrow_open', stroke: 'dotted' }; + } +}; + +const destructEndLink = _str => { + const str = _str.trim(); + + switch (str) { + case '--x': + return { type: 'arrow_cross', stroke: 'normal' }; + case '-->': + return { type: 'arrow', stroke: 'normal' }; + case '<-->': + return { type: 'double_arrow_point', stroke: 'normal' }; + case 'x--x': + return { type: 'double_arrow_cross', stroke: 'normal' }; + case 'o--o': + return { type: 'double_arrow_circle', stroke: 'normal' }; + case 'o.-o': + return { type: 'double_arrow_circle', stroke: 'dotted' }; + case '<==>': + return { type: 'double_arrow_point', stroke: 'thick' }; + case 'o==o': + return { type: 'double_arrow_circle', stroke: 'thick' }; + case 'x==x': + return { type: 'double_arrow_cross', stroke: 'thick' }; + case 'x.-x': + return { type: 'double_arrow_cross', stroke: 'dotted' }; + case 'x-.-x': + return { type: 'double_arrow_cross', stroke: 'dotted' }; + case '<.->': + return { type: 'double_arrow_point', stroke: 'dotted' }; + case '<-.->': + return { type: 'double_arrow_point', stroke: 'dotted' }; + case 'o-.-o': + return { type: 'double_arrow_circle', stroke: 'dotted' }; + case '--o': + return { type: 'arrow_circle', stroke: 'normal' }; + case '---': + return { type: 'arrow_open', stroke: 'normal' }; + case '-.-x': + return { type: 'arrow_cross', stroke: 'dotted' }; + case '-.->': + return { type: 'arrow', stroke: 'dotted' }; + case '-.-o': + return { type: 'arrow_circle', stroke: 'dotted' }; + case '-.-': + return { type: 'arrow_open', stroke: 'dotted' }; + case '.-x': + return { type: 'arrow_cross', stroke: 'dotted' }; + case '.->': + return { type: 'arrow', stroke: 'dotted' }; + case '.-o': + return { type: 'arrow_circle', stroke: 'dotted' }; + case '.-': + return { type: 'arrow_open', stroke: 'dotted' }; + case '==x': + return { type: 'arrow_cross', stroke: 'thick' }; + case '==>': + return { type: 'arrow', stroke: 'thick' }; + case '==o': + return { type: 'arrow_circle', stroke: 'thick' }; + case '===': + return { type: 'arrow_open', stroke: 'thick' }; + } +}; + +const destructLink = (_str, _startStr) => { + 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 form 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'; + } + + return startInfo; + } + + return info; +}; + +export default { + addVertex, + addLink, + updateLinkInterpolate, + updateLink, + addClass, + setDirection, + setClass, + getTooltip, + setClickEvent, + setLink, + bindFunctions, + getDirection, + getVertices, + getEdges, + getClasses, + clear, + defaultStyle, + addSubGraph, + getDepthFirstPos, + indexNodes, + getSubGraphs, + destructLink, + lex: { + firstGraph + } +}; diff --git a/src/diagrams/flowchart-v2/flowRenderer.js b/src/diagrams/flowchart-v2/flowRenderer.js new file mode 100644 index 000000000..10250a16c --- /dev/null +++ b/src/diagrams/flowchart-v2/flowRenderer.js @@ -0,0 +1,488 @@ +import graphlib from 'graphlib'; +import * as d3 from 'd3'; +import dagre from 'dagre'; + +import flowDb from '../flowchart/flowDb'; +import flow from '../flowchart/parser/flow'; +import { getConfig } from '../../config'; + +import dagreD3 from 'dagre-d3'; +import addHtmlLabel from 'dagre-d3/lib/label/add-html-label.js'; +import { logger } from '../../logger'; +import { interpolateToCurve, getStylesFromArray } from '../../utils'; +import flowChartShapes from '../flowchart/flowChartShapes'; + +const conf = {}; +export const setConf = function(cnf) { + const keys = Object.keys(cnf); + for (let i = 0; i < keys.length; i++) { + conf[keys[i]] = cnf[keys[i]]; + } +}; + +/** + * Function that adds the vertices found in the graph definition to the graph to be rendered. + * @param vert Object containing the vertices. + * @param g The graph that is to be drawn. + */ +export const addVertices = function(vert, g, svgId) { + const svg = d3.select(`[id="${svgId}"]`); + const keys = Object.keys(vert); + + // Iterate through each item in the vertex object (containing all the vertices found) in the graph definition + keys.forEach(function(id) { + const vertex = vert[id]; + + /** + * Variable for storing the classes for the vertex + * @type {string} + */ + let classStr = 'default'; + if (vertex.classes.length > 0) { + classStr = vertex.classes.join(' '); + } + + const styles = getStylesFromArray(vertex.styles); + + // Use vertex id as text in the box if no text is provided by the graph definition + let vertexText = vertex.text !== undefined ? vertex.text : vertex.id; + + // We create a SVG label, either by delegating to addHtmlLabel or manually + let vertexNode; + if (getConfig().flowchart.htmlLabels) { + // TODO: addHtmlLabel accepts a labelStyle. Do we possibly have that? + const node = { + label: vertexText.replace( + /fa[lrsb]?:fa-[\w-]+/g, + s => `` + ) + }; + vertexNode = addHtmlLabel(svg, node).node(); + vertexNode.parentNode.removeChild(vertexNode); + } else { + const svgLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + svgLabel.setAttribute('style', styles.labelStyle.replace('color:', 'fill:')); + + const rows = vertexText.split(//gi); + + for (let j = 0; j < rows.length; j++) { + 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', '1'); + tspan.textContent = rows[j]; + svgLabel.appendChild(tspan); + } + vertexNode = svgLabel; + } + + let radious = 0; + let _shape = ''; + // Set the shape based parameters + switch (vertex.type) { + case 'round': + radious = 5; + _shape = 'rect'; + break; + case 'square': + _shape = 'rect'; + break; + case 'diamond': + _shape = 'question'; + break; + case 'hexagon': + _shape = 'hexagon'; + break; + case 'odd': + _shape = 'rect_left_inv_arrow'; + break; + case 'lean_right': + _shape = 'lean_right'; + break; + case 'lean_left': + _shape = 'lean_left'; + break; + case 'trapezoid': + _shape = 'trapezoid'; + break; + case 'inv_trapezoid': + _shape = 'inv_trapezoid'; + break; + case 'odd_right': + _shape = 'rect_left_inv_arrow'; + break; + case 'circle': + _shape = 'circle'; + break; + case 'ellipse': + _shape = 'ellipse'; + break; + case 'stadium': + _shape = 'stadium'; + break; + case 'cylinder': + _shape = 'cylinder'; + break; + case 'group': + _shape = 'rect'; + break; + default: + _shape = 'rect'; + } + // Add the node + g.setNode(vertex.id, { + labelType: 'svg', + labelStyle: styles.labelStyle, + shape: _shape, + label: vertexNode, + rx: radious, + ry: radious, + class: classStr, + style: styles.style, + id: vertex.id + }); + }); +}; + +/** + * Add edges to graph based on parsed graph defninition + * @param {Object} edges The edges to add to the graph + * @param {Object} g The graph object + */ +export const addEdges = function(edges, g) { + let cnt = 0; + + let defaultStyle; + let defaultLabelStyle; + + if (typeof edges.defaultStyle !== 'undefined') { + const defaultStyles = getStylesFromArray(edges.defaultStyle); + defaultStyle = defaultStyles.style; + defaultLabelStyle = defaultStyles.labelStyle; + } + + edges.forEach(function(edge) { + cnt++; + const edgeData = {}; + + // Set link type for rendering + if (edge.type === 'arrow_open') { + edgeData.arrowhead = 'none'; + } else { + edgeData.arrowhead = 'normal'; + } + + let style = ''; + let labelStyle = ''; + + if (typeof edge.style !== 'undefined') { + const styles = getStylesFromArray(edge.style); + style = styles.style; + labelStyle = styles.labelStyle; + } else { + switch (edge.stroke) { + case 'normal': + style = 'fill:none'; + if (typeof defaultStyle !== 'undefined') { + style = defaultStyle; + } + if (typeof defaultLabelStyle !== 'undefined') { + labelStyle = defaultLabelStyle; + } + break; + case 'dotted': + style = 'fill:none;stroke-width:2px;stroke-dasharray:3;'; + break; + case 'thick': + style = ' stroke-width: 3.5px;fill:none'; + break; + } + } + + edgeData.style = style; + edgeData.labelStyle = labelStyle; + + if (typeof edge.interpolate !== 'undefined') { + edgeData.curve = interpolateToCurve(edge.interpolate, d3.curveLinear); + } else if (typeof edges.defaultInterpolate !== 'undefined') { + edgeData.curve = interpolateToCurve(edges.defaultInterpolate, d3.curveLinear); + } else { + edgeData.curve = interpolateToCurve(conf.curve, d3.curveLinear); + } + + if (typeof edge.text === 'undefined') { + if (typeof edge.style !== 'undefined') { + edgeData.arrowheadStyle = 'fill: #333'; + } + } else { + edgeData.arrowheadStyle = 'fill: #333'; + edgeData.labelpos = 'c'; + + if (getConfig().flowchart.htmlLabels) { + edgeData.labelType = 'html'; + edgeData.label = '' + edge.text + ''; + } else { + edgeData.labelType = 'text'; + edgeData.label = edge.text.replace(//gi, '\n'); + + if (typeof edge.style === 'undefined') { + edgeData.style = edgeData.style || 'stroke: #333; stroke-width: 1.5px;fill:none'; + } + + edgeData.labelStyle = edgeData.labelStyle.replace('color:', 'fill:'); + } + } + // Add the edge to the graph + g.setEdge(edge.start, edge.end, edgeData, cnt); + }); +}; + +/** + * Returns the all the styles from classDef statements in the graph definition. + * @returns {object} classDef styles + */ +export const getClasses = function(text) { + logger.info('Extracting classes'); + flowDb.clear(); + const parser = flow.parser; + parser.yy = flowDb; + + // Parse the graph definition + parser.parse(text); + return flowDb.getClasses(); +}; + +/** + * Draws a flowchart in the tag with id: id based on the graph definition in text. + * @param text + * @param id + */ +export const draw = function(text, id) { + logger.info('Drawing flowchart'); + flowDb.clear(); + const parser = flow.parser; + parser.yy = flowDb; + + // Parse the graph definition + try { + parser.parse(text); + } catch (err) { + logger.debug('Parsing failed'); + } + + // Fetch the default direction, use TD if none was found + let dir = flowDb.getDirection(); + if (typeof dir === 'undefined') { + dir = 'TD'; + } + + const conf = getConfig().flowchart; + const nodeSpacing = conf.nodeSpacing || 50; + const rankSpacing = conf.rankSpacing || 50; + + // Create the input mermaid.graph + const g = new graphlib.Graph({ + multigraph: true, + compound: true + }) + .setGraph({ + rankdir: dir, + nodesep: nodeSpacing, + ranksep: rankSpacing, + marginx: 8, + marginy: 8 + }) + .setDefaultEdgeLabel(function() { + return {}; + }); + + let subG; + const subGraphs = flowDb.getSubGraphs(); + for (let i = subGraphs.length - 1; i >= 0; i--) { + subG = subGraphs[i]; + flowDb.addVertex(subG.id, subG.title, 'group', undefined, subG.classes); + } + + // Fetch the verices/nodes and edges/links from the parsed graph definition + const vert = flowDb.getVertices(); + + const edges = flowDb.getEdges(); + + let i = 0; + for (i = subGraphs.length - 1; i >= 0; i--) { + subG = subGraphs[i]; + + d3.selectAll('cluster').append('text'); + + for (let j = 0; j < subG.nodes.length; j++) { + g.setParent(subG.nodes[j], subG.id); + } + } + addVertices(vert, g, id); + addEdges(edges, g); + + // Create the renderer + const Render = dagreD3.render; + const render = new Render(); + + // Add custom shapes + flowChartShapes.addToRender(render); + + // Add our custom arrow - an empty arrowhead + render.arrows().none = function normal(parent, id, edge, type) { + const marker = parent + .append('marker') + .attr('id', id) + .attr('viewBox', '0 0 10 10') + .attr('refX', 9) + .attr('refY', 5) + .attr('markerUnits', 'strokeWidth') + .attr('markerWidth', 8) + .attr('markerHeight', 6) + .attr('orient', 'auto'); + + const path = marker.append('path').attr('d', 'M 0 0 L 0 0 L 0 0 z'); + dagreD3.util.applyStyle(path, edge[type + 'Style']); + }; + + // Override normal arrowhead defined in d3. Remove style & add class to allow css styling. + render.arrows().normal = function normal(parent, id) { + const marker = parent + .append('marker') + .attr('id', id) + .attr('viewBox', '0 0 10 10') + .attr('refX', 9) + .attr('refY', 5) + .attr('markerUnits', 'strokeWidth') + .attr('markerWidth', 8) + .attr('markerHeight', 6) + .attr('orient', 'auto'); + + marker + .append('path') + .attr('d', 'M 0 0 L 10 5 L 0 10 z') + .attr('class', 'arrowheadPath') + .style('stroke-width', 1) + .style('stroke-dasharray', '1,0'); + }; + + // Set up an SVG group so that we can translate the final graph. + const svg = d3.select(`[id="${id}"]`); + + // Run the renderer. This is what draws the final graph. + const element = d3.select('#' + id + ' g'); + render(element, g); + + element.selectAll('g.node').attr('title', function() { + return flowDb.getTooltip(this.id); + }); + + const padding = 8; + const svgBounds = svg.node().getBBox(); + const width = svgBounds.width + padding * 2; + const height = svgBounds.height + padding * 2; + logger.debug( + `new ViewBox 0 0 ${width} ${height}`, + `translate(${padding - g._label.marginx}, ${padding - g._label.marginy})` + ); + + if (conf.useMaxWidth) { + svg.attr('width', '100%'); + svg.attr('style', `max-width: ${width}px;`); + } else { + svg.attr('height', height); + svg.attr('width', width); + } + + svg.attr('viewBox', `0 0 ${width} ${height}`); + svg + .select('g') + .attr('transform', `translate(${padding - g._label.marginx}, ${padding - svgBounds.y})`); + + // Index nodes + flowDb.indexNodes('subGraph' + i); + + // reposition labels + for (i = 0; i < subGraphs.length; i++) { + subG = subGraphs[i]; + + if (subG.title !== 'undefined') { + const clusterRects = document.querySelectorAll('#' + id + ' [id="' + subG.id + '"] rect'); + const clusterEl = document.querySelectorAll('#' + id + ' [id="' + subG.id + '"]'); + + const xPos = clusterRects[0].x.baseVal.value; + const yPos = clusterRects[0].y.baseVal.value; + const width = clusterRects[0].width.baseVal.value; + const cluster = d3.select(clusterEl[0]); + const te = cluster.select('.label'); + te.attr('transform', `translate(${xPos + width / 2}, ${yPos + 14})`); + te.attr('id', id + 'Text'); + + for (let j = 0; j < subG.classes.length; j++) { + clusterEl[0].classList.add(subG.classes[j]); + } + } + } + + // Add label rects for non html labels + if (!conf.htmlLabels) { + const labels = document.querySelectorAll('[id="' + id + '"] .edgeLabel .label'); + for (let k = 0; k < labels.length; k++) { + const label = labels[k]; + + // Get dimensions of label + const dim = label.getBBox(); + + const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + rect.setAttribute('rx', 0); + rect.setAttribute('ry', 0); + rect.setAttribute('width', dim.width); + rect.setAttribute('height', dim.height); + rect.setAttribute('style', 'fill:#e8e8e8;'); + + label.insertBefore(rect, label.firstChild); + } + } + + // If node has a link, wrap it in an anchor SVG object. + const keys = Object.keys(vert); + keys.forEach(function(key) { + const vertex = vert[key]; + + if (vertex.link) { + const node = d3.select('#' + id + ' [id="' + key + '"]'); + if (node) { + const link = document.createElementNS('http://www.w3.org/2000/svg', 'a'); + link.setAttributeNS('http://www.w3.org/2000/svg', 'class', vertex.classes.join(' ')); + link.setAttributeNS('http://www.w3.org/2000/svg', 'href', vertex.link); + link.setAttributeNS('http://www.w3.org/2000/svg', 'rel', 'noopener'); + + const linkNode = node.insert(function() { + return link; + }, ':first-child'); + + const shape = node.select('.label-container'); + if (shape) { + linkNode.append(function() { + return shape.node(); + }); + } + + const label = node.select('.label'); + if (label) { + linkNode.append(function() { + return label.node(); + }); + } + } + } + }); +}; + +export default { + setConf, + addVertices, + addEdges, + getClasses, + draw +}; diff --git a/src/diagrams/flowchart/parser/flow.jison b/src/diagrams/flowchart/parser/flow.jison index f867e5713..58e4664a6 100644 --- a/src/diagrams/flowchart/parser/flow.jison +++ b/src/diagrams/flowchart/parser/flow.jison @@ -21,7 +21,8 @@ "classDef" return 'CLASSDEF'; "class" return 'CLASS'; "click" return 'CLICK'; -"graph" {if(yy.lex.firstGraph()){this.begin("dir");} return 'GRAPH';} +"graph" {if(yy.lex.firstGraph()){this.begin("dir");} return 'GRAPH';} +"flowchart" {if(yy.lex.firstGraph()){this.begin("dir");} return 'GRAPH';} "subgraph" return 'subgraph'; "end"\b\s* return 'end'; \s*"LR" { this.popState(); return 'DIR'; } diff --git a/src/experimental.js b/src/experimental.js new file mode 100644 index 000000000..d34de469d --- /dev/null +++ b/src/experimental.js @@ -0,0 +1,42 @@ +import dagre from 'dagre'; +import graphlib from 'graphlib'; + +// Create a new directed graph +var g = new dagre.graphlib.Graph({ compound: true }); + +// Set an object for the graph label +g.setGraph({}); + +// Default to assigning a new object as a label for each new edge. +g.setDefaultEdgeLabel(function() { + return {}; +}); + +// Add nodes to the graph. The first argument is the node id. The second is +// metadata about the node. In this case we're going to add labels to each of +// our nodes. +g.setNode('root', { label: 'Cluster' }); +g.setNode('kspacey', { label: 'Kevin Spacey', width: 144, height: 100, x: 200 }); +// g.setParent('kspacey', 'root'); +g.setNode('swilliams', { label: 'Saul Williams', width: 160, height: 100 }); +// g.setNode('bpitt', { label: 'Brad Pitt', width: 108, height: 100 }); +// g.setNode('hford', { label: 'Harrison Ford', width: 168, height: 100 }); +// g.setNode('lwilson', { label: 'Luke Wilson', width: 144, height: 100 }); +// g.setNode('kbacon', { label: 'Kevin Bacon', width: 121, height: 100 }); + +// Add edges to the graph. +g.setEdge('kspacey', 'swilliams'); +g.setEdge('swilliams'); +// g.setEdge('swilliams', 'kbacon'); +// g.setEdge('bpitt', 'kbacon'); +// g.setEdge('hford', 'lwilson'); +// g.setEdge('lwilson', 'kbacon'); + +dagre.layout(g); + +g.nodes().forEach(function(v) { + console.log('Node ' + v + ': ' + JSON.stringify(g.node(v))); +}); +g.edges().forEach(function(e) { + console.log('Edge ' + e.v + ' -> ' + e.w + ': ' + JSON.stringify(g.edge(e))); +}); diff --git a/src/mermaid.js b/src/mermaid.js index 0cf24e81f..6f2311390 100644 --- a/src/mermaid.js +++ b/src/mermaid.js @@ -6,7 +6,6 @@ import he from 'he'; import mermaidAPI from './mermaidAPI'; import { logger } from './logger'; - /** * ## init * Function that goes through the document to find the chart definitions in there and render them. diff --git a/src/mermaidAPI.js b/src/mermaidAPI.js index 205254540..600938027 100644 --- a/src/mermaidAPI.js +++ b/src/mermaidAPI.js @@ -17,6 +17,7 @@ import { setConfig, getConfig } from './config'; import { logger, setLogLevel } from './logger'; import utils from './utils'; import flowRenderer from './diagrams/flowchart/flowRenderer'; +import flowRendererV2 from './diagrams/flowchart-v2/flowRenderer'; import flowParser from './diagrams/flowchart/parser/flow'; import flowDb from './diagrams/flowchart/flowDb'; import sequenceRenderer from './diagrams/sequence/sequenceRenderer'; @@ -363,6 +364,11 @@ function parse(text) { parser = flowParser; parser.parser.yy = flowDb; break; + case 'flowchart-v2': + flowDb.clear(); + parser = flowRendererV2; + parser.parser.yy = flowDb; + break; case 'sequence': parser = sequenceParser; parser.parser.yy = sequenceDb; @@ -568,6 +574,11 @@ const render = function(id, _txt, cb, container) { flowRenderer.setConf(config.flowchart); flowRenderer.draw(txt, id, false); break; + case 'flowchart-v2': + config.flowchart.arrowMarkerAbsolute = config.arrowMarkerAbsolute; + flowRendererV2.setConf(config.flowchart); + flowRendererV2.draw(txt, id, false); + break; case 'sequence': config.sequence.arrowMarkerAbsolute = config.arrowMarkerAbsolute; if (config.sequenceDiagram) { diff --git a/src/utils.js b/src/utils.js index 1aec62d4f..c6e37bb9d 100644 --- a/src/utils.js +++ b/src/utils.js @@ -41,6 +41,9 @@ export const detectType = function(text) { if (text.match(/^\s*gitGraph/)) { return 'git'; } + if (text.match(/^\s*flowchart/)) { + return 'flowchart-v2'; + } if (text.match(/^\s*info/)) { return 'info';