diff --git a/scripts/sequence/Generator.js b/scripts/sequence/Generator.js index e31eab9..836b181 100644 --- a/scripts/sequence/Generator.js +++ b/scripts/sequence/Generator.js @@ -184,7 +184,9 @@ define(() => { ); return { - meta, + meta: { + title: meta.title, + }, agents, stages: rootStages, }; diff --git a/scripts/sequence/Generator_spec.js b/scripts/sequence/Generator_spec.js index 42a4b7b..112b633 100644 --- a/scripts/sequence/Generator_spec.js +++ b/scripts/sequence/Generator_spec.js @@ -14,13 +14,13 @@ defineDescribe('Sequence Generator', ['./Generator'], (Generator) => { const BLOCK_END = 'block end'; describe('.generate', () => { - it('propagates metadata', () => { + it('propagates title metadata', () => { const input = { - meta: {foo: 'bar'}, + meta: {title: 'bar'}, stages: [], }; const sequence = generator.generate(input); - expect(sequence.meta).toEqual({foo: 'bar'}); + expect(sequence.meta).toEqual({title: 'bar'}); }); it('returns an empty sequence for blank input', () => { diff --git a/scripts/sequence/Renderer.js b/scripts/sequence/Renderer.js index 72acb2a..9fc819b 100644 --- a/scripts/sequence/Renderer.js +++ b/scripts/sequence/Renderer.js @@ -32,6 +32,8 @@ define(() => { } } + const SEP_ZERO = {left: 0, right: 0}; + const NS = 'http://www.w3.org/2000/svg'; const LINE_HEIGHT = 1.3; @@ -160,6 +162,24 @@ define(() => { this.diagram.appendChild(this.actions); this.base.appendChild(this.diagram); + this.separationAgentCap = { + 'box': this.separationAgentCapBox.bind(this), + 'cross': this.separationAgentCapCross.bind(this), + 'bar': this.separationAgentCapBar.bind(this), + 'none': this.separationAgentCapNone.bind(this), + }; + + this.separationAction = { + 'agent begin': this.separationAgent.bind(this), + 'agent end': this.separationAgent.bind(this), + 'connection': this.separationConnection.bind(this), + 'block': this.separationBlock.bind(this), + 'note over': this.separationNoteOver.bind(this), + 'note left': this.separationNoteLeft.bind(this), + 'note right': this.separationNoteRight.bind(this), + 'note between': this.separationNoteBetween.bind(this), + }; + this.renderAgentCap = { 'box': this.renderAgentCapBox.bind(this), 'cross': this.renderAgentCapCross.bind(this), @@ -178,121 +198,117 @@ define(() => { 'note between': this.renderNoteBetween.bind(this), }; - this.separationAction = { - 'agent begin': this.separationAgentCap.bind(this), - 'agent end': this.separationAgentCap.bind(this), - 'connection': this.separationConnection.bind(this), - 'block': this.separationBlock.bind(this), - 'note over': this.separationNoteOver.bind(this), - 'note left': this.separationNoteLeft.bind(this), - 'note right': this.separationNoteRight.bind(this), - 'note between': this.separationNoteBetween.bind(this), - }; - this.width = 0; this.height = 0; } - addSeparation(agentInfo, agent, dist) { - let d = agentInfo.separations.get(agent) || 0; - agentInfo.separations.set(agent, Math.max(d, dist)); + addSeparation(agent1, agent2, dist) { + const info1 = this.agentInfos.get(agent1); + const info2 = this.agentInfos.get(agent2); + + const d1 = info1.separations.get(agent2) || 0; + info1.separations.set(agent2, Math.max(d1, dist)); + + const d2 = info2.separations.get(agent1) || 0; + info2.separations.set(agent1, Math.max(d2, dist)); } - separationAgentCap(agentInfos, stage) { - switch(stage.mode) { - case 'box': - case 'bar': - stage.agents.forEach((agent1) => { - const info1 = agentInfos.get(agent1); - const sep1 = ( - info1.labelWidth / 2 + - AGENT_MARGIN + addSeparations(agents, agentSpaces) { + agents.forEach((agent1) => { + const info1 = this.agentInfos.get(agent1); + const sep1 = agentSpaces.get(agent1) || SEP_ZERO; + agents.forEach((agent2) => { + const info2 = this.agentInfos.get(agent2); + if(info2.index >= info1.index) { + return; + } + const sep2 = agentSpaces.get(agent2) || SEP_ZERO; + this.addSeparation( + agent1, + agent2, + sep1.right + sep2.left + AGENT_MARGIN ); - stage.agents.forEach((agent2) => { - if(agent1 === agent2) { - return; - } - const info2 = agentInfos.get(agent2); - this.addSeparation(info1, agent2, - sep1 + info2.labelWidth / 2 - ); - }); - this.visibleAgents.forEach((agent2) => { - if(stage.agents.indexOf(agent2) === -1) { - const info2 = agentInfos.get(agent2); - this.addSeparation(info1, agent2, sep1); - this.addSeparation(info2, agent1, sep1); - } - }); }); - break; - case 'cross': - stage.agents.forEach((agent1) => { - const info1 = agentInfos.get(agent1); - const sep1 = ( - AGENT_CROSS_SIZE / 2 + - AGENT_MARGIN - ); - stage.agents.forEach((agent2) => { - if(agent1 === agent2) { - return; - } - this.addSeparation(info1, agent2, - sep1 + AGENT_CROSS_SIZE / 2 - ); - }); - this.visibleAgents.forEach((agent2) => { - if(stage.agents.indexOf(agent2) === -1) { - const info2 = agentInfos.get(agent2); - this.addSeparation(info1, agent2, sep1); - this.addSeparation(info2, agent1, sep1); - } - }); - }); - break; + }); + } + + separationAgentCapBox(agentInfo) { + return { + left: agentInfo.labelWidth / 2, + right: agentInfo.labelWidth / 2, + }; + } + + separationAgentCapCross() { + return { + left: AGENT_CROSS_SIZE / 2, + right: AGENT_CROSS_SIZE / 2, + }; + } + + separationAgentCapBar(agentInfo) { + return { + left: agentInfo.labelWidth / 2, + right: agentInfo.labelWidth / 2, + }; + } + + separationAgentCapNone() { + return {left: 0, right: 0}; + } + + separationAgent({type, mode, agents}) { + if(type === 'agent begin') { + mergeSets(this.visibleAgents, agents); } - if(stage.type === 'agent begin') { - mergeSets(this.visibleAgents, stage.agents); - } else if(stage.type === 'agent end') { - removeAll(this.visibleAgents, stage.agents); + + const agentSpaces = new Map(); + agents.forEach((agent) => { + const info = this.agentInfos.get(agent); + const separationFn = this.separationAgentCap[mode]; + agentSpaces.set(agent, separationFn(info)); + }); + this.addSeparations(this.visibleAgents, agentSpaces); + + if(type === 'agent end') { + removeAll(this.visibleAgents, agents); } } - separationConnection(agentInfos, stage) { - const w = ( - this.testTextWidth(this.testConnectWidth, stage.label) + + separationConnection({agents, label}) { + this.addSeparation( + agents[0], + agents[1], + + this.testTextWidth(this.testConnectWidth, label) + CONNECT_POINT * 2 + CONNECT_LABEL_PADDING * 2 + ATTRS.AGENT_LINE['stroke-width'] ); - const agent1 = stage.agents[0]; - const agent2 = stage.agents[1]; - this.addSeparation(agentInfos.get(agent1), agent2, w); - this.addSeparation(agentInfos.get(agent2), agent1, w); } - separationBlock(/*agentInfos, stage*/) { + separationBlock(/*stage*/) { // TODO } - separationNoteOver(/*agentInfos, stage*/) { + separationNoteOver(/*stage*/) { // TODO } - separationNoteLeft(/*agentInfos, stage*/) { + separationNoteLeft(/*stage*/) { // TODO } - separationNoteRight(/*agentInfos, stage*/) { + separationNoteRight(/*stage*/) { // TODO } - separationNoteBetween(/*agentInfos, stage*/) { + separationNoteBetween(/*stage*/) { // TODO } - checkSeparation(agentInfos, stage) { - this.separationAction[stage.type](agentInfos, stage); + checkSeparation(stage) { + this.separationAction[stage.type](stage); } renderAgentCapBox({x, labelWidth, label}) { @@ -361,37 +377,38 @@ define(() => { }; } - renderAgentBegin(agentInfos, stage) { + renderAgentBegin({mode, agents}) { let shifts = {height: 0}; - stage.agents.forEach((agent) => { - const agentInfo = agentInfos.get(agent); - shifts = this.renderAgentCap[stage.mode](agentInfo); + agents.forEach((agent) => { + const agentInfo = this.agentInfos.get(agent); + shifts = this.renderAgentCap[mode](agentInfo); agentInfo.latestYStart = this.currentY + shifts.lineBottom; }); this.currentY += shifts.height + ACTION_MARGIN; } - renderAgentEnd(agentInfos, stage) { + renderAgentEnd({mode, agents}) { let shifts = {height: 0}; - stage.agents.forEach((agent) => { - const agentInfo = agentInfos.get(agent); + agents.forEach((agent) => { + const agentInfo = this.agentInfos.get(agent); const x = agentInfo.x; - shifts = this.renderAgentCap[stage.mode](agentInfo); + shifts = this.renderAgentCap[mode](agentInfo); this.agentLines.appendChild(makeSVGNode('path', Object.assign({ 'd': ( 'M ' + x + ' ' + agentInfo.latestYStart + ' L ' + x + ' ' + (this.currentY + shifts.lineTop) ), + 'class': 'agent-' + agentInfo.index + '-line', }, ATTRS.AGENT_LINE))); agentInfo.latestYStart = null; }); this.currentY += shifts.height + ACTION_MARGIN; } - renderConnection(agentInfos, {label, agents, line, left, right}) { + renderConnection({label, agents, line, left, right}) { /* jshint -W074, -W071 */ // TODO: tidy this up - const from = agentInfos.get(agents[0]); - const to = agentInfos.get(agents[1]); + const from = this.agentInfos.get(agents[0]); + const to = this.agentInfos.get(agents[1]); const dy = CONNECT_HEIGHT / 2; const dx = CONNECT_POINT; @@ -466,28 +483,28 @@ define(() => { this.currentY = y + dy + ACTION_MARGIN; } - renderBlock(/*agentInfos, stage*/) { + renderBlock(/*stage*/) { // TODO } - renderNoteOver(/*agentInfos, stage*/) { + renderNoteOver(/*stage*/) { // TODO } - renderNoteLeft(/*agentInfos, stage*/) { + renderNoteLeft(/*stage*/) { // TODO } - renderNoteRight(/*agentInfos, stage*/) { + renderNoteRight(/*stage*/) { // TODO } - renderNoteBetween(/*agentInfos, stage*/) { + renderNoteBetween(/*stage*/) { // TODO } - addAction(agentInfos, stage) { - this.renderAction[stage.type](agentInfos, stage); + addAction(stage) { + this.renderAction[stage.type](stage); } makeTextTester(attrs) { @@ -510,42 +527,43 @@ define(() => { buildAgentInfos(agents, stages) { const testNameWidth = this.makeTextTester(ATTRS.AGENT_BOX_LABEL); - const agentInfos = new Map(); - agents.forEach((agent) => { - agentInfos.set(agent, { + this.agentInfos = new Map(); + agents.forEach((agent, index) => { + this.agentInfos.set(agent, { label: agent, labelWidth: ( this.testTextWidth(testNameWidth, agent) + BOX_PADDING * 2 ), + index, x: null, latestYStart: null, separations: new Map(), }); }); - agentInfos.get('[').labelWidth = 0; - agentInfos.get(']').labelWidth = 0; + this.agentInfos.get('[').labelWidth = 0; + this.agentInfos.get(']').labelWidth = 0; this.removeTextTester(testNameWidth); this.testConnectWidth = this.makeTextTester(ATTRS.CONNECT_LABEL); this.visibleAgents = ['[', ']']; - traverse(stages, this.checkSeparation.bind(this, agentInfos)); + traverse(stages, this.checkSeparation.bind(this)); this.removeTextTester(this.testConnectWidth); - let currentX = 0; agents.forEach((agent) => { - const agentInfo = agentInfos.get(agent); + const agentInfo = this.agentInfos.get(agent); + let currentX = 0; agentInfo.separations.forEach((dist, otherAgent) => { - const otherAgentInfo = agentInfos.get(otherAgent); + const otherAgentInfo = this.agentInfos.get(otherAgent); if(otherAgentInfo.x !== null) { currentX = Math.max(currentX, otherAgentInfo.x + dist); } }); agentInfo.x = currentX; + this.minX = Math.min(this.minX, currentX); + this.maxX = Math.max(this.maxX, currentX); }); - - return {agentInfos, minX: 0, maxX: currentX}; } updateBounds(stagesHeight) { @@ -584,17 +602,20 @@ define(() => { this.titleText.nodeValue = meta.title || ''; - const info = this.buildAgentInfos(agents, stages); + this.minX = 0; + this.maxX = 0; + this.buildAgentInfos(agents, stages); - this.minX = info.minX; - this.maxX = info.maxX; this.currentY = 0; - - traverse(stages, this.addAction.bind(this, info.agentInfos)); + traverse(stages, this.addAction.bind(this)); this.updateBounds(Math.max(this.currentY - ACTION_MARGIN, 0)); } + getColumnX(name) { + return this.agentInfos.get(name).x; + } + svg() { return this.base; } diff --git a/scripts/sequence/Renderer_spec.js b/scripts/sequence/Renderer_spec.js index ef68aed..f54c59d 100644 --- a/scripts/sequence/Renderer_spec.js +++ b/scripts/sequence/Renderer_spec.js @@ -5,6 +5,11 @@ defineDescribe('Sequence Renderer', ['./Renderer'], (Renderer) => { beforeEach(() => { renderer = new Renderer(); + document.body.appendChild(renderer.svg()); + }); + + afterEach(() => { + document.body.removeChild(renderer.svg()); }); describe('.svg', () => { @@ -14,6 +19,17 @@ defineDescribe('Sequence Renderer', ['./Renderer'], (Renderer) => { }); }); + function connectionStage(agents, label = '') { + return { + type: 'connection', + line: 'solid', + left: false, + right: true, + agents, + label, + }; + } + describe('.render', () => { it('populates the SVG with content', () => { renderer.render({ @@ -25,5 +41,101 @@ defineDescribe('Sequence Renderer', ['./Renderer'], (Renderer) => { const title = element.getElementsByClassName('title')[0]; expect(title.innerHTML).toEqual('Title'); }); + + it('positions column lines', () => { + /* + A -> B + */ + + renderer.render({ + meta: {title: ''}, + agents: ['[', 'A', 'B', ']'], + stages: [ + {type: 'agent begin', agents: ['A', 'B'], mode: 'box'}, + connectionStage(['A', 'B']), + {type: 'agent end', agents: ['A', 'B'], mode: 'none'}, + ], + }); + + const element = renderer.svg(); + const line = element.getElementsByClassName('agent-1-line')[0]; + const drawnX = Number(line.getAttribute('d').split(' ')[1]); + + expect(drawnX).toEqual(renderer.getColumnX('A')); + }); + + it('arranges columns left-to-right', () => { + /* + [ -> A + A -> B + B -> C + C -> ] + */ + + renderer.render({ + meta: {title: ''}, + agents: ['[', 'A', 'B', 'C', ']'], + stages: [ + {type: 'agent begin', agents: ['A', 'B', 'C'], mode: 'box'}, + connectionStage(['[', 'A']), + connectionStage(['A', 'B']), + connectionStage(['B', 'C']), + connectionStage(['C', ']']), + {type: 'agent end', agents: ['A', 'B', 'C'], mode: 'none'}, + ], + }); + + const xL = renderer.getColumnX('['); + const xA = renderer.getColumnX('A'); + const xB = renderer.getColumnX('B'); + const xC = renderer.getColumnX('C'); + const xR = renderer.getColumnX(']'); + + expect(xA).toBeGreaterThan(xL); + expect(xB).toBeGreaterThan(xA); + expect(xC).toBeGreaterThan(xB); + expect(xR).toBeGreaterThan(xC); + }); + + it('allows column reordering for mutually-exclusive columns', () => { + /* + A -> B: short + end B + A -> C: long description here + end C + A -> D: short again + end A, D + */ + + renderer.render({ + meta: {title: ''}, + agents: ['[', 'A', 'B', 'C', 'D', ']'], + stages: [ + {type: 'agent begin', agents: ['A', 'B'], mode: 'box'}, + connectionStage(['A', 'B'], 'short'), + {type: 'agent end', agents: ['B'], mode: 'cross'}, + {type: 'agent begin', agents: ['C'], mode: 'box'}, + connectionStage(['A', 'C'], 'long description here'), + {type: 'agent end', agents: ['C'], mode: 'cross'}, + {type: 'agent begin', agents: ['D'], mode: 'box'}, + connectionStage(['A', 'D'], 'short again'), + {type: 'agent end', agents: ['A', 'D'], mode: 'cross'}, + ], + }); + + const xA = renderer.getColumnX('A'); + const xB = renderer.getColumnX('B'); + const xC = renderer.getColumnX('C'); + const xD = renderer.getColumnX('D'); + + expect(xB).toBeGreaterThan(xA); + expect(xC).toBeGreaterThan(xA); + expect(xD).toBeGreaterThan(xA); + + expect(xC).toBeGreaterThan(xB); + expect(xD).toBeGreaterThan(xB); + + expect(xD).toBeLessThan(xC); + }); }); });