From b240990f3e72aa02d1be898c7495b5a918a7d33d Mon Sep 17 00:00:00 2001 From: David Evans Date: Wed, 25 Oct 2017 20:57:11 +0100 Subject: [PATCH] Add support for rendering notes and state [#2] --- scripts/sequence/Parser.js | 17 +- scripts/sequence/Parser_spec.js | 4 + scripts/sequence/Renderer.js | 380 ++++++++++++++++++++++++++------ 3 files changed, 321 insertions(+), 80 deletions(-) diff --git a/scripts/sequence/Parser.js b/scripts/sequence/Parser.js index ccca8a3..e6f5778 100644 --- a/scripts/sequence/Parser.js +++ b/scripts/sequence/Parser.js @@ -51,19 +51,17 @@ define(() => { const NOTE_TYPES = { 'note': { mode: 'note', - multiAgent: true, types: { - 'over': {type: 'note over', skip: []}, - 'left': {type: 'note left', skip: ['of']}, - 'right': {type: 'note right', skip: ['of']}, - 'between': {type: 'note between', skip: []}, + 'over': {type: 'note over', skip: [], min: 1, max: null}, + 'left': {type: 'note left', skip: ['of'], min: 1, max: null}, + 'right': {type: 'note right', skip: ['of'], min: 1, max: null}, + 'between': {type: 'note between', skip: [], min: 2, max: null}, }, }, 'state': { mode: 'state', - multiAgent: false, types: { - 'over': {type: 'note over', skip: []}, + 'over': {type: 'note over', skip: [], min: 1, max: 1}, }, }, }; @@ -209,7 +207,10 @@ define(() => { let skip = 2; skip = skipOver(line, skip, type.skip); const agents = parseCommaList(line.slice(skip, labelSplit)); - if(agents.length < 1 || (agents.length > 1 && !mode.multiAgent)) { + if( + agents.length < type.min || + (type.max !== null && agents.length > type.max) + ) { throw new Error('Invalid ' + line[0] + ': ' + line.join(' ')); } return { diff --git a/scripts/sequence/Parser_spec.js b/scripts/sequence/Parser_spec.js index 490d727..a5e20d5 100644 --- a/scripts/sequence/Parser_spec.js +++ b/scripts/sequence/Parser_spec.js @@ -326,6 +326,10 @@ defineDescribe('Sequence Parser', ['./Parser'], (Parser) => { }]); }); + it('rejects note between for a single agent', () => { + expect(() => parser.parse('state between A: hi')).toThrow(); + }); + it('converts state', () => { const parsed = parser.parse('state over A: doing stuff'); expect(parsed.stages).toEqual([{ diff --git a/scripts/sequence/Renderer.js b/scripts/sequence/Renderer.js index 297594c..c9dd1d8 100644 --- a/scripts/sequence/Renderer.js +++ b/scripts/sequence/Renderer.js @@ -3,6 +3,22 @@ define(() => { /* jshint -W071 */ // TODO: break up rendering logic + const NS = 'http://www.w3.org/2000/svg'; + + function makeText(text = '') { + return document.createTextNode(text); + } + + function makeSVGNode(type, attrs = {}) { + const o = document.createElementNS(NS, type); + for(let k in attrs) { + if(attrs.hasOwnProperty(k)) { + o.setAttribute(k, attrs[k]); + } + } + return o; + } + function empty(node) { while(node.childNodes.length > 0) { node.removeChild(node.lastChild); @@ -32,9 +48,41 @@ define(() => { } } - const SEP_ZERO = {left: 0, right: 0}; + function boxRenderer(attrs, position) { + return makeSVGNode('rect', Object.assign({}, position, attrs)); + } - const NS = 'http://www.w3.org/2000/svg'; + function noteRenderer(attrs, flickAttrs, position) { + const g = makeSVGNode('g'); + const x0 = position.x; + const x1 = position.x + position.width; + const y0 = position.y; + const y1 = position.y + position.height; + const flick = 7; + + g.appendChild(makeSVGNode('path', Object.assign({ + 'd': ( + 'M ' + x0 + ' ' + y0 + + ' L ' + (x1 - flick) + ' ' + y0 + + ' L ' + x1 + ' ' + (y0 + flick) + + ' L ' + x1 + ' ' + y1 + + ' L ' + x0 + ' ' + y1 + + ' Z' + ), + }, attrs))); + + g.appendChild(makeSVGNode('path', Object.assign({ + 'd': ( + 'M ' + (x1 - flick) + ' ' + y0 + + ' L ' + (x1 - flick) + ' ' + (y0 + flick) + + ' L ' + x1 + ' ' + (y0 + flick) + ), + }, flickAttrs))); + + return g; + } + + const SEP_ZERO = {left: 0, right: 0}; const LINE_HEIGHT = 1.3; const TITLE_MARGIN = 10; @@ -53,12 +101,12 @@ define(() => { bottom: 1, }; const BLOCK_MARGIN = { - top: 5, - bottom: 5, + top: 0, + bottom: 0, }; const BLOCK_SECTION_PADDING = { top: 3, - bottom: 5, + bottom: 2, }; const BLOCK_MODE_PADDING = { top: 1, @@ -75,6 +123,43 @@ define(() => { right: 3, }; + const NOTE = { + 'note': { + margin: {top: 0, left: 5, right: 5, bottom: 0}, + padding: {top: 5, left: 5, right: 10, bottom: 5}, + overlap: {left: 10, right: 10}, + boxRenderer: noteRenderer.bind(null, { + 'fill': '#FFFFFF', + 'stroke': '#000000', + 'stroke-width': 1, + }, { + 'fill': 'none', + 'stroke': '#000000', + 'stroke-width': 1, + }), + labelAttrs: { + 'font-family': 'sans-serif', + 'font-size': 8, + }, + }, + 'state': { + margin: {top: 0, left: 5, right: 5, bottom: 0}, + padding: {top: 7, left: 7, right: 7, bottom: 7}, + overlap: {left: 10, right: 10}, + boxRenderer: boxRenderer.bind(null, { + 'fill': '#FFFFFF', + 'stroke': '#000000', + 'stroke-width': 1, + 'rx': 10, + 'ry': 10, + }), + labelAttrs: { + 'font-family': 'sans-serif', + 'font-size': 8, + }, + }, + }; + const ATTRS = { TITLE: { 'font-family': 'sans-serif', @@ -170,20 +255,6 @@ define(() => { }, }; - function makeText(text = '') { - return document.createTextNode(text); - } - - function makeSVGNode(type, attrs = {}) { - const o = document.createElementNS(NS, type); - for(let k in attrs) { - if(attrs.hasOwnProperty(k)) { - o.setAttribute(k, attrs[k]); - } - } - return o; - } - function traverse(stages, callbacks) { stages.forEach((stage) => { if(stage.type === 'block') { @@ -238,6 +309,9 @@ define(() => { this.diagram.appendChild(this.actions); this.base.appendChild(this.diagram); + this.testers = makeSVGNode('g'); + this.testersCache = new Map(); + this.separationAgentCap = { 'box': this.separationAgentCapBox.bind(this), 'cross': this.separationAgentCapCross.bind(this), @@ -291,6 +365,24 @@ define(() => { this.height = 0; } + findExtremes(agents) { + let min = null; + let max = null; + agents.forEach((agent) => { + const info = this.agentInfos.get(agent); + if(min === null || info.index < min.index) { + min = info; + } + if(max === null || info.index > max.index) { + max = info; + } + }); + return { + left: min.label, + right: max.label, + }; + } + addSeparation(agent1, agent2, dist) { const info1 = this.agentInfos.get(agent1); const info2 = this.agentInfos.get(agent2); @@ -303,19 +395,19 @@ define(() => { } 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) { + agents.forEach((agentR) => { + const infoR = this.agentInfos.get(agentR); + const sepR = agentSpaces.get(agentR) || SEP_ZERO; + agents.forEach((agentL) => { + const infoL = this.agentInfos.get(agentL); + if(infoL.index >= infoR.index) { return; } - const sep2 = agentSpaces.get(agent2) || SEP_ZERO; + const sepL = agentSpaces.get(agentL) || SEP_ZERO; this.addSeparation( - agent1, - agent2, - sep1.right + sep2.left + AGENT_MARGIN + agentR, + agentL, + sepR.left + sepL.right + AGENT_MARGIN ); }); }); @@ -369,27 +461,95 @@ define(() => { agents[0], agents[1], - this.testTextWidth(this.testConnect, label) + + this.testTextWidth(ATTRS.CONNECT_LABEL, label) + CONNECT_POINT * 2 + CONNECT_LABEL_PADDING * 2 + ATTRS.AGENT_LINE['stroke-width'] ); } - separationNoteOver(/*stage*/) { - // TODO + separationNoteOver({agents, mode, label}) { + const config = NOTE[mode]; + const width = ( + this.testTextWidth(config.labelAttrs, label) + + config.padding.left + + config.padding.right + ); + + const agentSpaces = new Map(); + if(agents.length > 1) { + const {left, right} = this.findExtremes(agents); + + this.addSeparation( + left, + right, + + width - + config.overlap.left - + config.overlap.right + ); + + agentSpaces.set(left, {left: config.overlap.left, right: 0}); + agentSpaces.set(right, {left: 0, right: config.overlap.right}); + } else { + agentSpaces.set(agents[0], { + left: width / 2, + right: width / 2, + }); + } + this.addSeparations(this.visibleAgents, agentSpaces); } - separationNoteLeft(/*stage*/) { - // TODO + separationNoteLeft({agents, mode, label}) { + const config = NOTE[mode]; + const {left} = this.findExtremes(agents); + + const agentSpaces = new Map(); + agentSpaces.set(left, { + left: ( + this.testTextWidth(config.labelAttrs, label) + + config.padding.left + + config.padding.right + + config.margin.left + + config.margin.right + ), + right: 0, + }); + this.addSeparations(this.visibleAgents, agentSpaces); } - separationNoteRight(/*stage*/) { - // TODO + separationNoteRight({agents, mode, label}) { + const config = NOTE[mode]; + const {right} = this.findExtremes(agents); + + const agentSpaces = new Map(); + agentSpaces.set(right, { + left: 0, + right: ( + this.testTextWidth(config.labelAttrs, label) + + config.padding.left + + config.padding.right + + config.margin.left + + config.margin.right + ), + }); + this.addSeparations(this.visibleAgents, agentSpaces); } - separationNoteBetween(/*stage*/) { - // TODO + separationNoteBetween({agents, mode, label}) { + const config = NOTE[mode]; + const {left, right} = this.findExtremes(agents); + + this.addSeparation( + left, + right, + + this.testTextWidth(config.labelAttrs, label) + + config.padding.left + + config.padding.right + + config.margin.left + + config.margin.right + ); } separationBlockBegin(scope, {left, right}) { @@ -399,9 +559,9 @@ define(() => { separationSectionBegin(scope, {left, right}, {mode, label}) { const width = ( - this.testTextWidth(this.testBlockMode, mode) + + this.testTextWidth(ATTRS.BLOCK_MODE_LABEL, mode) + BLOCK_MODE_PADDING.left + BLOCK_MODE_PADDING.right + - this.testTextWidth(this.testBlockLabel, label) + + this.testTextWidth(ATTRS.BLOCK_LABEL, label) + BLOCK_LABEL_PADDING.left + BLOCK_LABEL_PADDING.right ); this.addSeparation(left, right, width); @@ -587,20 +747,103 @@ define(() => { this.currentY = y + dy + ACTION_MARGIN; } - renderNoteOver(/*stage*/) { - // TODO + renderNote({xMid = null, x0 = null, x1 = null}, anchor, mode, label) { + const config = NOTE[mode]; + + const sz = config.labelAttrs['font-size']; + + this.currentY += config.margin.top; + + const labelNode = makeSVGNode('text', Object.assign({ + 'y': this.currentY + config.padding.top + sz, + 'text-anchor': anchor, + }, config.labelAttrs)); + labelNode.appendChild(makeText(label)); + this.actions.appendChild(labelNode); + + const w = labelNode.getComputedTextLength(); + const fullW = w + config.padding.left + config.padding.right; + if(x0 === null && xMid !== null) { + x0 = xMid - fullW / 2; + } + if(x1 === null && x0 !== null) { + x1 = x0 + fullW; + } else if(x0 === null) { + x0 = x1 - fullW; + } + switch(anchor) { + case 'start': + labelNode.setAttribute('x', x0 + config.padding.left); + break; + case 'end': + labelNode.setAttribute('x', x1 - config.padding.right); + break; + default: + labelNode.setAttribute('x', ( + x0 + config.padding.left + + x1 - config.padding.right + ) / 2); + } + + this.actions.insertBefore(config.boxRenderer({ + x: x0, + y: this.currentY, + width: x1 - x0, + height: ( + config.padding.top + + sz * LINE_HEIGHT + + config.padding.bottom + ), + }), labelNode); + + this.currentY += ( + config.padding.top + + sz * LINE_HEIGHT + + config.padding.bottom + + config.margin.bottom + + ACTION_MARGIN + ); } - renderNoteLeft(/*stage*/) { - // TODO + renderNoteOver({agents, mode, label}) { + const config = NOTE[mode]; + + if(agents.length > 1) { + const {left, right} = this.findExtremes(agents); + this.renderNote({ + x0: this.agentInfos.get(left).x - config.overlap.left, + x1: this.agentInfos.get(right).x + config.overlap.right, + }, 'middle', mode, label); + } else { + const xMid = this.agentInfos.get(agents[0]).x; + this.renderNote({xMid}, 'middle', mode, label); + } } - renderNoteRight(/*stage*/) { - // TODO + renderNoteLeft({agents, mode, label}) { + const config = NOTE[mode]; + + const {left} = this.findExtremes(agents); + const x1 = this.agentInfos.get(left).x - config.margin.right; + this.renderNote({x1}, 'end', mode, label); } - renderNoteBetween(/*stage*/) { - // TODO + renderNoteRight({agents, mode, label}) { + const config = NOTE[mode]; + + const {right} = this.findExtremes(agents); + const x0 = this.agentInfos.get(right).x + config.margin.left; + this.renderNote({x0}, 'start', mode, label); + } + + renderNoteBetween({agents, mode, label}) { + const {left, right} = this.findExtremes(agents); + const xMid = ( + this.agentInfos.get(left).x + + this.agentInfos.get(right).x + ) / 2; + + this.renderNote({xMid}, 'middle', mode, label); } renderBlockBegin(scope) { @@ -700,39 +943,39 @@ define(() => { 'height': this.currentY - scope.y, }, ATTRS.BLOCK_BOX))); - this.currentY += BLOCK_MARGIN.bottom; + this.currentY += BLOCK_MARGIN.bottom + ACTION_MARGIN; } addAction(stage) { this.renderAction[stage.type](stage); } - makeTextTester(attrs) { - const text = makeText(); - const node = makeSVGNode('text', attrs); - node.appendChild(text); - this.agentDecor.appendChild(node); - return {text, node}; - } + testTextWidth(attrs, content) { + let tester = this.testersCache.get(attrs); + if(!tester) { + const text = makeText(); + const node = makeSVGNode('text', attrs); + node.appendChild(text); + this.testers.appendChild(node); + tester = {text, node}; + this.testersCache.set(attrs, tester); + } - testTextWidth(tester, text) { - tester.text.nodeValue = text; + tester.text.nodeValue = content; return tester.node.getComputedTextLength(); } - removeTextTester(tester) { - this.agentDecor.removeChild(tester.node); - } - buildAgentInfos(agents, stages) { - const testName = this.makeTextTester(ATTRS.AGENT_BOX_LABEL); + empty(this.testers); + this.testersCache.clear(); + this.diagram.appendChild(this.testers); this.agentInfos = new Map(); agents.forEach((agent, index) => { this.agentInfos.set(agent, { label: agent, labelWidth: ( - this.testTextWidth(testName, agent) + + this.testTextWidth(ATTRS.AGENT_BOX_LABEL, agent) + BOX_PADDING * 2 ), index, @@ -744,16 +987,9 @@ define(() => { this.agentInfos.get('[').labelWidth = 0; this.agentInfos.get(']').labelWidth = 0; - this.removeTextTester(testName); - - this.testConnect = this.makeTextTester(ATTRS.CONNECT_LABEL); - this.testBlockMode = this.makeTextTester(ATTRS.BLOCK_MODE_LABEL); - this.testBlockLabel = this.makeTextTester(ATTRS.BLOCK_LABEL); this.visibleAgents = ['[', ']']; traverse(stages, this.separationTraversalFns); - this.removeTextTester(this.testConnect); - this.removeTextTester(this.testBlockMode); - this.removeTextTester(this.testBlockLabel); + this.diagram.removeChild(this.testers); agents.forEach((agent) => { const agentInfo = this.agentInfos.get(agent);