diff --git a/cypress/integration/rendering/stateDiagram.spec.js b/cypress/integration/rendering/stateDiagram.spec.js new file mode 100644 index 000000000..a696be5de --- /dev/null +++ b/cypress/integration/rendering/stateDiagram.spec.js @@ -0,0 +1,16 @@ +/* eslint-env jest */ +import { imgSnapshotTest } from '../../helpers/util'; + +describe('State diagram', () => { + it('should render a simple state diagrams', () => { + imgSnapshotTest( + ` + stateDiagram + [*] --> State1 + State1 --> [*] + `, + { logLevel: 0 } + ); + cy.get('svg'); + }); +}); diff --git a/src/diagrams/state/stateDb.js b/src/diagrams/state/stateDb.js index 94ec0e21e..8eb1a30e1 100644 --- a/src/diagrams/state/stateDb.js +++ b/src/diagrams/state/stateDb.js @@ -1,7 +1,7 @@ import { logger } from '../../logger'; let relations = []; -let classes = {}; +let states = {}; /** * Function called by parser when a node definition has been found. @@ -10,26 +10,25 @@ let classes = {}; * @param type * @param style */ -export const addClass = function(id) { - if (typeof classes[id] === 'undefined') { - classes[id] = { +export const addState = function(id) { + if (typeof states[id] === 'undefined') { + states[id] = { id: id, - methods: [], - members: [] + descriptions: [] }; } }; export const clear = function() { relations = []; - classes = {}; + states = {}; }; -export const getClass = function(id) { - return classes[id]; +export const getState = function(id) { + return states[id]; }; -export const getClasses = function() { - return classes; +export const getstates = function() { + return states; }; export const getRelations = function() { @@ -38,18 +37,18 @@ export const getRelations = function() { export const addRelation = function(relation) { logger.debug('Adding relation: ' + JSON.stringify(relation)); - addClass(relation.id1); - addClass(relation.id2); + addState(relation.id1); + addState(relation.id2); relations.push(relation); }; export const addMember = function(className, member) { - const theClass = classes[className]; + const theState = states[className]; if (typeof member === 'string') { if (member.substr(-1) === ')') { - theClass.methods.push(member); + theState.methods.push(member); } else { - theClass.members.push(member); + theState.members.push(member); } } }; @@ -81,10 +80,10 @@ export const relationType = { }; export default { - addClass, + addState, clear, - getClass, - getClasses, + getState, + getstates, getRelations, addRelation, addMember, diff --git a/src/diagrams/state/stateRenderer.js b/src/diagrams/state/stateRenderer.js index 6c6646ec5..c40e902f8 100644 --- a/src/diagrams/state/stateRenderer.js +++ b/src/diagrams/state/stateRenderer.js @@ -10,6 +10,9 @@ parser.yy = stateDb; const idCache = {}; let stateCnt = 0; +let total = 0; +let edgeCount = 0; + const conf = { dividerMargin: 10, padding: 5, @@ -17,6 +20,363 @@ const conf = { }; export const setConf = function(cnf) {}; + +// Todo optimize +const getGraphId = function(label) { + const keys = Object.keys(idCache); + + for (let i = 0; i < keys.length; i++) { + if (idCache[keys[i]].label === label) { + return keys[i]; + } + } + + return undefined; +}; + +/** + * Setup arrow head and define the marker. The result is appended to the svg. + */ +const insertMarkers = function(elem) { + elem + .append('defs') + .append('marker') + .attr('id', 'extensionStart') + .attr('class', 'extension') + .attr('refX', 0) + .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', 'extensionEnd') + .attr('refX', 19) + .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 + + elem + .append('defs') + .append('marker') + .attr('id', 'compositionStart') + .attr('class', 'extension') + .attr('refX', 0) + .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', 'compositionEnd') + .attr('refX', 19) + .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'); + + elem + .append('defs') + .append('marker') + .attr('id', 'aggregationStart') + .attr('class', 'extension') + .attr('refX', 0) + .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', 'aggregationEnd') + .attr('refX', 19) + .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'); + + elem + .append('defs') + .append('marker') + .attr('id', 'dependencyStart') + .attr('class', 'extension') + .attr('refX', 0) + .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', 'dependencyEnd') + .attr('refX', 19) + .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 drawStart = function(elem, stateDef) { + logger.info('Rendering class ' + stateDef); + + const addTspan = function(textEl, txt, isFirst) { + const tSpan = textEl + .append('tspan') + .attr('x', conf.padding) + .text(txt); + if (!isFirst) { + tSpan.attr('dy', conf.textHeight); + } + }; + + const id = 'classId' + (stateCnt % total); + const stateInfo = { + id: id, + label: stateDef.id, + width: 0, + height: 0 + }; + + const g = elem + .append('g') + .attr('id', id) + .attr('class', 'classGroup'); + const title = g + .append('text') + .attr('x', conf.padding) + .attr('y', conf.textHeight + conf.padding) + .text(stateDef.id); + + const titleHeight = title.node().getBBox().height; + + const stateBox = g.node().getBBox(); + g.insert('rect', ':first-child') + .attr('x', 0) + .attr('y', 0) + .attr('width', stateBox.width + 2 * conf.padding) + .attr('height', stateBox.height + conf.padding + 0.5 * conf.dividerMargin); + + membersLine.attr('x2', stateBox.width + 2 * conf.padding); + methodsLine.attr('x2', stateBox.width + 2 * conf.padding); + + stateInfo.width = stateBox.width + 2 * conf.padding; + stateInfo.height = stateBox.height + conf.padding + 0.5 * conf.dividerMargin; + + idCache[id] = stateInfo; + stateCnt++; + return stateInfo; +}; + +/** + * Draws a start state as a black circle + */ +const drawStartState = g => + g + .append('circle') + .style('stroke', 'black') + .style('fill', 'black') + .attr('r', 5) + .attr('cx', conf.padding + 5) + .attr('cy', conf.padding + 5); +/** + * Draws a an end state as a black circle + */ +const drawSimpleState = (g, stateDef) => { + const state = g + .append('text') + .attr('x', 2 * conf.padding) + .attr('y', conf.textHeight + 2 * conf.padding) + .attr('font-size', 24) + .text(stateDef.id); + + const classBox = state.node().getBBox(); + g.insert('rect', ':first-child') + .attr('x', conf.padding) + .attr('y', conf.padding) + .attr('width', classBox.width + 2 * conf.padding) + .attr('height', classBox.height + 2 * conf.padding) + .attr('rx', '5'); + + return state; +}; +const drawEndState = g => { + g.append('circle') + .style('stroke', 'black') + .style('fill', 'white') + .attr('r', 7) + .attr('cx', conf.padding + 7) + .attr('cy', conf.padding + 7); + + return g + .append('circle') + .style('stroke', 'black') + .style('fill', 'black') + .attr('r', 5) + .attr('cx', conf.padding + 7) + .attr('cy', conf.padding + 7); +}; + +const drawEdge = function(elem, path, relation) { + const getRelationType = function(type) { + switch (type) { + case stateDb.relationType.AGGREGATION: + return 'aggregation'; + case stateDb.relationType.EXTENSION: + return 'extension'; + case stateDb.relationType.COMPOSITION: + return 'composition'; + case stateDb.relationType.DEPENDENCY: + return 'dependency'; + } + }; + + path.points = path.points.filter(p => !Number.isNaN(p.y)); + + // The data for our line + const lineData = path.points; + + // This is the accessor function we talked about above + const lineFunction = d3 + .line() + .x(function(d) { + return d.x; + }) + .y(function(d) { + return d.y; + }) + .curve(d3.curveBasis); + + const svgPath = elem + .append('path') + .attr('d', lineFunction(lineData)) + .attr('id', 'edge' + edgeCount) + .attr('class', 'relation'); + let url = ''; + if (conf.arrowMarkerAbsolute) { + url = + window.location.protocol + + '//' + + window.location.host + + window.location.pathname + + window.location.search; + url = url.replace(/\(/g, '\\('); + url = url.replace(/\)/g, '\\)'); + } + + svgPath.attr( + 'marker-start', + 'url(' + url + '#' + getRelationType(stateDb.relationType.DEPENDENCY) + 'Start' + ')' + ); + svgPath.attr( + 'marker-end', + 'url(' + url + '#' + getRelationType(stateDb.relationType.DEPENDENCY) + 'End' + ')' + ); + + let x, y; + const l = path.points.length; + if (l % 2 !== 0 && l > 1) { + const p1 = path.points[Math.floor(l / 2)]; + const p2 = path.points[Math.ceil(l / 2)]; + x = (p1.x + p2.x) / 2; + y = (p1.y + p2.y) / 2; + } else { + const p = path.points[Math.floor(l / 2)]; + x = p.x; + y = p.y; + } + + if (typeof relation.title !== 'undefined') { + const g = elem.append('g').attr('class', 'classLabel'); + const label = g + .append('text') + .attr('class', 'label') + .attr('x', x) + .attr('y', y) + .attr('fill', 'red') + .attr('text-anchor', 'middle') + .text(relation.title); + + window.label = label; + const bounds = label.node().getBBox(); + + g.insert('rect', ':first-child') + .attr('class', 'box') + .attr('x', bounds.x - conf.padding / 2) + .attr('y', bounds.y - conf.padding / 2) + .attr('width', bounds.width + conf.padding) + .attr('height', bounds.height + conf.padding); + } + + edgeCount++; +}; + +/** + * Draws a state + * @param {*} elem + * @param {*} stateDef + */ +const drawState = function(elem, stateDef) { + logger.info('Rendering class ' + stateDef); + + const addTspan = function(textEl, txt, isFirst) { + const tSpan = textEl + .append('tspan') + .attr('x', conf.padding) + .text(txt); + if (!isFirst) { + tSpan.attr('dy', conf.textHeight); + } + }; + + const id = stateDef.id; + const stateInfo = { + id: id, + label: stateDef.id, + width: 0, + height: 0 + }; + + const g = elem + .append('g') + .attr('id', id) + .attr('class', 'classGroup'); + + if (stateDef.type === 'start') drawStartState(g); + if (stateDef.type === 'end') drawEndState(g); + if (stateDef.type === 'default') drawSimpleState(g, stateDef); + + const stateBox = g.node().getBBox(); + + stateInfo.width = stateBox.width + 2 * conf.padding; + stateInfo.height = stateBox.height + 2 * conf.padding; + + idCache[id] = stateInfo; + stateCnt++; + return stateInfo; +}; + /** * Draws a flowchart in the tag with id: id based on the graph definition in text. * @param text @@ -25,74 +385,88 @@ export const setConf = function(cnf) {}; export const draw = function(text, id) { parser.yy.clear(); parser.parse(text); - logger.info('Rendering diagram ' + text); // /// / Fetch the default direction, use TD if none was found - // const diagram = d3.select(`[id='${id}']`); - // insertMarkers(diagram); + const diagram = d3.select(`[id='${id}']`); + insertMarkers(diagram); // // Layout graph, Create a new directed graph - // const g = new graphlib.Graph({ - // multigraph: true - // }); + const graph = new graphlib.Graph({ + multigraph: false + }); // // Set an object for the graph label - // g.setGraph({ - // isMultiGraph: true - // }); + graph.setGraph({ + isMultiGraph: false + }); // // Default to assigning a new object as a label for each new edge. - // g.setDefaultEdgeLabel(function() { - // return {}; - // }); + graph.setDefaultEdgeLabel(function() { + return {}; + }); - // const classes = classDb.getClasses(); - // const keys = Object.keys(classes); - // total = keys.length; - // for (let i = 0; i < keys.length; i++) { - // const classDef = classes[keys[i]]; - // const node = drawClass(diagram, classDef); - // // 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(node.id, node); - // logger.info('Org height: ' + node.height); - // } + // const states = stateDb.getStates(); + const states = { + start1: { + id: 'start1', + type: 'start' + }, + state1: { + id: 'state1', + type: 'default' + }, + exit: { + id: 'exit1', + type: 'end' + } + }; + const keys = Object.keys(states); + total = keys.length; + for (let i = 0; i < keys.length; i++) { + const stateDef = states[keys[i]]; + const node = drawState(diagram, stateDef); + // 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. + graph.setNode(node.id, node); + logger.info('Org height: ' + node.height); + } - // const relations = classDb.getRelations(); - // relations.forEach(function(relation) { - // logger.info( - // 'tjoho' + getGraphId(relation.id1) + getGraphId(relation.id2) + JSON.stringify(relation) - // ); - // g.setEdge(getGraphId(relation.id1), getGraphId(relation.id2), { - // relation: relation - // }); - // }); - // dagre.layout(g); - // g.nodes().forEach(function(v) { - // if (typeof v !== 'undefined' && typeof g.node(v) !== 'undefined') { - // logger.debug('Node ' + v + ': ' + JSON.stringify(g.node(v))); - // d3.select('#' + v).attr( - // 'transform', - // 'translate(' + - // (g.node(v).x - g.node(v).width / 2) + - // ',' + - // (g.node(v).y - g.node(v).height / 2) + - // ' )' - // ); - // } - // }); - // g.edges().forEach(function(e) { - // if (typeof e !== 'undefined' && typeof g.edge(e) !== 'undefined') { - // logger.debug('Edge ' + e.v + ' -> ' + e.w + ': ' + JSON.stringify(g.edge(e))); - // drawEdge(diagram, g.edge(e), g.edge(e).relation); - // } - // }); + // const relations = stateDb.getRelations(); + const relations = [{ id1: 'start1', id2: 'state1' }, { id1: 'state1', id2: 'exit1' }]; + relations.forEach(function(relation) { + logger.info( + 'tjoho' + getGraphId(relation.id1) + getGraphId(relation.id2) + JSON.stringify(relation) + ); + graph.setEdge(getGraphId(relation.id1), getGraphId(relation.id2), { + relation: relation + }); + }); + dagre.layout(graph); + graph.nodes().forEach(function(v) { + if (typeof v !== 'undefined' && typeof graph.node(v) !== 'undefined') { + logger.debug('Node ' + v + ': ' + JSON.stringify(graph.node(v))); + d3.select('#' + v).attr( + 'transform', + 'translate(' + + (graph.node(v).x - graph.node(v).width / 2) + + ',' + + (graph.node(v).y - graph.node(v).height / 2) + + ' )' + ); + } + }); + graph.edges().forEach(function(e) { + if (typeof e !== 'undefined' && typeof graph.edge(e) !== 'undefined') { + logger.debug('Edge ' + e.v + ' -> ' + e.w + ': ' + JSON.stringify(graph.edge(e))); + drawEdge(diagram, graph.edge(e), graph.edge(e).relation); + } + }); - // diagram.attr('height', '100%'); - // diagram.attr('width', '100%'); - // diagram.attr('viewBox', '0 0 ' + (g.graph().width + 20) + ' ' + (g.graph().height + 20)); + diagram.attr('height', '100%'); + diagram.attr('width', '100%'); + diagram.attr('viewBox', '0 0 ' + (graph.graph().width + 20) + ' ' + (graph.graph().height + 20)); }; export default { diff --git a/src/mermaidAPI.js b/src/mermaidAPI.js index e6305d727..9fa0901a4 100644 --- a/src/mermaidAPI.js +++ b/src/mermaidAPI.js @@ -28,6 +28,9 @@ import ganttDb from './diagrams/gantt/ganttDb'; import classRenderer from './diagrams/class/classRenderer'; import classParser from './diagrams/class/parser/classDiagram'; import classDb from './diagrams/class/classDb'; +import stateRenderer from './diagrams/state/stateRenderer'; +import stateParser from './diagrams/state/parser/stateDiagram'; +import stateDb from './diagrams/state/stateDb'; import gitGraphRenderer from './diagrams/git/gitGraphRenderer'; import gitGraphParser from './diagrams/git/parser/gitGraph'; import gitGraphAst from './diagrams/git/gitGraphAst'; @@ -332,6 +335,10 @@ function parse(text) { parser = classParser; parser.parser.yy = classDb; break; + case 'state': + parser = stateParser; + parser.parser.yy = stateDb; + break; case 'info': logger.debug('info info info'); console.warn('In API', pkg.version); @@ -522,6 +529,11 @@ const render = function(id, txt, cb, container) { classRenderer.setConf(config.class); classRenderer.draw(txt, id); break; + case 'state': + // config.class.arrowMarkerAbsolute = config.arrowMarkerAbsolute; + stateRenderer.setConf(config.state); + stateRenderer.draw(txt, id); + break; case 'info': config.class.arrowMarkerAbsolute = config.arrowMarkerAbsolute; infoRenderer.setConf(config.class); diff --git a/src/utils.js b/src/utils.js index 357865fbf..080c54c96 100644 --- a/src/utils.js +++ b/src/utils.js @@ -33,6 +33,10 @@ export const detectType = function(text) { return 'class'; } + if (text.match(/^\s*stateDiagram/)) { + return 'state'; + } + if (text.match(/^\s*gitGraph/)) { return 'git'; }