diff --git a/README.md b/README.md index 25a5e7e..5e6b41a 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,39 @@ Foo -> Bar Bar -> Baz ``` +### Simultaneous Actions (Beta!) + +Simultaneous Actions preview + +This is a work-in-progress feature. There are situations where this can +lead to [ugly / unreadable overlapping content](https://github.com/davidje13/SequenceDiagram/issues/13). + +``` +begin A, B, C, D +A -> C + +# Define a marker which can be returned to later + +some primary process: +A -> B +B -> A +A -> B +B -> A + +# Return to the defined marker +# (should be interpreted as no-higher-then the marker; may still be +# pushed down to keep relative action ordering consistent) + +simultaneously with some primary process: +C -> D +D -> C +end D +C -> A + +# The marker name is optional; using "simultaneously:" with no marker +# will jump to the top of the entire sequence. +``` + ## DSL Basics Comments begin with a `#` and end at the next newline: diff --git a/screenshots/SimultaneousActions.png b/screenshots/SimultaneousActions.png new file mode 100644 index 0000000..0d44c5b Binary files /dev/null and b/screenshots/SimultaneousActions.png differ diff --git a/scripts/sequence/Generator.js b/scripts/sequence/Generator.js index 30c5788..d1bd32a 100644 --- a/scripts/sequence/Generator.js +++ b/scripts/sequence/Generator.js @@ -17,10 +17,13 @@ define(['core/ArrayUtilities'], (array) => { this.agents = []; this.blockCount = 0; this.nesting = []; + this.markers = new Set(); this.currentSection = null; this.currentNest = null; this.stageHandlers = { + 'mark': this.handleMark.bind(this), + 'async': this.handleAsync.bind(this), 'agent define': this.handleAgentDefine.bind(this), 'agent begin': this.handleAgentBegin.bind(this), 'agent end': this.handleAgentEnd.bind(this), @@ -83,6 +86,7 @@ define(['core/ArrayUtilities'], (array) => { agents: filteredAgents, mode, }); + this.currentNest.hasContent = true; } array.mergeSets(this.currentNest.agents, filteredAgents); array.mergeSets(this.agents, filteredAgents); @@ -100,6 +104,7 @@ define(['core/ArrayUtilities'], (array) => { }; this.currentNest = { agents, + hasContent: false, stage: { type: 'block', sections: [this.currentSection], @@ -114,6 +119,18 @@ define(['core/ArrayUtilities'], (array) => { return {agents, stages}; } + handleMark(stage) { + this.markers.add(stage.name); + this.currentSection.stages.push(stage); + } + + handleAsync(stage) { + if(stage.target !== '' && !this.markers.has(stage.target)) { + throw new Error('Unknown marker: ' + stage.target); + } + this.currentSection.stages.push(stage); + } + handleAgentDefine({agents}) { array.mergeSets(this.currentNest.agents, agents); array.mergeSets(this.agents, agents); @@ -149,10 +166,10 @@ define(['core/ArrayUtilities'], (array) => { if(this.nesting.length <= 1) { throw new Error('Invalid block nesting'); } - const {stage, agents} = this.nesting.pop(); + const {hasContent, stage, agents} = this.nesting.pop(); this.currentNest = array.last(this.nesting); this.currentSection = array.last(this.currentNest.stage.sections); - if(stage.sections.some((section) => section.stages.length > 0)) { + if(hasContent) { array.mergeSets(this.currentNest.agents, agents); array.mergeSets(this.agents, agents); this.addBounds( @@ -162,14 +179,18 @@ define(['core/ArrayUtilities'], (array) => { agents ); this.currentSection.stages.push(stage); + this.currentNest.hasContent = true; } } handleUnknownStage(stage) { - this.setAgentVis(stage.agents, true, 'box'); + if(stage.agents) { + this.setAgentVis(stage.agents, true, 'box'); + array.mergeSets(this.currentNest.agents, stage.agents); + array.mergeSets(this.agents, stage.agents); + } this.currentSection.stages.push(stage); - array.mergeSets(this.currentNest.agents, stage.agents); - array.mergeSets(this.agents, stage.agents); + this.currentNest.hasContent = true; } handleStage(stage) { @@ -183,6 +204,7 @@ define(['core/ArrayUtilities'], (array) => { generate({stages, meta = {}}) { this.agentStates.clear(); + this.markers.clear(); this.agents.length = 0; this.blockCount = 0; this.nesting.length = 0; diff --git a/scripts/sequence/Generator_spec.js b/scripts/sequence/Generator_spec.js index 99f7b4c..77bcc6f 100644 --- a/scripts/sequence/Generator_spec.js +++ b/scripts/sequence/Generator_spec.js @@ -31,6 +31,26 @@ defineDescribe('Sequence Generator', ['./Generator'], (Generator) => { expect(sequence.agents).toEqual(['[', ']']); }); + it('passes marks and async through', () => { + const sequence = generator.generate({stages: [ + {type: 'mark', name: 'foo'}, + {type: 'async', target: 'foo'}, + {type: 'async', target: ''}, + ]}); + expect(sequence.stages).toEqual([ + {type: 'mark', name: 'foo'}, + {type: 'async', target: 'foo'}, + {type: 'async', target: ''}, + ]); + }); + + it('rejects attempts to jump to markers not yet defined', () => { + expect(() => generator.generate({stages: [ + {type: 'async', target: 'foo'}, + {type: 'mark', name: 'foo'}, + ]})).toThrow(); + }); + it('returns aggregated agents', () => { const sequence = generator.generate({stages: [ {type: '->', agents: ['A', 'B']}, @@ -369,10 +389,11 @@ defineDescribe('Sequence Generator', ['./Generator'], (Generator) => { expect(sequence.stages).toEqual([]); }); - it('removes blocks which only contain define statements', () => { + it('removes blocks containing only define statements / markers', () => { const sequence = generator.generate({stages: [ {type: BLOCK_BEGIN, mode: 'if', label: 'abc'}, {type: AGENT_DEFINE, agents: ['A']}, + {type: 'mark', name: 'foo'}, {type: BLOCK_END}, ]}); diff --git a/scripts/sequence/Parser.js b/scripts/sequence/Parser.js index e6f5778..54a71aa 100644 --- a/scripts/sequence/Parser.js +++ b/scripts/sequence/Parser.js @@ -1,4 +1,4 @@ -define(() => { +define(['core/ArrayUtilities'], (array) => { 'use strict'; function execAt(str, reg, i) { @@ -160,124 +160,162 @@ define(() => { return list; } - function parseBlockCommand(line) { - if(line[0] === 'end' && line.length === 1) { - return {type: 'block end'}; - } - - const type = BLOCK_TYPES[line[0]]; - if(!type) { - return null; - } - let skip = 1; - if(line.length > skip) { - skip = skipOver(line, skip, type.skip, 'Invalid block command'); - } - skip = skipOver(line, skip, [':']); - return { - type: type.type, - mode: type.mode, - label: line.slice(skip).join(' '), - }; - } - - function parseAgentCommand(line) { - const type = AGENT_MANIPULATION_TYPES[line[0]]; - if(!type) { - return null; - } - if(line.length <= 1) { - return null; - } - return Object.assign({ - agents: parseCommaList(line.slice(1)), - }, type); - } - - function parseNote(line) { - const mode = NOTE_TYPES[line[0]]; - const labelSplit = line.indexOf(':'); - if(!mode || labelSplit === -1) { - return null; - } - const type = mode.types[line[1]]; - if(!type) { - return null; - } - let skip = 2; - skip = skipOver(line, skip, type.skip); - const agents = parseCommaList(line.slice(skip, labelSplit)); - if( - agents.length < type.min || - (type.max !== null && agents.length > type.max) - ) { - throw new Error('Invalid ' + line[0] + ': ' + line.join(' ')); - } - return { - type: type.type, - agents, - mode: mode.mode, - label: line.slice(labelSplit + 1).join(' '), - }; - } - - function parseConnection(line) { - let labelSplit = line.indexOf(':'); - if(labelSplit === -1) { - labelSplit = line.length; - } - let typeSplit = -1; - let options = null; - for(let j = 0; j < line.length; ++ j) { - const opts = CONNECTION_TYPES[line[j]]; - if(opts) { - typeSplit = j; - options = opts; - break; + const PARSERS = [ + (line, meta) => { // title + if(line[0] !== 'title') { + return null; } - } - if(typeSplit <= 0 || typeSplit >= labelSplit - 1) { - return null; - } - return Object.assign({ - type: 'connection', - agents: [ - line.slice(0, typeSplit).join(' '), - line.slice(typeSplit + 1, labelSplit).join(' '), - ], - label: line.slice(labelSplit + 1).join(' '), - }, options); - } - function parseMeta(line, meta) { - if(line[0] === 'title') { meta.title = line.slice(1).join(' '); return true; - } - if(line[0] === 'terminators') { + }, + + (line, meta) => { // terminators + if(line[0] !== 'terminators') { + return null; + } + if(TERMINATOR_TYPES.indexOf(line[1]) === -1) { - throw new Error('Unrecognised termination: ' + line.join(' ')); + throw new Error('Unknown termination: ' + line.join(' ')); } meta.terminators = line[1]; return true; - } - return false; - } + }, + + (line) => { // block + if(line[0] === 'end' && line.length === 1) { + return {type: 'block end'}; + } + + const type = BLOCK_TYPES[line[0]]; + if(!type) { + return null; + } + let skip = 1; + if(line.length > skip) { + skip = skipOver(line, skip, type.skip, 'Invalid block command'); + } + skip = skipOver(line, skip, [':']); + return { + type: type.type, + mode: type.mode, + label: line.slice(skip).join(' '), + }; + }, + + (line) => { // agent + const type = AGENT_MANIPULATION_TYPES[line[0]]; + if(!type) { + return null; + } + if(line.length <= 1) { + return null; + } + return Object.assign({ + agents: parseCommaList(line.slice(1)), + }, type); + }, + + (line) => { // async + if(line[0] !== 'simultaneously') { + return null; + } + if(array.last(line) !== ':') { + return null; + } + let target = ''; + if(line.length > 2) { + if(line[1] !== 'with') { + return null; + } + target = line.slice(2, line.length - 1).join(' '); + } + return { + type: 'async', + target, + }; + }, + + (line) => { // note + const mode = NOTE_TYPES[line[0]]; + const labelSplit = line.indexOf(':'); + if(!mode || labelSplit === -1) { + return null; + } + const type = mode.types[line[1]]; + if(!type) { + return null; + } + let skip = 2; + skip = skipOver(line, skip, type.skip); + const agents = parseCommaList(line.slice(skip, labelSplit)); + if( + agents.length < type.min || + (type.max !== null && agents.length > type.max) + ) { + throw new Error('Invalid ' + line[0] + ': ' + line.join(' ')); + } + return { + type: type.type, + agents, + mode: mode.mode, + label: line.slice(labelSplit + 1).join(' '), + }; + }, + + (line) => { // connection + let labelSplit = line.indexOf(':'); + if(labelSplit === -1) { + labelSplit = line.length; + } + let typeSplit = -1; + let options = null; + for(let j = 0; j < line.length; ++ j) { + const opts = CONNECTION_TYPES[line[j]]; + if(opts) { + typeSplit = j; + options = opts; + break; + } + } + if(typeSplit <= 0 || typeSplit >= labelSplit - 1) { + return null; + } + return Object.assign({ + type: 'connection', + agents: [ + line.slice(0, typeSplit).join(' '), + line.slice(typeSplit + 1, labelSplit).join(' '), + ], + label: line.slice(labelSplit + 1).join(' '), + }, options); + }, + + (line) => { // marker + if(line.length < 2 || array.last(line) !== ':') { + return null; + } + return { + type: 'mark', + name: line.slice(0, line.length - 1).join(' '), + }; + }, + ]; function parseLine(line, {meta, stages}) { - if(parseMeta(line, meta)) { - return; + let stage = null; + for(let i = 0; i < PARSERS.length; ++ i) { + stage = PARSERS[i](line, meta); + if(stage) { + break; + } } - const stage = ( - parseBlockCommand(line) || - parseAgentCommand(line) || - parseNote(line) || - parseConnection(line) - ); if(!stage) { throw new Error('Unrecognised command: ' + line.join(' ')); } - stages.push(stage); + if(typeof stage === 'object') { + stages.push(stage); + } } return class Parser { diff --git a/scripts/sequence/Parser_spec.js b/scripts/sequence/Parser_spec.js index ad14f1b..7a5467a 100644 --- a/scripts/sequence/Parser_spec.js +++ b/scripts/sequence/Parser_spec.js @@ -355,6 +355,30 @@ defineDescribe('Sequence Parser', ['./Parser'], (Parser) => { ]); }); + it('converts markers', () => { + const parsed = parser.parse('abc:'); + expect(parsed.stages).toEqual([{ + type: 'mark', + name: 'abc', + }]); + }); + + it('converts "simultaneously" flow commands', () => { + const parsed = parser.parse('simultaneously:'); + expect(parsed.stages).toEqual([{ + type: 'async', + target: '', + }]); + }); + + it('converts named "simultaneously" flow commands', () => { + const parsed = parser.parse('simultaneously with abc:'); + expect(parsed.stages).toEqual([{ + type: 'async', + target: 'abc', + }]); + }); + it('converts conditional blocks', () => { const parsed = parser.parse( 'if something happens\n' + diff --git a/scripts/sequence/Renderer.js b/scripts/sequence/Renderer.js index 1db5a03..02b76c9 100644 --- a/scripts/sequence/Renderer.js +++ b/scripts/sequence/Renderer.js @@ -61,12 +61,14 @@ define([ }; this.separationAction = { + 'mark': this.separationMark.bind(this), + 'async': this.separationAsync.bind(this), 'agent begin': this.separationAgent.bind(this), 'agent end': this.separationAgent.bind(this), 'connection': this.separationConnection.bind(this), 'note over': this.separationNoteOver.bind(this), - 'note left': this.separationNoteLeft.bind(this), - 'note right': this.separationNoteRight.bind(this), + 'note left': this.separationNoteSide.bind(this, false), + 'note right': this.separationNoteSide.bind(this, true), 'note between': this.separationNoteBetween.bind(this), }; @@ -78,6 +80,8 @@ define([ }; this.renderAction = { + 'mark': this.renderMark.bind(this), + 'async': this.renderAsync.bind(this), 'agent begin': this.renderAgentBegin.bind(this), 'agent end': this.renderAgentEnd.bind(this), 'connection': this.renderConnection.bind(this), @@ -104,6 +108,7 @@ define([ this.width = 0; this.height = 0; + this.marks = new Map(); this.theme = theme; this.currentSequence = null; this.buildStaticElements(); @@ -180,6 +185,12 @@ define([ }); } + separationMark() { + } + + separationAsync() { + } + separationAgentCapBox({label}) { const config = this.theme.agentCap.box; const width = ( @@ -300,39 +311,23 @@ define([ this.addSeparations(this.visibleAgents, agentSpaces); } - separationNoteLeft({agents, mode, label}) { + separationNoteSide(isRight, {agents, mode, label}) { const config = this.theme.note[mode]; - const {left} = this.findExtremes(agents); + const {left, right} = this.findExtremes(agents); + const width = ( + this.sizer.measure(config.labelAttrs, label).width + + config.padding.left + + config.padding.right + + config.margin.left + + config.margin.right + ); const agentSpaces = new Map(); - agentSpaces.set(left, { - left: ( - this.sizer.measure(config.labelAttrs, label).width + - config.padding.left + - config.padding.right + - config.margin.left + - config.margin.right - ), - right: 0, - }); - this.addSeparations(this.visibleAgents, agentSpaces); - } - - separationNoteRight({agents, mode, label}) { - const config = this.theme.note[mode]; - const {right} = this.findExtremes(agents); - - const agentSpaces = new Map(); - agentSpaces.set(right, { - left: 0, - right: ( - this.sizer.measure(config.labelAttrs, label).width + - config.padding.left + - config.padding.right + - config.margin.left + - config.margin.right - ), - }); + if(isRight) { + agentSpaces.set(right, {left: 0, right: width}); + } else { + agentSpaces.set(left, {left: width, right: 0}); + } this.addSeparations(this.visibleAgents, agentSpaces); } @@ -378,6 +373,18 @@ define([ this.separationAction[stage.type](stage); } + renderMark({name}) { + this.marks.set(name, this.currentY); + } + + renderAsync({target}) { + if(target) { + this.currentY = this.marks.get(target) || 0; + } else { + this.currentY = 0; + } + } + renderAgentCapBox({x, label}) { const config = this.theme.agentCap.box; const {height} = SVGShapes.renderBoxedText(label, { @@ -449,7 +456,30 @@ define([ }; } + checkAgentRange(agents) { + const {left, right} = this.findExtremes(agents); + const leftX = this.agentInfos.get(left).x; + const rightX = this.agentInfos.get(right).x; + this.agentInfos.forEach((agentInfo) => { + if(agentInfo.x >= leftX && agentInfo.x <= rightX) { + this.currentY = Math.max(this.currentY, agentInfo.latestY); + } + }); + } + + markAgentRange(agents) { + const {left, right} = this.findExtremes(agents); + const leftX = this.agentInfos.get(left).x; + const rightX = this.agentInfos.get(right).x; + this.agentInfos.forEach((agentInfo) => { + if(agentInfo.x >= leftX && agentInfo.x <= rightX) { + agentInfo.latestY = this.currentY; + } + }); + } + renderAgentBegin({mode, agents}) { + this.checkAgentRange(agents); let maxHeight = 0; agents.forEach((agent) => { const agentInfo = this.agentInfos.get(agent); @@ -458,9 +488,11 @@ define([ agentInfo.latestYStart = this.currentY + shifts.lineBottom; }); this.currentY += maxHeight + this.theme.actionMargin; + this.markAgentRange(agents); } renderAgentEnd({mode, agents}) { + this.checkAgentRange(agents); let maxHeight = 0; agents.forEach((agent) => { const agentInfo = this.agentInfos.get(agent); @@ -477,6 +509,7 @@ define([ agentInfo.latestYStart = null; }); this.currentY += maxHeight + this.theme.actionMargin; + this.markAgentRange(agents); } renderSelfConnection({label, agents, line, left, right}) { @@ -609,11 +642,13 @@ define([ } renderConnection(stage) { + this.checkAgentRange(stage.agents); if(stage.agents[0] === stage.agents[1]) { this.renderSelfConnection(stage); } else { this.renderSimpleConnection(stage); } + this.markAgentRange(stage.agents); } renderNote({xMid = null, x0 = null, x1 = null}, anchor, mode, label) { @@ -679,6 +714,7 @@ define([ } renderNoteOver({agents, mode, label}) { + this.checkAgentRange(agents); const config = this.theme.note[mode]; if(agents.length > 1) { @@ -691,25 +727,31 @@ define([ const xMid = this.agentInfos.get(agents[0]).x; this.renderNote({xMid}, 'middle', mode, label); } + this.markAgentRange(agents); } renderNoteLeft({agents, mode, label}) { + this.checkAgentRange(agents); const config = this.theme.note[mode]; const {left} = this.findExtremes(agents); const x1 = this.agentInfos.get(left).x - config.margin.right; this.renderNote({x1}, 'end', mode, label); + this.markAgentRange(agents); } renderNoteRight({agents, mode, label}) { + this.checkAgentRange(agents); const config = this.theme.note[mode]; const {right} = this.findExtremes(agents); const x0 = this.agentInfos.get(right).x + config.margin.left; this.renderNote({x0}, 'start', mode, label); + this.markAgentRange(agents); } renderNoteBetween({agents, mode, label}) { + this.checkAgentRange(agents); const {left, right} = this.findExtremes(agents); const xMid = ( this.agentInfos.get(left).x + @@ -717,16 +759,20 @@ define([ ) / 2; this.renderNote({xMid}, 'middle', mode, label); + this.markAgentRange(agents); } - renderBlockBegin(scope) { + renderBlockBegin(scope, {left, right}) { + this.checkAgentRange([left, right]); this.currentY += this.theme.block.margin.top; scope.y = this.currentY; scope.first = true; + this.markAgentRange([left, right]); } renderSectionBegin(scope, {left, right}, {mode, label}) { + this.checkAgentRange([left, right]); const config = this.theme.block; const agentInfoL = this.agentInfos.get(left); const agentInfoR = this.agentInfos.get(right); @@ -767,12 +813,14 @@ define([ Math.max(modeRender.height, labelRender.height) + config.section.padding.top ); + this.markAgentRange([left, right]); } renderSectionEnd(/*scope, block, section*/) { } renderBlockEnd(scope, {left, right}) { + this.checkAgentRange([left, right]); const config = this.theme.block; this.currentY += config.section.padding.bottom; @@ -786,6 +834,7 @@ define([ }, config.boxAttrs))); this.currentY += config.margin.bottom + this.theme.actionMargin; + this.markAgentRange([left, right]); } addAction(stage) { @@ -835,6 +884,7 @@ define([ index, x: null, latestYStart: null, + latestY: 0, separations: new Map(), }); }); @@ -884,6 +934,7 @@ define([ svg.empty(this.sections); svg.empty(this.actionShapes); svg.empty(this.actionLabels); + this.marks.clear(); this.title.set({ attrs: this.theme.titleAttrs, @@ -896,6 +947,7 @@ define([ this.currentY = 0; traverse(sequence.stages, this.renderTraversalFns); + this.checkAgentRange(['[', ']']); const stagesHeight = Math.max( this.currentY - this.theme.actionMargin,