diff --git a/cypress/integration/rendering/classDiagram.spec.js b/cypress/integration/rendering/classDiagram.spec.js index 9c0b51581..d1f290436 100644 --- a/cypress/integration/rendering/classDiagram.spec.js +++ b/cypress/integration/rendering/classDiagram.spec.js @@ -30,4 +30,32 @@ describe('Class diagram', () => { ); cy.get('svg'); }); + it('should render a simple class diagrams with cardinality', () => { + imgSnapshotTest( + ` + classDiagram + Class01 "1" <|--|> "*" AveryLongClass : Cool + <<interface>> Class01 + Class03 "1" *-- "*" Class04 + Class05 "1" o-- "many" Class06 + Class07 "1" .. "*" Class08 + Class09 "1" --> "*" C2 : Where am i? + Class09 "*" --* "*" C3 + Class09 "1" --|> "1" Class07 + Class07 : equals() + Class07 : Object[] elementData + Class01 : size() + Class01 : int chimp + Class01 : int gorilla + Class08 "1" <--> "*" C2: Cool label + class Class10 { + <<service>> + int id + test() + } + `, + {} + ); + cy.get('svg'); + }); }); diff --git a/cypress/integration/rendering/stateDiagram.spec.js b/cypress/integration/rendering/stateDiagram.spec.js new file mode 100644 index 000000000..6ae7cbe4e --- /dev/null +++ b/cypress/integration/rendering/stateDiagram.spec.js @@ -0,0 +1,57 @@ +/* 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'); + }); + it('should render a simple state diagrams', () => { + imgSnapshotTest( + ` + stateDiagram + [*] --> State1 + State1 --> State2 + State1 --> State3 + State1 --> [*] + `, + { logLevel: 0 } + ); + cy.get('svg'); + }); + it('should render a simple state diagrams with labels', () => { + imgSnapshotTest( + ` + stateDiagram + [*] --> State1 + State1 --> State2 : Transition 1 + State1 --> State3 : Transition 2 + State1 --> State4 : Transition 3 + State1 --> State5 : Transition 4 + State2 --> State3 : Transition 5 + State1 --> [*] + `, + { logLevel: 0 } + ); + cy.get('svg'); + }); + it('should render state descriptions', () => { + imgSnapshotTest( + ` + stateDiagram + state "Long state description" as XState1 + state "Another Long state description" as XState2 + XState2 : New line + `, + { logLevel: 0 } + ); + cy.get('svg'); + }); +}); diff --git a/src/diagrams/class/classRenderer.js b/src/diagrams/class/classRenderer.js index 2d0825edc..60a88d2c8 100644 --- a/src/diagrams/class/classRenderer.js +++ b/src/diagrams/class/classRenderer.js @@ -3,6 +3,7 @@ import dagre from 'dagre-layout'; import graphlib from 'graphlibrary'; import { logger } from '../../logger'; import classDb from './classDb'; +import utils from '../../utils'; import { parser } from './parser/classDiagram'; parser.yy = classDb; @@ -198,15 +199,38 @@ const drawEdge = function(elem, path, relation) { let x, y; const l = path.points.length; + // Calculate Label position + let labalPosition = utils.calcLabelPosition(path.points); + x = labalPosition.x; + y = labalPosition.y; + + let p1_card_x, + p1_card_y, + p1_card_padd_x = conf.padding * 2, + p1_card_padd_y = conf.padding; + let p2_card_x, + p2_card_y, + p2_card_padd_x = conf.padding * 2, + p2_card_padd_y = -conf.padding / 2; 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; + let cardinality_1_point = utils.calcCardinalityPosition( + relation.relation.type1 !== 'none', + path.points, + path.points[0] + ); + let cardinality_2_point = utils.calcCardinalityPosition( + relation.relation.type2 !== 'none', + path.points, + path.points[l - 1] + ); + + logger.debug('cardinality_1_point ' + JSON.stringify(cardinality_1_point)); + logger.debug('cardinality_2_point ' + JSON.stringify(cardinality_2_point)); + + p1_card_x = cardinality_1_point.x; + p1_card_y = cardinality_1_point.y; + p2_card_x = cardinality_2_point.x; + p2_card_y = cardinality_2_point.y; } if (typeof relation.title !== 'undefined') { @@ -231,6 +255,30 @@ const drawEdge = function(elem, path, relation) { .attr('height', bounds.height + conf.padding); } + logger.info('Rendering relation ' + JSON.stringify(relation)); + if (typeof relation.relationTitle1 !== 'undefined' && relation.relationTitle1 !== 'none') { + const g = elem.append('g').attr('class', 'cardinality'); + const label = g + .append('text') + .attr('class', 'type1') + .attr('x', p1_card_x) + .attr('y', p1_card_y) + .attr('fill', 'black') + .attr('font-size', '6') + .text(relation.relationTitle1); + } + if (typeof relation.relationTitle2 !== 'undefined' && relation.relationTitle2 !== 'none') { + const g = elem.append('g').attr('class', 'cardinality'); + const label = g + .append('text') + .attr('class', 'type2') + .attr('x', p2_card_x) + .attr('y', p2_card_y) + .attr('fill', 'black') + .attr('font-size', '6') + .text(relation.relationTitle2); + } + edgeCount++; }; diff --git a/src/diagrams/state/parser/stateDiagram.jison b/src/diagrams/state/parser/stateDiagram.jison new file mode 100644 index 000000000..c54ccd357 --- /dev/null +++ b/src/diagrams/state/parser/stateDiagram.jison @@ -0,0 +1,240 @@ +/** mermaid + * https://mermaidjs.github.io/ + * (c) 2014-2015 Knut Sveidqvist + * MIT license. + * + * Based on js sequence diagrams jison grammr + * http://bramp.github.io/js-sequence-diagrams/ + * (c) 2012-2013 Andrew Brampton (bramp.net) + * Simplified BSD license. + */ +%lex + +%options case-insensitive + +// Special states for recognizing aliases +%x ID +%x STATE +%x FORK_STATE +%x STATE_STRING +%x STATE_ID +%x ALIAS +%x SCALE +%x NOTE +%x NOTE_ID +%x NOTE_TEXT +%x FLOATING_NOTE +%x FLOATING_NOTE_ID +%x struct + +// A special state for grabbing text up to the first comment/newline +%x LINE + +%% + +[\n]+ return 'NL'; +\s+ /* skip all whitespace */ +((?!\n)\s)+ /* skip same-line whitespace */ +\#[^\n]* /* skip comments */ +\%%[^\n]* /* skip comments */ + +"scale"\s+ { this.pushState('SCALE'); console.log('Got scale', yytext);return 'scale'; } +\d+ return 'WIDTH'; +\s+"width" {this.popState();} + +"state"\s+ { this.pushState('STATE'); } +.*"<>" {this.popState();console.log('Fork: ',yytext);return 'FORK';} +.*"<>" {this.popState();console.log('Join: ',yytext);return 'JOIN';} +["] this.begin("STATE_STRING"); +"as"\s* {this.popState();this.pushState('STATE_ID');return "AS";} +[^\n\{]* {this.popState();console.log('STATE_ID', yytext);return "ID";} +["] this.popState(); +[^"]* { console.log('Long description:', yytext);return "STATE_DESCR";} +[^\n\s\{]+ {console.log('COMPOSIT_STATE', yytext);return 'COMPOSIT_STATE';} +\n {this.popState();} +\{ {this.popState();this.pushState('struct'); console.log('begin struct', yytext);return 'STRUCT_START';} +\} { console.log('Ending struct'); this.popState(); return 'STRUCT_STOP';}} +[\n] /* nothing */ + +"note"\s+ { this.begin('NOTE'); return 'note'; } +"left of" { this.popState();this.pushState('NOTE_ID');console.log('Got dir');return 'left_of';} +"right of" { this.popState();this.pushState('NOTE_ID');return 'right_of';} +\" { this.popState();this.pushState('FLOATING_NOTE');} +\s*"as"\s* {this.popState();this.pushState('FLOATING_NOTE_ID');return "AS";} +["] /**/ +[^"]* { console.log('Floating note text: ', yytext);return "NOTE_TEXT";} +[^\n]* {this.popState();console.log('Floating note ID', yytext);return "ID";} +\s*[^:\n\s\-]+ { this.popState();this.pushState('NOTE_TEXT');console.log('Got ID for note', yytext);return 'ID';} +\s*":"[^\+\-:\n,;]+ { this.popState();console.log('Got NOTE_TEXT for note',yytext);return 'NOTE_TEXT';} +\s*[^\+\-:,;]+"end note" { this.popState();console.log('Got NOTE_TEXT for note',yytext);return 'NOTE_TEXT';} + +"stateDiagram"\s+ { console.log('Got state diagram', yytext,'#');return 'SD'; } +"hide empty description" { console.log('HIDE_EMPTY', yytext,'#');return 'HIDE_EMPTY'; } +"[*]" { console.log('EDGE_STATE=',yytext); return 'EDGE_STATE';} +[^:\n\s\-\{]+ { console.log('=>ID=',yytext); return 'ID';} +\s*":"[^\+\->:\n,;]+ { yytext = yytext.trim(); console.log('Descr = ', yytext); return 'DESCR'; } +"-->" return '-->'; +"--" return 'CONCURRENT'; +<> return 'NL'; +. return 'INVALID'; + +/lex + +%left '^' + +%start start + +%% /* language grammar */ + +start + : SPACE start + | NL start + | SD document { return $2; } + ; + +document + : /* empty */ { $$ = [] } + | document line {$1.push($2);$$ = $1} + ; + +line + : SPACE statement { console.log('here');$$ = $2 } + | statement {console.log('line', $1); $$ = $1 } + | NL { $$=[];} + ; + +statement + : idStatement DESCR {yy.addState($1, 'default');yy.addDescription($1, $2.trim());} + | idStatement '-->' idStatement {yy.addRelation($1, $3);} + | idStatement '-->' idStatement DESCR {yy.addRelation($1, $3, $4.substr(1).trim());} + | HIDE_EMPTY + | scale WIDTH + | COMPOSIT_STATE + | COMPOSIT_STATE STRUCT_START document STRUCT_STOP + | STATE_DESCR AS ID {yy.addState($3, 'default');yy.addDescription($3, $1);} + | STATE_DESCR AS ID STRUCT_START document STRUCT_STOP + | FORK + | JOIN + | CONCURRENT + | note notePosition ID NOTE_TEXT + | note NOTE_TEXT AS ID + ; + +idStatement + : ID {$$=$1;} + | EDGE_STATE {$$=$1;} + ; + +notePosition + : left_of + | right_of + ; +// statement +// : 'participant' actor 'AS' restOfLine 'NL' {$2.description=$4; $$=$2;} +// | 'participant' actor 'NL' {$$=$2;} +// | signal 'NL' +// | 'activate' actor 'NL' {$$={type: 'activeStart', signalType: yy.LINETYPE.ACTIVE_START, actor: $2};} +// | 'deactivate' actor 'NL' {$$={type: 'activeEnd', signalType: yy.LINETYPE.ACTIVE_END, actor: $2};} +// | note_statement 'NL' +// | title text2 'NL' {$$=[{type:'setTitle', text:$2}]} +// | 'loop' restOfLine document end +// { +// $3.unshift({type: 'loopStart', loopText:$2, signalType: yy.LINETYPE.LOOP_START}); +// $3.push({type: 'loopEnd', loopText:$2, signalType: yy.LINETYPE.LOOP_END}); +// $$=$3;} +// | 'rect' restOfLine document end +// { +// $3.unshift({type: 'rectStart', color:$2, signalType: yy.LINETYPE.RECT_START }); +// $3.push({type: 'rectEnd', color:$2, signalType: yy.LINETYPE.RECT_END }); +// $$=$3;} +// | opt restOfLine document end +// { +// $3.unshift({type: 'optStart', optText:$2, signalType: yy.LINETYPE.OPT_START}); +// $3.push({type: 'optEnd', optText:$2, signalType: yy.LINETYPE.OPT_END}); +// $$=$3;} +// | alt restOfLine else_sections end +// { +// // Alt start +// $3.unshift({type: 'altStart', altText:$2, signalType: yy.LINETYPE.ALT_START}); +// // Content in alt is already in $3 +// // End +// $3.push({type: 'altEnd', signalType: yy.LINETYPE.ALT_END}); +// $$=$3;} +// | par restOfLine par_sections end +// { +// // Parallel start +// $3.unshift({type: 'parStart', parText:$2, signalType: yy.LINETYPE.PAR_START}); +// // Content in par is already in $3 +// // End +// $3.push({type: 'parEnd', signalType: yy.LINETYPE.PAR_END}); +// $$=$3;} +// ; + +// par_sections +// : document +// | document and restOfLine par_sections +// { $$ = $1.concat([{type: 'and', parText:$3, signalType: yy.LINETYPE.PAR_AND}, $4]); } +// ; + +// else_sections +// : document +// | document else restOfLine else_sections +// { $$ = $1.concat([{type: 'else', altText:$3, signalType: yy.LINETYPE.ALT_ELSE}, $4]); } +// ; + +// note_statement +// : 'note' placement actor text2 +// { +// $$ = [$3, {type:'addNote', placement:$2, actor:$3.actor, text:$4}];} +// | 'note' 'over' actor_pair text2 +// { +// // Coerce actor_pair into a [to, from, ...] array +// $2 = [].concat($3, $3).slice(0, 2); +// $2[0] = $2[0].actor; +// $2[1] = $2[1].actor; +// $$ = [$3, {type:'addNote', placement:yy.PLACEMENT.OVER, actor:$2.slice(0, 2), text:$4}];} +// ; + +// spaceList +// : SPACE spaceList +// | SPACE +// ; +// actor_pair +// : actor ',' actor { $$ = [$1, $3]; } +// | actor { $$ = $1; } +// ; + +// placement +// : 'left_of' { $$ = yy.PLACEMENT.LEFTOF; } +// | 'right_of' { $$ = yy.PLACEMENT.RIGHTOF; } +// ; + +// signal +// : actor signaltype '+' actor text2 +// { $$ = [$1,$4,{type: 'addMessage', from:$1.actor, to:$4.actor, signalType:$2, msg:$5}, +// {type: 'activeStart', signalType: yy.LINETYPE.ACTIVE_START, actor: $4} +// ]} +// | actor signaltype '-' actor text2 +// { $$ = [$1,$4,{type: 'addMessage', from:$1.actor, to:$4.actor, signalType:$2, msg:$5}, +// {type: 'activeEnd', signalType: yy.LINETYPE.ACTIVE_END, actor: $1} +// ]} +// | actor signaltype actor text2 +// { $$ = [$1,$3,{type: 'addMessage', from:$1.actor, to:$3.actor, signalType:$2, msg:$4}]} +// ; + +// actor +// : ACTOR {$$={type: 'addActor', actor:$1}} +// ; + +// signaltype +// : SOLID_OPEN_ARROW { $$ = yy.LINETYPE.SOLID_OPEN; } +// | DOTTED_OPEN_ARROW { $$ = yy.LINETYPE.DOTTED_OPEN; } +// | SOLID_ARROW { $$ = yy.LINETYPE.SOLID; } +// | DOTTED_ARROW { $$ = yy.LINETYPE.DOTTED; } +// | SOLID_CROSS { $$ = yy.LINETYPE.SOLID_CROSS; } +// | DOTTED_CROSS { $$ = yy.LINETYPE.DOTTED_CROSS; } +// ; + +// text2: TXT {$$ = $1.substring(1).trim().replace(/\\n/gm, "\n");} ; + +%% diff --git a/src/diagrams/state/stateDb.js b/src/diagrams/state/stateDb.js new file mode 100644 index 000000000..5a24f5680 --- /dev/null +++ b/src/diagrams/state/stateDb.js @@ -0,0 +1,113 @@ +import { logger } from '../../logger'; + +let relations = []; +let states = {}; + +let startCnt = 0; +let endCnt = 0; + +/** + * Function called by parser when a node definition has been found. + * @param id + * @param text + * @param type + * @param style + */ +export const addState = function(id, type) { + if (typeof states[id] === 'undefined') { + states[id] = { + id: id, + descriptions: [], + type + }; + } +}; + +export const clear = function() { + relations = []; + states = {}; +}; + +export const getState = function(id) { + return states[id]; +}; +export const getStates = function() { + return states; +}; + +export const getRelations = function() { + // const relations1 = [{ id1: 'start1', id2: 'state1' }, { id1: 'state1', id2: 'exit1' }]; + // return relations; + return relations; +}; + +export const addRelation = function(_id1, _id2, title) { + let id1 = _id1; + let id2 = _id2; + let type1 = 'default'; + let type2 = 'default'; + if (_id1 === '[*]') { + startCnt++; + id1 = 'start' + startCnt; + type1 = 'start'; + } + if (_id2 === '[*]') { + endCnt++; + id2 = 'end' + startCnt; + type2 = 'end'; + } + console.log(id1, id2, title); + addState(id1, type1); + addState(id2, type2); + relations.push({ id1, id2, title }); +}; + +export const addDescription = function(id, _descr) { + const theState = states[id]; + let descr = _descr; + if (descr[0] === ':') { + descr = descr.substr(1).trim(); + } + + theState.descriptions.push(descr); +}; + +export const addMembers = function(className, MembersArr) { + if (Array.isArray(MembersArr)) { + MembersArr.forEach(member => addMember(className, member)); + } +}; + +export const cleanupLabel = function(label) { + if (label.substring(0, 1) === ':') { + return label.substr(2).trim(); + } else { + return label.trim(); + } +}; + +export const lineType = { + LINE: 0, + DOTTED_LINE: 1 +}; + +export const relationType = { + AGGREGATION: 0, + EXTENSION: 1, + COMPOSITION: 2, + DEPENDENCY: 3 +}; + +export default { + addState, + clear, + getState, + getStates, + getRelations, + addRelation, + addDescription, + addMembers, + cleanupLabel, + lineType, + relationType +}; diff --git a/src/diagrams/state/stateDiagram.spec.js b/src/diagrams/state/stateDiagram.spec.js new file mode 100644 index 000000000..d8918edd7 --- /dev/null +++ b/src/diagrams/state/stateDiagram.spec.js @@ -0,0 +1,328 @@ +/* eslint-env jasmine */ +import { parser } from './parser/stateDiagram'; +import stateDb from './stateDb'; + +describe('state diagram, ', function() { + describe('when parsing an info graph it', function() { + beforeEach(function() { + parser.yy = stateDb; + }); + + fit('super simple', function() { + const str = ` + stateDiagram + [*] --> State1 + State1 --> [*] + `; + + parser.parse(str); + expect(stateDb.getRelations()).toEqual([ + { id1: 'start1', id2: 'State1' }, + { id1: 'State1', id2: 'end1' } + ]); + expect(stateDb.getStates()).toEqual({ + State1: { + id: 'State1', + type: 'default', + descriptions: [] + }, + end1: { + id: 'end1', + type: 'end', + descriptions: [] + }, + start1: { + id: 'start1', + type: 'start', + descriptions: [] + } + }); + }); + it('simple', function() { + const str = `stateDiagram\n + State1 : this is another string + [*] --> State1 + State1 --> [*] + `; + + parser.parse(str); + }); + it('should handle relation definitions', function() { + const str = `stateDiagram\n + [*] --> State1 + State1 --> [*] + State1 : this is a string + State1 : this is another string + + State1 --> State2 + State2 --> [*] + `; + + parser.parse(str); + }); + it('hide empty description', function() { + const str = `stateDiagram\n + hide empty description + [*] --> State1 + State1 --> [*] + State1 : this is a string + State1 : this is another string + + State1 --> State2 + State2 --> [*] + `; + + parser.parse(str); + }); + it('scale', function() { + const str = `stateDiagram\n + scale 350 width + [*] --> State1 + State1 --> [*] + State1 : this is a string + State1 : this is another string + + State1 --> State2 + State2 --> [*] + `; + + parser.parse(str); + }); + + it('description after second state', function() { + const str = `stateDiagram\n + scale 350 width + [*] --> State1 : This is the description + State1 --> [*] + `; + + parser.parse(str); + }); + it('should handle state statements', function() { + const str = `stateDiagram\n + state Configuring { + [*] --> NewValueSelection + NewValueSelection --> NewValuePreview : EvNewValue + NewValuePreview --> NewValueSelection : EvNewValueRejected + NewValuePreview --> NewValueSelection : EvNewValueSaved1 + } + `; + + parser.parse(str); + }); + it('should handle recursive state definitions', function() { + const str = `stateDiagram\n + state Configuring { + [*] --> NewValueSelection + NewValueSelection --> NewValuePreview : EvNewValue + NewValuePreview --> NewValueSelection : EvNewValueRejected + NewValuePreview --> NewValueSelection : EvNewValueSaved + + state NewValuePreview { + State1 --> State2 + } + } + `; + + parser.parse(str); + }); + it('should handle multiple recursive state definitions', function() { + const str = `stateDiagram\n + scale 350 width + [*] --> NotShooting + + state NotShooting { + [*] --> Idle + Idle --> Configuring : EvConfig + Configuring --> Idle : EvConfig + } + + state Configuring { + [*] --> NewValueSelection + NewValueSelection --> NewValuePreview : EvNewValue + NewValuePreview --> NewValueSelection : EvNewValueRejected + NewValuePreview --> NewValueSelection : EvNewValueSaved + + state NewValuePreview { + State1 --> State2 + } + } + `; + + parser.parse(str); + }); + it('should handle state deifintions with separation of id', function() { + const str = `stateDiagram\n + state "Long state description" as state1 + `; + + parser.parse(str); + }); + it('should handle state deifintions with separation of id', function() { + const str = `stateDiagram + state "Not Shooting State" as NotShooting { + state "Idle mode" as Idle + state "Configuring mode" as Configuring + [*] --> Idle + Idle --> Configuring : EvConfig + Configuring --> Idle : EvConfig + } + `; + + parser.parse(str); + }); + + it('should State definition with quotes', function() { + const str = `stateDiagram\n + scale 600 width + + [*] --> State1 + State1 --> State2 : Succeeded + State1 --> [*] : Aborted + State2 --> State3 : Succeeded + State2 --> [*] : Aborted + state State3 { + state "Accumulate Enough Data\nLong State Name" as long1 + long1 : Just a test + [*] --> long1 + long1 --> long1 : New Data + long1 --> ProcessData : Enough Data + } + State3 --> State3 : Failed + State3 --> [*] : Succeeded / Save Result + State3 --> [*] : Aborted + `; + + parser.parse(str); + }); + it('should handle fork statements', function() { + const str = `stateDiagram\n + state fork_state <> + [*] --> fork_state + fork_state --> State2 + fork_state --> State3 + + state join_state <> + State2 --> join_state + State3 --> join_state + join_state --> State4 + State4 --> [*] + `; + + parser.parse(str); + }); + it('should handle concurrent state', function() { + const str = `stateDiagram\n + [*] --> Active + + state Active { + [*] --> NumLockOff + NumLockOff --> NumLockOn : EvNumLockPressed + NumLockOn --> NumLockOff : EvNumLockPressed + -- + [*] --> CapsLockOff + CapsLockOff --> CapsLockOn : EvCapsLockPressed + CapsLockOn --> CapsLockOff : EvCapsLockPressed + -- + [*] --> ScrollLockOff + ScrollLockOff --> ScrollLockOn : EvCapsLockPressed + ScrollLockOn --> ScrollLockOff : EvCapsLockPressed + } + `; + + parser.parse(str); + }); + it('should handle concurrent state', function() { + const str = `stateDiagram\n + [*] --> Active + + state Active { + [*] --> NumLockOff + -- + [*] --> CapsLockOff + -- + [*] --> ScrollLockOff + } + `; + + parser.parse(str); + }); + // it('should handle arrow directions definitions', function() { + // const str = `stateDiagram\n + // [*] -up-> First + // First -right-> Second + // Second --> Third + // Third -left-> Last + // `; + + // parser.parse(str); + // }); + it('should handle note statements', function() { + const str = `stateDiagram\n + [*] --> Active + Active --> Inactive + + note left of Active : this is a short
note + + note right of Inactive + A note can also + be defined on + several lines + end note + `; + + parser.parse(str); + }); + it('should handle floating notes', function() { + const str = `stateDiagram + foo: bar + note "This is a floating note" as N1 + `; + + parser.parse(str); + }); + it('should handle floating notes', function() { + const str = `stateDiagram\n + state foo + note "This is a floating note" as N1 + `; + + parser.parse(str); + }); + it('should handle notes for composit states', function() { + const str = `stateDiagram\n + [*] --> NotShooting + + state "Not Shooting State" as NotShooting { + state "Idle mode" as Idle + state "Configuring mode" as Configuring + [*] --> Idle + Idle --> Configuring : EvConfig + Configuring --> Idle : EvConfig + } + + note right of NotShooting : This is a note on a composite state + `; + + parser.parse(str); + }); + xit('should handle if statements', function() { + const str = `stateDiagram\n + [*] --> "Order Submitted" + if "Payment Accepted" then + -->[yes] "Pack products" + --> "Send parcel" + -right-> (*) + else + ->[no] "Send error message" + -->[Cancel Order] [*] + endif + } + + note right of NotShooting : This is a note on a composite state + `; + + parser.parse(str); + }); + }); +}); diff --git a/src/diagrams/state/stateRenderer.js b/src/diagrams/state/stateRenderer.js new file mode 100644 index 000000000..800465845 --- /dev/null +++ b/src/diagrams/state/stateRenderer.js @@ -0,0 +1,506 @@ +import * as d3 from 'd3'; +import dagre from 'dagre-layout'; +import graphlib from 'graphlibrary'; +import { logger } from '../../logger'; +import stateDb from './stateDb'; +import { parser } from './parser/stateDiagram'; +import utils from '../../utils'; + +parser.yy = stateDb; + +const idCache = {}; + +let stateCnt = 0; +let total = 0; +let edgeCount = 0; + +const conf = { + dividerMargin: 10, + padding: 5, + textHeight: 10 +}; + +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 19,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; +}; +/** + * Draws a state with descriptions + * @param {*} g + * @param {*} stateDef + */ +const drawDescrState = (g, stateDef) => { + const addTspan = function(textEl, txt, isFirst) { + const tSpan = textEl + .append('tspan') + .attr('x', 2 * conf.padding) + .text(txt); + if (!isFirst) { + tSpan.attr('dy', conf.textHeight); + } + }; + const title = g + .append('text') + .attr('x', 2 * conf.padding) + .attr('y', conf.textHeight + 1.5 * conf.padding) + .attr('font-size', 24) + .attr('class', 'state-title') + .text(stateDef.id); + + const titleHeight = title.node().getBBox().height; + + const description = g + .append('text') // text label for the x axis + .attr('x', conf.padding) + .attr('y', titleHeight + conf.padding * 0.2 + conf.dividerMargin + conf.textHeight) + .attr('fill', 'white') + .attr('class', 'state-description'); + + let isFirst = true; + stateDef.descriptions.forEach(function(descr) { + addTspan(description, descr, isFirst); + isFirst = false; + }); + + const descrLine = g + .append('line') // text label for the x axis + .attr('x1', conf.padding) + .attr('y1', conf.padding + titleHeight + conf.dividerMargin / 2) + .attr('y2', conf.padding + titleHeight + conf.dividerMargin / 2) + .attr('class', 'descr-divider'); + const descrBox = description.node().getBBox(); + descrLine.attr('x2', descrBox.width + 3 * conf.padding); + // const classBox = title.node().getBBox(); + + g.insert('rect', ':first-child') + .attr('x', conf.padding) + .attr('y', conf.padding) + .attr('width', descrBox.width + 2 * conf.padding) + .attr('height', descrBox.height + titleHeight + 2 * conf.padding) + .attr('rx', '5'); + + return g; +}; +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-end', + 'url(' + url + '#' + getRelationType(stateDb.relationType.DEPENDENCY) + 'End' + ')' + ); + + if (typeof relation.title !== 'undefined') { + const g = elem.append('g').attr('class', 'classLabel'); + const label = g + .append('text') + .attr('class', 'label') + .attr('fill', 'red') + .attr('text-anchor', 'middle') + .text(relation.title); + + const { x, y } = utils.calcLabelPosition(path.points); + label.attr('x', x).attr('y', y); + + 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); + + // Debug points + // path.points.forEach(point => { + // g.append('circle') + // .style('stroke', 'red') + // .style('fill', 'red') + // .attr('r', 1) + // .attr('cx', point.x) + // .attr('cy', point.y); + // }); + + // g.append('circle') + // .style('stroke', 'blue') + // .style('fill', 'blue') + // .attr('r', 1) + // .attr('cx', x) + // .attr('cy', y); + } + + edgeCount++; +}; + +/** + * Draws a state + * @param {*} elem + * @param {*} stateDef + */ +const drawState = function(elem, stateDef) { + // logger.info('Rendering class ' + stateDef); + + 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' && stateDef.descriptions.length === 0) + drawSimpleState(g, stateDef); + if (stateDef.type === 'default' && stateDef.descriptions.length > 0) drawDescrState(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 + * @param id + */ +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); + + // // Layout graph, Create a new directed graph + const graph = new graphlib.Graph({ + multigraph: false + }); + + // // Set an object for the graph label + graph.setGraph({ + isMultiGraph: false + }); + + // // Default to assigning a new object as a label for each new edge. + graph.setDefaultEdgeLabel(function() { + return {}; + }); + + const states = stateDb.getStates(); + 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 = stateDb.getRelations(); + relations.forEach(function(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 ' + (graph.graph().width + 20) + ' ' + (graph.graph().height + 20)); +}; + +export default { + setConf, + draw +}; 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..c03621749 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'; } @@ -69,8 +73,106 @@ export const interpolateToCurve = (interpolate, defaultCurve) => { return d3[curveName] || defaultCurve; }; +const distance = (p1, p2) => + p1 && p2 ? Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2)) : 0; + +const traverseEdge = points => { + let prevPoint; + let totalDistance = 0; + + points.forEach(point => { + totalDistance += distance(point, prevPoint); + prevPoint = point; + }); + + // Traverse half of total distance along points + const distanceToLabel = totalDistance / 2; + + let remainingDistance = distanceToLabel; + let center; + prevPoint = undefined; + points.forEach(point => { + if (prevPoint && !center) { + const vectorDistance = distance(point, prevPoint); + if (vectorDistance < remainingDistance) { + remainingDistance -= vectorDistance; + } else { + // The point is remainingDistance from prevPoint in the vector between prevPoint and point + // Calculate the coordinates + const distanceRatio = remainingDistance / vectorDistance; + if (distanceRatio <= 0) center = prevPoint; + if (distanceRatio >= 1) center = { x: point.x, y: point.y }; + if (distanceRatio > 0 && distanceRatio < 1) { + center = { + x: (1 - distanceRatio) * prevPoint.x + distanceRatio * point.x, + y: (1 - distanceRatio) * prevPoint.y + distanceRatio * point.y + }; + } + } + } + prevPoint = point; + }); + return center; +}; + +const calcLabelPosition = points => { + const p = traverseEdge(points); + return p; +}; + +const calcCardinalityPosition = (isRelationTypePresent, points, initialPosition) => { + let prevPoint; + let totalDistance = 0; + if (points[0] !== initialPosition) { + points = points.reverse(); + } + points.forEach(point => { + totalDistance += distance(point, prevPoint); + prevPoint = point; + }); + + // Traverse only 25 total distance along points to find cardinality point + const distanceToCardinalityPoint = 25; + + let remainingDistance = distanceToCardinalityPoint; + let center; + prevPoint = undefined; + points.forEach(point => { + if (prevPoint && !center) { + const vectorDistance = distance(point, prevPoint); + if (vectorDistance < remainingDistance) { + remainingDistance -= vectorDistance; + } else { + // The point is remainingDistance from prevPoint in the vector between prevPoint and point + // Calculate the coordinates + const distanceRatio = remainingDistance / vectorDistance; + if (distanceRatio <= 0) center = prevPoint; + if (distanceRatio >= 1) center = { x: point.x, y: point.y }; + if (distanceRatio > 0 && distanceRatio < 1) { + center = { + x: (1 - distanceRatio) * prevPoint.x + distanceRatio * point.x, + y: (1 - distanceRatio) * prevPoint.y + distanceRatio * point.y + }; + } + } + } + prevPoint = point; + }); + // if relation is present (Arrows will be added), change cardinality point off-set distance (d) + let d = isRelationTypePresent ? 10 : 5; + //Calculate Angle for x and y axis + let angle = Math.atan2(points[0].y - center.y, points[0].x - center.x); + let cardinalityPosition = { x: 0, y: 0 }; + //Calculation cardinality position using angle, center point on the line/curve but pendicular and with offset-distance + cardinalityPosition.x = Math.sin(angle) * d + (points[0].x + center.x) / 2; + cardinalityPosition.y = -Math.cos(angle) * d + (points[0].y + center.y) / 2; + return cardinalityPosition; +}; + export default { detectType, isSubstringInArray, - interpolateToCurve + interpolateToCurve, + calcLabelPosition, + calcCardinalityPosition };