diff --git a/README.md b/README.md index d10b4ff..a4d05e5 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ Gremlin -> Bowie: Do what? Bowie -> Gremlin: Remind me of the babe! Bowie -> Audience: Sings + +terminators box ``` ### Connection Types @@ -62,12 +64,13 @@ Foo <- ]: From the right Notes and State preview ``` -title Note placements +title Note Placements note over Foo: Foo says something note left of Foo: Stuff note right of Bar: More stuff -note over Foo, Bar: Foo and Bar +note over Foo, Bar: "Foo and Bar +on multiple lines" note between Foo, Bar: Link state over Foo: Foo is ponderous @@ -78,7 +81,7 @@ state over Foo: Foo is ponderous Logic preview ``` -title At the bank +title At the Bank begin Person, ATM, Bank Person -> ATM: Request money @@ -97,6 +100,25 @@ else end ``` +### Multiline Text + +Multiline Text preview + +``` +title 'My Multiline +Title' + +note over Foo: 'Also possible\nwith escapes' + +Foo -> Bar: 'Lines of text\non this arrow' + +if 'Even multiline\ninside conditions like this' + Foo -> 'Multiline\nagent' +end + +state over Foo: 'Newlines here,\ntoo!' +``` + ### Short-Lived Agents Short Lived Agents preview diff --git a/screenshots/AlternativeAgentOrdering.png b/screenshots/AlternativeAgentOrdering.png index cc66ad0..52ad5fd 100644 Binary files a/screenshots/AlternativeAgentOrdering.png and b/screenshots/AlternativeAgentOrdering.png differ diff --git a/screenshots/ConnectionTypes.png b/screenshots/ConnectionTypes.png index 0638b95..fd90699 100644 Binary files a/screenshots/ConnectionTypes.png and b/screenshots/ConnectionTypes.png differ diff --git a/screenshots/Logic.png b/screenshots/Logic.png index ea2c7b3..8bf5e62 100644 Binary files a/screenshots/Logic.png and b/screenshots/Logic.png differ diff --git a/screenshots/MultilineText.png b/screenshots/MultilineText.png new file mode 100644 index 0000000..1b98de2 Binary files /dev/null and b/screenshots/MultilineText.png differ diff --git a/screenshots/NotesAndState.png b/screenshots/NotesAndState.png index 6fcd1b6..d6aa957 100644 Binary files a/screenshots/NotesAndState.png and b/screenshots/NotesAndState.png differ diff --git a/screenshots/ShortLivedAgents.png b/screenshots/ShortLivedAgents.png index 510cc65..7ff5626 100644 Binary files a/screenshots/ShortLivedAgents.png and b/screenshots/ShortLivedAgents.png differ diff --git a/screenshots/SimpleUsage.png b/screenshots/SimpleUsage.png index 14af585..237a5c3 100644 Binary files a/screenshots/SimpleUsage.png and b/screenshots/SimpleUsage.png differ diff --git a/scripts/sequence/Renderer.js b/scripts/sequence/Renderer.js index 94b4e55..57ef413 100644 --- a/scripts/sequence/Renderer.js +++ b/scripts/sequence/Renderer.js @@ -2,58 +2,62 @@ define([ './ArrayUtilities', './SVGUtilities', './SVGTextBlock', + './SVGShapes', ], ( array, svg, - SVGTextBlock + SVGTextBlock, + SVGShapes ) => { 'use strict'; - function boxRenderer(attrs, position) { - return svg.make('rect', Object.assign({}, position, attrs)); - } - - function noteRenderer(attrs, flickAttrs, position) { - const g = svg.make('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(svg.make('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(svg.make('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; const OUTER_MARGIN = 5; - const AGENT_BOX_PADDING = 10; const AGENT_MARGIN = 10; - const AGENT_CROSS_SIZE = 20; - const AGENT_NONE_HEIGHT = 10; const ACTION_MARGIN = 5; + const AGENT_CAP = { + box: { + padding: { + top: 5, + left: 10, + right: 10, + bottom: 5, + }, + boxAttrs: { + 'fill': '#FFFFFF', + 'stroke': '#000000', + 'stroke-width': 1, + }, + labelAttrs: { + 'font-family': 'sans-serif', + 'font-size': 12, + 'line-height': LINE_HEIGHT, + 'text-anchor': 'middle', + }, + }, + cross: { + size: 20, + attrs: { + 'fill': 'none', + 'stroke': '#000000', + 'stroke-width': 1, + }, + }, + bar: { + attrs: { + 'fill': '#000000', + 'height': 5, + }, + }, + none: { + height: 10, + }, + }; + const CONNECT = { lineAttrs: { 'solid': { @@ -84,12 +88,18 @@ define([ attrs: { 'font-family': 'sans-serif', 'font-size': 8, + 'line-height': LINE_HEIGHT, 'text-anchor': 'middle', }, }, mask: { - padding: 3, - attrs: { + padding: { + top: 0, + left: 3, + right: 3, + bottom: 0, + }, + maskAttrs: { 'fill': '#FFFFFF', }, }, @@ -130,24 +140,24 @@ define([ 'font-family': 'sans-serif', 'font-weight': 'bold', 'font-size': 9, + 'line-height': LINE_HEIGHT, 'text-anchor': 'left', }, }, label: { - maskPadding: { - left: 3, + padding: { + top: 1, + left: 5, right: 3, + bottom: 0, }, maskAttrs: { 'fill': '#FFFFFF', }, - labelPadding: { - left: 5, - right: 5, - }, labelAttrs: { 'font-family': 'sans-serif', 'font-size': 8, + 'line-height': LINE_HEIGHT, 'text-anchor': 'left', }, }, @@ -166,7 +176,7 @@ define([ 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, { + boxRenderer: SVGShapes.renderNote.bind(null, { 'fill': '#FFFFFF', 'stroke': '#000000', 'stroke-width': 1, @@ -178,13 +188,14 @@ define([ labelAttrs: { 'font-family': 'sans-serif', 'font-size': 8, + 'line-height': LINE_HEIGHT, }, }, '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, { + boxRenderer: SVGShapes.renderBox.bind(null, { 'fill': '#FFFFFF', 'stroke': '#000000', 'stroke-width': 1, @@ -194,6 +205,7 @@ define([ labelAttrs: { 'font-family': 'sans-serif', 'font-size': 8, + 'line-height': LINE_HEIGHT, }, }, }; @@ -202,6 +214,7 @@ define([ TITLE: { 'font-family': 'sans-serif', 'font-size': 20, + 'line-height': LINE_HEIGHT, 'text-anchor': 'middle', 'class': 'title', }, @@ -211,28 +224,21 @@ define([ 'stroke': '#000000', 'stroke-width': 1, }, - AGENT_BOX: { - 'fill': '#FFFFFF', - 'stroke': '#000000', - 'stroke-width': 1, - 'height': 24, - }, - AGENT_BOX_LABEL: { - 'font-family': 'sans-serif', - 'font-size': 12, - 'text-anchor': 'middle', - }, - AGENT_CROSS: { - 'fill': 'none', - 'stroke': '#000000', - 'stroke-width': 1, - }, - AGENT_BAR: { - 'fill': '#000000', - 'height': 5, - }, }; + function drawHorizontalArrowHead(container, {x, y, dx, dy, attrs}) { + container.appendChild(svg.make( + attrs.fill === 'none' ? 'polyline' : 'polygon', + Object.assign({ + 'points': ( + (x + dx) + ' ' + (y - dy) + ' ' + + x + ' ' + y + ' ' + + (x + dx) + ' ' + (y + dy) + ), + }, attrs) + )); + } + function traverse(stages, callbacks) { stages.forEach((stage) => { if(stage.type === 'block') { @@ -321,19 +327,20 @@ define([ }); this.agentLines = svg.make('g'); + this.mask = svg.make('g'); this.blocks = svg.make('g'); this.sections = svg.make('g'); - this.agentDecor = svg.make('g'); - this.actions = svg.make('g'); + this.actionShapes = svg.make('g'); + this.actionLabels = svg.make('g'); this.base.appendChild(this.agentLines); + this.base.appendChild(this.mask); this.base.appendChild(this.blocks); this.base.appendChild(this.sections); - this.base.appendChild(this.agentDecor); - this.base.appendChild(this.actions); - this.title = new SVGTextBlock(this.base, ATTRS.TITLE, LINE_HEIGHT); + this.base.appendChild(this.actionShapes); + this.base.appendChild(this.actionLabels); + this.title = new SVGTextBlock(this.base, ATTRS.TITLE); - this.testers = svg.make('g'); - this.testersCache = new Map(); + this.sizer = new SVGTextBlock.SizeTester(this.base); } findExtremes(agents) { @@ -384,24 +391,36 @@ define([ }); } - separationAgentCapBox(agentInfo) { + separationAgentCapBox({label}) { + const width = ( + this.sizer.measure(AGENT_CAP.box.labelAttrs, label).width + + AGENT_CAP.box.padding.left + + AGENT_CAP.box.padding.right + ); + return { - left: agentInfo.labelWidth / 2, - right: agentInfo.labelWidth / 2, + left: width / 2, + right: width / 2, }; } separationAgentCapCross() { return { - left: AGENT_CROSS_SIZE / 2, - right: AGENT_CROSS_SIZE / 2, + left: AGENT_CAP.cross.size / 2, + right: AGENT_CAP.cross.size / 2, }; } - separationAgentCapBar(agentInfo) { + separationAgentCapBar({label}) { + const width = ( + this.sizer.measure(AGENT_CAP.box.labelAttrs, label).width + + AGENT_CAP.box.padding.left + + AGENT_CAP.box.padding.right + ); + return { - left: agentInfo.labelWidth / 2, - right: agentInfo.labelWidth / 2, + left: width / 2, + right: width / 2, }; } @@ -432,7 +451,7 @@ define([ agents[0], agents[1], - this.testTextWidth(CONNECT.label.attrs, label) + + this.sizer.measure(CONNECT.label.attrs, label).width + CONNECT.arrow.width * 2 + CONNECT.label.padding * 2 + ATTRS.AGENT_LINE['stroke-width'] @@ -442,7 +461,7 @@ define([ separationNoteOver({agents, mode, label}) { const config = NOTE[mode]; const width = ( - this.testTextWidth(config.labelAttrs, label) + + this.sizer.measure(config.labelAttrs, label).width + config.padding.left + config.padding.right ); @@ -478,7 +497,7 @@ define([ const agentSpaces = new Map(); agentSpaces.set(left, { left: ( - this.testTextWidth(config.labelAttrs, label) + + this.sizer.measure(config.labelAttrs, label).width + config.padding.left + config.padding.right + config.margin.left + @@ -497,7 +516,7 @@ define([ agentSpaces.set(right, { left: 0, right: ( - this.testTextWidth(config.labelAttrs, label) + + this.sizer.measure(config.labelAttrs, label).width + config.padding.left + config.padding.right + config.margin.left + @@ -515,7 +534,7 @@ define([ left, right, - this.testTextWidth(config.labelAttrs, label) + + this.sizer.measure(config.labelAttrs, label).width + config.padding.left + config.padding.right + config.margin.left + @@ -529,13 +548,14 @@ define([ } separationSectionBegin(scope, {left, right}, {mode, label}) { + const config = BLOCK.section; const width = ( - this.testTextWidth(BLOCK.section.mode.labelAttrs, mode) + - BLOCK.section.mode.padding.left + - BLOCK.section.mode.padding.right + - this.testTextWidth(BLOCK.section.label.labelAttrs, label) + - BLOCK.section.label.labelPadding.left + - BLOCK.section.label.labelPadding.right + this.sizer.measure(config.mode.labelAttrs, mode).width + + config.mode.padding.left + + config.mode.padding.right + + this.sizer.measure(config.label.labelAttrs, label).width + + config.label.padding.left + + config.label.padding.right ); this.addSeparation(left, right, width); } @@ -548,42 +568,36 @@ define([ this.separationAction[stage.type](stage); } - renderAgentCapBox({x, labelWidth, label}) { - this.agentDecor.appendChild(svg.make('rect', Object.assign({ - 'x': x - labelWidth / 2, - 'y': this.currentY, - 'width': labelWidth, - }, ATTRS.AGENT_BOX))); - - const name = svg.make('text', Object.assign({ - 'x': x, - 'y': this.currentY + ( - ATTRS.AGENT_BOX.height + - ATTRS.AGENT_BOX_LABEL['font-size'] * (2 - LINE_HEIGHT) - ) / 2, - }, ATTRS.AGENT_BOX_LABEL)); - name.appendChild(svg.makeText(label)); - this.agentDecor.appendChild(name); + renderAgentCapBox({x, label}) { + const {height} = SVGShapes.renderBoxedText(label, { + x, + y: this.currentY, + padding: AGENT_CAP.box.padding, + boxAttrs: AGENT_CAP.box.boxAttrs, + labelAttrs: AGENT_CAP.box.labelAttrs, + boxLayer: this.actionShapes, + labelLayer: this.actionLabels, + }); return { lineTop: 0, - lineBottom: ATTRS.AGENT_BOX.height, - height: ATTRS.AGENT_BOX.height, + lineBottom: height, + height, }; } renderAgentCapCross({x}) { const y = this.currentY; - const d = AGENT_CROSS_SIZE / 2; + const d = AGENT_CAP.cross.size / 2; - this.agentDecor.appendChild(svg.make('path', Object.assign({ + this.actionShapes.appendChild(svg.make('path', Object.assign({ 'd': ( 'M ' + (x - d) + ' ' + y + ' L ' + (x + d) + ' ' + (y + d * 2) + ' M ' + (x + d) + ' ' + y + ' L ' + (x - d) + ' ' + (y + d * 2) ), - }, ATTRS.AGENT_CROSS))); + }, AGENT_CAP.cross.attrs))); return { lineTop: d, @@ -592,124 +606,115 @@ define([ }; } - renderAgentCapBar({x, labelWidth}) { - this.agentDecor.appendChild(svg.make('rect', Object.assign({ - 'x': x - labelWidth / 2, + renderAgentCapBar({x, label}) { + const width = ( + this.sizer.measure(AGENT_CAP.box.labelAttrs, label).width + + AGENT_CAP.box.padding.left + + AGENT_CAP.box.padding.right + ); + + this.actionShapes.appendChild(svg.make('rect', Object.assign({ + 'x': x - width / 2, 'y': this.currentY, - 'width': labelWidth, - }, ATTRS.AGENT_BAR))); + 'width': width, + }, AGENT_CAP.bar.attrs))); return { lineTop: 0, - lineBottom: ATTRS.AGENT_BAR.height, - height: ATTRS.AGENT_BAR.height, + lineBottom: AGENT_CAP.bar.attrs.height, + height: AGENT_CAP.bar.attrs.height, }; } renderAgentCapNone() { return { - lineTop: AGENT_NONE_HEIGHT, + lineTop: AGENT_CAP.none.height, lineBottom: 0, - height: AGENT_NONE_HEIGHT, + height: AGENT_CAP.none.height, }; } renderAgentBegin({mode, agents}) { - let shifts = {height: 0}; + let maxHeight = 0; agents.forEach((agent) => { const agentInfo = this.agentInfos.get(agent); - shifts = this.renderAgentCap[mode](agentInfo); + const shifts = this.renderAgentCap[mode](agentInfo); + maxHeight = Math.max(maxHeight, shifts.height); agentInfo.latestYStart = this.currentY + shifts.lineBottom; }); - this.currentY += shifts.height + ACTION_MARGIN; + this.currentY += maxHeight + ACTION_MARGIN; } renderAgentEnd({mode, agents}) { - let shifts = {height: 0}; + let maxHeight = 0; agents.forEach((agent) => { const agentInfo = this.agentInfos.get(agent); const x = agentInfo.x; - shifts = this.renderAgentCap[mode](agentInfo); - this.agentLines.appendChild(svg.make('path', Object.assign({ - 'd': ( - 'M ' + x + ' ' + agentInfo.latestYStart + - ' L ' + x + ' ' + (this.currentY + shifts.lineTop) - ), + const shifts = this.renderAgentCap[mode](agentInfo); + maxHeight = Math.max(maxHeight, shifts.height); + this.agentLines.appendChild(svg.make('line', Object.assign({ + 'x1': x, + 'y1': agentInfo.latestYStart, + 'x2': x, + 'y2': this.currentY + shifts.lineTop, 'class': 'agent-' + agentInfo.index + '-line', }, ATTRS.AGENT_LINE))); agentInfo.latestYStart = null; }); - this.currentY += shifts.height + ACTION_MARGIN; + this.currentY += maxHeight + ACTION_MARGIN; } renderConnection({label, agents, line, left, right}) { - /* jshint -W074, -W071 */ // TODO: tidy this up const from = this.agentInfos.get(agents[0]); const to = this.agentInfos.get(agents[1]); const dy = CONNECT.arrow.height / 2; - const dx = CONNECT.arrow.width; const dir = (from.x < to.x) ? 1 : -1; const short = ATTRS.AGENT_LINE['stroke-width']; - let y = this.currentY; - if(label) { - const mask = svg.make('rect', CONNECT.mask.attrs); - const labelNode = svg.make('text', CONNECT.label.attrs); - labelNode.appendChild(svg.makeText(label)); - const sz = CONNECT.label.attrs['font-size']; - this.actions.appendChild(mask); - this.actions.appendChild(labelNode); - y += Math.max( - dy, - CONNECT.label.margin.top + - sz * LINE_HEIGHT + - CONNECT.label.margin.bottom - ); - const w = labelNode.getComputedTextLength(); - const x = (from.x + to.x) / 2; - const yBase = ( - y - - sz * (LINE_HEIGHT - 1) - - CONNECT.label.margin.bottom - ); - labelNode.setAttribute('x', x); - labelNode.setAttribute('y', yBase); - mask.setAttribute('x', x - w / 2 - CONNECT.mask.padding); - mask.setAttribute('y', yBase - sz); - mask.setAttribute('width', w + CONNECT.mask.padding * 2); - mask.setAttribute('height', sz * LINE_HEIGHT); - } else { - y += dy; - } + const height = ( + this.sizer.measureHeight(CONNECT.label.attrs, label) + + CONNECT.label.margin.top + + CONNECT.label.margin.bottom + ); - this.actions.appendChild(svg.make('path', Object.assign({ - 'd': ( - 'M ' + (from.x + (left ? short : 0) * dir) + ' ' + y + - ' L ' + (to.x - (right ? short : 0) * dir) + ' ' + y - ), + let y = this.currentY + Math.max(dy, height); + + SVGShapes.renderBoxedText(label, { + x: (from.x + to.x) / 2, + y: y - height + CONNECT.label.margin.top, + padding: CONNECT.mask.padding, + boxAttrs: CONNECT.mask.maskAttrs, + labelAttrs: CONNECT.label.attrs, + boxLayer: this.mask, + labelLayer: this.actionLabels, + }); + + this.actionShapes.appendChild(svg.make('line', Object.assign({ + 'x1': from.x + (left ? short : 0) * dir, + 'y1': y, + 'x2': to.x - (right ? short : 0) * dir, + 'y2': y, }, CONNECT.lineAttrs[line]))); if(left) { - this.actions.appendChild(svg.make('path', Object.assign({ - 'd': ( - 'M ' + (from.x + (dx + short) * dir) + ' ' + (y - dy) + - ' L ' + (from.x + short * dir) + ' ' + y + - ' L ' + (from.x + (dx + short) * dir) + ' ' + (y + dy) + - (CONNECT.arrow.attrs.fill === 'none' ? '' : ' Z') - ), - }, CONNECT.arrow.attrs))); + drawHorizontalArrowHead(this.actionShapes, { + x: from.x + short * dir, + y, + dx: CONNECT.arrow.width * dir, + dy, + attrs: CONNECT.arrow.attrs, + }); } if(right) { - this.actions.appendChild(svg.make('path', Object.assign({ - 'd': ( - 'M ' + (to.x - (dx + short) * dir) + ' ' + (y - dy) + - ' L ' + (to.x - short * dir) + ' ' + y + - ' L ' + (to.x - (dx + short) * dir) + ' ' + (y + dy) + - (CONNECT.arrow.attrs.fill === 'none' ? '' : ' Z') - ), - }, CONNECT.arrow.attrs))); + drawHorizontalArrowHead(this.actionShapes, { + x: to.x - short * dir, + y, + dx: -CONNECT.arrow.width * dir, + dy, + attrs: CONNECT.arrow.attrs, + }); } this.currentY = y + dy + ACTION_MARGIN; @@ -718,19 +723,25 @@ define([ 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 = svg.make('text', Object.assign({ - 'y': this.currentY + config.padding.top + sz, - 'text-anchor': anchor, - }, config.labelAttrs)); - labelNode.appendChild(svg.makeText(label)); - this.actions.appendChild(labelNode); + const y = this.currentY + config.padding.top; + const labelNode = new SVGTextBlock( + this.actionLabels, + config.labelAttrs, + {text: label, y} + ); - const w = labelNode.getComputedTextLength(); - const fullW = w + config.padding.left + config.padding.right; + const fullW = ( + labelNode.width + + config.padding.left + + config.padding.right + ); + const fullH = ( + config.padding.top + + labelNode.height + + config.padding.bottom + ); if(x0 === null && xMid !== null) { x0 = xMid - fullW / 2; } @@ -739,35 +750,30 @@ define([ } 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', ( + switch(config.labelAttrs['text-anchor']) { + case 'middle': + labelNode.reanchor(( x0 + config.padding.left + x1 - config.padding.right - ) / 2); + ) / 2, y); + break; + case 'end': + labelNode.reanchor(x1 - config.padding.right, y); + break; + default: + labelNode.reanchor(x0 + config.padding.left, y); + break; } - this.actions.insertBefore(config.boxRenderer({ + this.actionShapes.appendChild(config.boxRenderer({ x: x0, y: this.currentY, width: x1 - x0, - height: ( - config.padding.top + - sz * LINE_HEIGHT + - config.padding.bottom - ), - }), labelNode); + height: fullH, + })); this.currentY += ( - config.padding.top + - sz * LINE_HEIGHT + - config.padding.bottom + + fullH + config.margin.bottom + ACTION_MARGIN ); @@ -822,8 +828,6 @@ define([ } renderSectionBegin(scope, {left, right}, {mode, label}) { - /* jshint -W071 */ // TODO: tidy this up (split text rendering) - const agentInfoL = this.agentInfos.get(left); const agentInfoR = this.agentInfos.get(right); @@ -831,71 +835,38 @@ define([ scope.first = false; } else { this.currentY += BLOCK.section.padding.bottom; - this.sections.appendChild(svg.make('path', Object.assign({ - 'd': ( - 'M' + agentInfoL.x + ' ' + this.currentY + - ' L' + agentInfoR.x + ' ' + this.currentY - ), + this.sections.appendChild(svg.make('line', Object.assign({ + 'x1': agentInfoL.x, + 'y1': this.currentY, + 'x2': agentInfoR.x, + 'y2': this.currentY, }, BLOCK.separator.attrs))); } - let x = agentInfoL.x; - if(mode) { - const sz = BLOCK.section.mode.labelAttrs['font-size']; - const modeBox = svg.make('rect', Object.assign({ - 'x': x, - 'y': this.currentY, - 'height': ( - sz * LINE_HEIGHT + - BLOCK.section.mode.padding.top + - BLOCK.section.mode.padding.bottom - ), - }, BLOCK.section.mode.boxAttrs)); - const modeLabel = svg.make('text', Object.assign({ - 'x': x + BLOCK.section.mode.padding.left, - 'y': ( - this.currentY + sz + - BLOCK.section.mode.padding.top - ), - }, BLOCK.section.mode.labelAttrs)); - modeLabel.appendChild(svg.makeText(mode)); - this.blocks.appendChild(modeBox); - this.actions.appendChild(modeLabel); - const w = ( - modeLabel.getComputedTextLength() + - BLOCK.section.mode.padding.left + - BLOCK.section.mode.padding.right - ); - modeBox.setAttribute('width', w); - x += w; + const modeRender = SVGShapes.renderBoxedText(mode, { + x: agentInfoL.x, + y: this.currentY, + padding: BLOCK.section.mode.padding, + boxAttrs: BLOCK.section.mode.boxAttrs, + labelAttrs: BLOCK.section.mode.labelAttrs, + boxLayer: this.blocks, + labelLayer: this.actionLabels, + }); - this.currentY += sz * LINE_HEIGHT; - } + const labelRender = SVGShapes.renderBoxedText(label, { + x: agentInfoL.x + modeRender.width, + y: this.currentY, + padding: BLOCK.section.label.padding, + boxAttrs: BLOCK.section.label.maskAttrs, + labelAttrs: BLOCK.section.label.labelAttrs, + boxLayer: this.mask, + labelLayer: this.actionLabels, + }); - if(label) { - x += BLOCK.section.label.labelPadding.left; - const sz = BLOCK.section.label.labelAttrs['font-size']; - const mask = svg.make('rect', Object.assign({ - 'x': x - BLOCK.section.label.maskPadding.left, - 'y': this.currentY - sz * LINE_HEIGHT, - 'height': sz * LINE_HEIGHT, - }, BLOCK.section.label.maskAttrs)); - const labelLabel = svg.make('text', Object.assign({ - 'x': x, - 'y': this.currentY - sz * (LINE_HEIGHT - 1), - }, BLOCK.section.label.labelAttrs)); - labelLabel.appendChild(svg.makeText(label)); - this.actions.appendChild(mask); - this.actions.appendChild(labelLabel); - const w = ( - labelLabel.getComputedTextLength() + - BLOCK.section.label.maskPadding.left + - BLOCK.section.label.maskPadding.right - ); - mask.setAttribute('width', w); - } - - this.currentY += BLOCK.section.padding.top; + this.currentY += ( + Math.max(modeRender.height, labelRender.height) + + BLOCK.section.padding.top + ); } renderSectionEnd(/*scope, block, section*/) { @@ -920,46 +891,20 @@ define([ this.renderAction[stage.type](stage); } - testTextWidth(attrs, content) { - let tester = this.testersCache.get(attrs); - if(!tester) { - const text = svg.makeText(); - const node = svg.make('text', attrs); - node.appendChild(text); - this.testers.appendChild(node); - tester = {text, node}; - this.testersCache.set(attrs, tester); - } - - tester.text.nodeValue = content; - return tester.node.getComputedTextLength(); - } - buildAgentInfos(agents, stages) { - svg.empty(this.testers); - this.testersCache.clear(); - this.base.appendChild(this.testers); - this.agentInfos = new Map(); agents.forEach((agent, index) => { this.agentInfos.set(agent, { label: agent, - labelWidth: ( - this.testTextWidth(ATTRS.AGENT_BOX_LABEL, agent) + - AGENT_BOX_PADDING * 2 - ), index, x: null, latestYStart: null, separations: new Map(), }); }); - this.agentInfos.get('[').labelWidth = 0; - this.agentInfos.get(']').labelWidth = 0; this.visibleAgents = ['[', ']']; traverse(stages, this.separationTraversalFns); - this.base.removeChild(this.testers); agents.forEach((agent) => { const agentInfo = this.agentInfos.get(agent); @@ -999,10 +944,11 @@ define([ render({meta, agents, stages}) { svg.empty(this.agentLines); + svg.empty(this.mask); svg.empty(this.blocks); svg.empty(this.sections); - svg.empty(this.agentDecor); - svg.empty(this.actions); + svg.empty(this.actionShapes); + svg.empty(this.actionLabels); this.title.setText(meta.title); @@ -1015,6 +961,9 @@ define([ const stagesHeight = Math.max(this.currentY - ACTION_MARGIN, 0); this.updateBounds(stagesHeight); + + this.sizer.resetCache(); + this.sizer.detach(); } getAgentX(name) { diff --git a/scripts/sequence/Renderer_spec.js b/scripts/sequence/Renderer_spec.js index ae45b6e..515259e 100644 --- a/scripts/sequence/Renderer_spec.js +++ b/scripts/sequence/Renderer_spec.js @@ -59,7 +59,7 @@ defineDescribe('Sequence Renderer', ['./Renderer'], (Renderer) => { const element = renderer.svg(); const line = element.getElementsByClassName('agent-1-line')[0]; - const drawnX = Number(line.getAttribute('d').split(' ')[1]); + const drawnX = Number(line.getAttribute('x1')); expect(drawnX).toEqual(renderer.getAgentX('A')); }); diff --git a/scripts/sequence/SVGShapes.js b/scripts/sequence/SVGShapes.js new file mode 100644 index 0000000..f70828e --- /dev/null +++ b/scripts/sequence/SVGShapes.js @@ -0,0 +1,108 @@ +define(['./SVGUtilities', './SVGTextBlock'], (svg, SVGTextBlock) => { + 'use strict'; + + function renderBox(attrs, position) { + return svg.make('rect', Object.assign({}, position, attrs)); + } + + function renderNote(attrs, flickAttrs, position) { + const g = svg.make('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(svg.make('polygon', Object.assign({ + 'points': ( + x0 + ' ' + y0 + ' ' + + (x1 - flick) + ' ' + y0 + ' ' + + x1 + ' ' + (y0 + flick) + ' ' + + x1 + ' ' + y1 + ' ' + + x0 + ' ' + y1 + ), + }, attrs))); + + g.appendChild(svg.make('polyline', Object.assign({ + 'points': ( + (x1 - flick) + ' ' + y0 + ' ' + + (x1 - flick) + ' ' + (y0 + flick) + ' ' + + x1 + ' ' + (y0 + flick) + ), + }, flickAttrs))); + + return g; + } + + function renderBoxedText(text, { + x, + y, + padding, + boxAttrs, + labelAttrs, + boxLayer, + labelLayer, + boxRenderer = null, + }) { + if(!text) { + return {width: 0, height: 0, label: null, box: null}; + } + + let shift = 0; + let anchorX = x; + switch(labelAttrs['text-anchor']) { + case 'middle': + shift = 0.5; + anchorX += (padding.left - padding.right) / 2; + break; + case 'end': + shift = 1; + anchorX -= padding.right; + break; + default: + shift = 0; + anchorX += padding.left; + break; + } + + const label = new SVGTextBlock(labelLayer, labelAttrs, { + text, + x: anchorX, + y: y + padding.top, + }); + + const width = (label.width + padding.left + padding.right); + const height = (label.height + padding.top + padding.bottom); + + let box = null; + if(boxRenderer) { + box = boxRenderer({ + 'x': anchorX - label.width * shift - padding.left, + 'y': y, + 'width': width, + 'height': height, + }); + } else { + box = renderBox(boxAttrs, { + 'x': anchorX - label.width * shift - padding.left, + 'y': y, + 'width': width, + 'height': height, + }); + } + + if(boxLayer === labelLayer) { + boxLayer.insertBefore(box, label.firstLine()); + } else { + boxLayer.appendChild(box); + } + + return {width, height, label, box}; + } + + return { + renderBox, + renderNote, + renderBoxedText, + }; +}); diff --git a/scripts/sequence/SVGShapes_spec.js b/scripts/sequence/SVGShapes_spec.js new file mode 100644 index 0000000..fc37c88 --- /dev/null +++ b/scripts/sequence/SVGShapes_spec.js @@ -0,0 +1,107 @@ +defineDescribe('SVGShapes', ['./SVGShapes'], (SVGShapes) => { + 'use strict'; + + describe('renderBox', () => { + it('returns a simple rect SVG element', () => { + const node = SVGShapes.renderBox({ + 'foo': 'bar', + }, { + 'x': 10, + 'y': 20, + 'width': 30, + 'height': 40, + }); + expect(node.tagName).toEqual('rect'); + expect(node.getAttribute('foo')).toEqual('bar'); + expect(node.getAttribute('x')).toEqual('10'); + expect(node.getAttribute('y')).toEqual('20'); + expect(node.getAttribute('width')).toEqual('30'); + expect(node.getAttribute('height')).toEqual('40'); + }); + }); + + describe('renderNote', () => { + it('returns a group containing a rectangle with a page flick', () => { + const node = SVGShapes.renderNote({ + 'foo': 'bar', + }, { + 'zig': 'zag', + }, { + 'x': 10, + 'y': 20, + 'width': 30, + 'height': 40, + }); + expect(node.tagName).toEqual('g'); + expect(node.children.length).toEqual(2); + const back = node.children[0]; + expect(back.getAttribute('foo')).toEqual('bar'); + expect(back.getAttribute('points')).toEqual( + '10 20 ' + + '33 20 ' + + '40 27 ' + + '40 60 ' + + '10 60' + ); + const flick = node.children[1]; + expect(flick.getAttribute('zig')).toEqual('zag'); + expect(flick.getAttribute('points')).toEqual( + '33 20 ' + + '33 27 ' + + '40 27' + ); + }); + }); + + describe('renderBoxedText', () => { + it('renders a label', () => { + const o = document.createElement('p'); + const rendered = SVGShapes.renderBoxedText('foo', { + x: 1, + y: 2, + padding: {left: 4, top: 8, right: 16, bottom: 32}, + boxAttrs: {}, + labelAttrs: {'font-size': 10, 'line-height': 1.5, 'foo': 'bar'}, + boxLayer: o, + labelLayer: o, + }); + expect(rendered.label.text).toEqual('foo'); + expect(rendered.label.x).toEqual(5); + expect(rendered.label.y).toEqual(10); + expect(rendered.label.firstLine().parentNode).toEqual(o); + }); + + it('positions a box beneath the rendered label', () => { + const o = document.createElement('p'); + const rendered = SVGShapes.renderBoxedText('foo', { + x: 1, + y: 2, + padding: {left: 4, top: 8, right: 16, bottom: 32}, + boxAttrs: {'foo': 'bar'}, + labelAttrs: {'font-size': 10, 'line-height': 1.5}, + boxLayer: o, + labelLayer: o, + }); + expect(rendered.box.getAttribute('x')).toEqual('1'); + expect(rendered.box.getAttribute('y')).toEqual('2'); + expect(rendered.box.getAttribute('height')).toEqual('55'); + expect(rendered.box.getAttribute('foo')).toEqual('bar'); + expect(rendered.box.parentNode).toEqual(o); + }); + + it('returns the size of the rendered box', () => { + const o = document.createElement('p'); + const rendered = SVGShapes.renderBoxedText('foo', { + x: 1, + y: 2, + padding: {left: 4, top: 8, right: 16, bottom: 32}, + boxAttrs: {}, + labelAttrs: {'font-size': 10, 'line-height': 1.5}, + boxLayer: o, + labelLayer: o, + }); + expect(rendered.width).toBeGreaterThan(20 - 1); + expect(rendered.height).toEqual(55); + }); + }); +}); diff --git a/scripts/sequence/SVGTextBlock.js b/scripts/sequence/SVGTextBlock.js index 3a71395..ee5b748 100644 --- a/scripts/sequence/SVGTextBlock.js +++ b/scripts/sequence/SVGTextBlock.js @@ -1,16 +1,23 @@ define(['./SVGUtilities'], (svg) => { 'use strict'; - return class SVGTextBlock { + function fontDetails(attrs) { + const size = Number(attrs['font-size']); + const lineHeight = size * (Number(attrs['line-height']) || 1); + return { + size, + lineHeight, + }; + } + + class SVGTextBlock { constructor( container, attrs, - lineHeight, {text = '', x = 0, y = 0} = {} ) { this.container = container; this.attrs = attrs; - this.lineHeight = lineHeight; this.text = ''; this.x = x; this.y = y; @@ -21,12 +28,11 @@ define(['./SVGUtilities'], (svg) => { } _updateY() { - const sz = Number(this.attrs['font-size']); - const space = sz * this.lineHeight; + const {size, lineHeight} = fontDetails(this.attrs); this.nodes.forEach(({element}, i) => { - element.setAttribute('y', this.y + i * space + sz); + element.setAttribute('y', this.y + i * lineHeight + size); }); - this.height = space * this.nodes.length; + this.height = lineHeight * this.nodes.length; } _rebuildNodes(count) { @@ -51,6 +57,14 @@ define(['./SVGUtilities'], (svg) => { this._updateY(); } + firstLine() { + if(this.nodes.length > 0) { + return this.nodes[0].element; + } else { + return null; + } + } + setText(newText) { if(newText === this.text) { return; @@ -93,5 +107,69 @@ define(['./SVGUtilities'], (svg) => { this.width = 0; this.height = 0; } - }; + } + + class SizeTester { + constructor(container) { + this.testers = svg.make('g', {'display': 'none'}); + this.container = container; + this.cache = new Map(); + } + + measure(attrs, content) { + if(!content) { + return {width: 0, height: 0}; + } + + let tester = this.cache.get(attrs); + if(!tester) { + const text = svg.makeText(); + const node = svg.make('text', attrs); + node.appendChild(text); + this.testers.appendChild(node); + tester = {text, node}; + this.cache.set(attrs, tester); + } + + if(!this.testers.parentNode) { + this.container.appendChild(this.testers); + } + + const lines = content.split('\n'); + let width = 0; + lines.forEach((line) => { + tester.text.nodeValue = line; + width = Math.max(width, tester.node.getComputedTextLength()); + }); + + return { + width, + height: lines.length * fontDetails(attrs).lineHeight, + }; + } + + measureHeight(attrs, content) { + if(!content) { + return 0; + } + + const lines = content.split('\n'); + return lines.length * fontDetails(attrs).lineHeight; + } + + resetCache() { + svg.empty(this.testers); + this.cache.clear(); + } + + detach() { + if(this.testers.parentNode) { + this.container.removeChild(this.testers); + } + } + } + + SVGTextBlock.SizeTester = SizeTester; + + return SVGTextBlock; }); diff --git a/scripts/sequence/SVGTextBlock_spec.js b/scripts/sequence/SVGTextBlock_spec.js index 417b064..2c41719 100644 --- a/scripts/sequence/SVGTextBlock_spec.js +++ b/scripts/sequence/SVGTextBlock_spec.js @@ -1,14 +1,20 @@ -defineDescribe('SVGTextBlock', ['./SVGTextBlock'], (SVGTextBlock) => { +defineDescribe('SVGTextBlock', [ + './SVGTextBlock', + './SVGUtilities', +], ( + SVGTextBlock, + svg +) => { 'use strict'; - const attrs = {'font-size': 10}; + const attrs = {'font-size': 10, 'line-height': 1.5}; let hold = null; let block = null; beforeEach(() => { - hold = document.createElement('p'); + hold = svg.makeContainer(); document.body.appendChild(hold); - block = new SVGTextBlock(hold, attrs, 1.5); + block = new SVGTextBlock(hold, attrs); }); afterEach(() => { @@ -24,19 +30,19 @@ defineDescribe('SVGTextBlock', ['./SVGTextBlock'], (SVGTextBlock) => { }); it('adds the given text if specified', () => { - block = new SVGTextBlock(hold, attrs, 1.5, {text: 'abc'}); + block = new SVGTextBlock(hold, attrs, {text: 'abc'}); expect(block.text).toEqual('abc'); expect(hold.children.length).toEqual(1); }); it('uses the given coordinates if specified', () => { - block = new SVGTextBlock(hold, attrs, 1.5, {x: 5, y: 7}); + block = new SVGTextBlock(hold, attrs, {x: 5, y: 7}); expect(block.x).toEqual(5); expect(block.y).toEqual(7); }); }); - describe('setText', () => { + describe('.setText', () => { it('sets the text to the given content', () => { block.setText('foo'); expect(block.text).toEqual('foo'); @@ -51,6 +57,12 @@ defineDescribe('SVGTextBlock', ['./SVGTextBlock'], (SVGTextBlock) => { expect(hold.children[1].innerHTML).toEqual('bar'); }); + it('populates width and height with the size of the text', () => { + block.setText('foo\nbar'); + expect(block.width).toBeGreaterThan(0); + expect(block.height).toEqual(30); + }); + it('re-uses text nodes when possible, adding more if needed', () => { block.setText('foo\nbar'); const line0 = hold.children[0]; @@ -89,7 +101,7 @@ defineDescribe('SVGTextBlock', ['./SVGTextBlock'], (SVGTextBlock) => { }); }); - describe('reanchor', () => { + describe('.reanchor', () => { it('moves all nodes', () => { block.setText('foo\nbaz'); block.reanchor(5, 7); @@ -100,7 +112,7 @@ defineDescribe('SVGTextBlock', ['./SVGTextBlock'], (SVGTextBlock) => { }); }); - describe('clear', () => { + describe('.clear', () => { it('resets the text empty', () => { block.setText('foo\nbaz'); block.setText(''); @@ -110,4 +122,80 @@ defineDescribe('SVGTextBlock', ['./SVGTextBlock'], (SVGTextBlock) => { expect(block.height).toEqual(0); }); }); + + describe('SizeTester', () => { + let tester = null; + + beforeEach(() => { + tester = new SVGTextBlock.SizeTester(hold); + }); + + describe('.measure', () => { + it('calculates the size of the rendered text', () => { + const size = tester.measure(attrs, 'foo'); + expect(size.width).toBeGreaterThan(0); + expect(size.height).toEqual(15); + }); + + it('measures multiline text', () => { + const size = tester.measure(attrs, 'foo\nbar'); + expect(size.width).toBeGreaterThan(0); + expect(size.height).toEqual(30); + }); + + it('returns 0, 0 for empty content', () => { + const size = tester.measure(attrs, ''); + expect(size.width).toEqual(0); + expect(size.height).toEqual(0); + }); + + it('returns the maximum width for multiline text', () => { + const size0 = tester.measure(attrs, 'foo'); + const size1 = tester.measure(attrs, 'longline'); + const size = tester.measure(attrs, 'foo\nlongline\nfoo'); + expect(size1.width).toBeGreaterThan(size0.width); + expect(size.width).toEqual(size1.width); + }); + }); + + describe('.measureHeight', () => { + it('calculates the height of the rendered text', () => { + const height = tester.measureHeight(attrs, 'foo'); + expect(height).toEqual(15); + }); + + it('measures multiline text', () => { + const height = tester.measureHeight(attrs, 'foo\nbar'); + expect(height).toEqual(30); + }); + + it('returns 0 for empty content', () => { + const height = tester.measureHeight(attrs, ''); + expect(height).toEqual(0); + }); + + it('does not require the container', () => { + tester.measureHeight(attrs, 'foo'); + expect(hold.children.length).toEqual(0); + }); + }); + + describe('.detach', () => { + it('removes the test node from the DOM', () => { + tester.measure(attrs, 'foo'); + expect(hold.children.length).toEqual(1); + tester.detach(); + expect(hold.children.length).toEqual(0); + }); + + it('does not prevent using the tester again later', () => { + tester.measure(attrs, 'foo'); + tester.detach(); + + const size = tester.measure(attrs, 'foo'); + expect(hold.children.length).toEqual(1); + expect(size.width).toBeGreaterThan(0); + }); + }); + }); }); diff --git a/scripts/specs.js b/scripts/specs.js index e2d1b05..bd27690 100644 --- a/scripts/specs.js +++ b/scripts/specs.js @@ -6,4 +6,5 @@ define([ 'sequence/ArrayUtilities_spec', 'sequence/SVGUtilities_spec', 'sequence/SVGTextBlock_spec', + 'sequence/SVGShapes_spec', ]);