From b0ba84b4eb6393e803af5293d120cc378ee77d29 Mon Sep 17 00:00:00 2001 From: David Evans Date: Sat, 28 Oct 2017 12:53:41 +0100 Subject: [PATCH] Begin separating theme out of renderer --- scripts/{sequence => core}/ArrayUtilities.js | 0 .../{sequence => core}/ArrayUtilities_spec.js | 0 scripts/main.js | 7 +- scripts/sequence/Generator.js | 2 +- scripts/sequence/Renderer.js | 439 ++++++------------ scripts/sequence/Renderer_spec.js | 10 +- scripts/sequence/themes/Basic.js | 233 ++++++++++ scripts/sequence/themes/Basic_spec.js | 8 + scripts/specs.js | 9 +- scripts/{sequence => svg}/SVGShapes.js | 3 +- scripts/{sequence => svg}/SVGShapes_spec.js | 6 +- scripts/{sequence => svg}/SVGTextBlock.js | 123 ++--- .../{sequence => svg}/SVGTextBlock_spec.js | 62 +-- scripts/{sequence => svg}/SVGUtilities.js | 0 .../{sequence => svg}/SVGUtilities_spec.js | 0 15 files changed, 500 insertions(+), 402 deletions(-) rename scripts/{sequence => core}/ArrayUtilities.js (100%) rename scripts/{sequence => core}/ArrayUtilities_spec.js (100%) create mode 100644 scripts/sequence/themes/Basic.js create mode 100644 scripts/sequence/themes/Basic_spec.js rename scripts/{sequence => svg}/SVGShapes.js (96%) rename scripts/{sequence => svg}/SVGShapes_spec.js (95%) rename scripts/{sequence => svg}/SVGTextBlock.js (69%) rename scripts/{sequence => svg}/SVGTextBlock_spec.js (80%) rename scripts/{sequence => svg}/SVGUtilities.js (100%) rename scripts/{sequence => svg}/SVGUtilities_spec.js (100%) diff --git a/scripts/sequence/ArrayUtilities.js b/scripts/core/ArrayUtilities.js similarity index 100% rename from scripts/sequence/ArrayUtilities.js rename to scripts/core/ArrayUtilities.js diff --git a/scripts/sequence/ArrayUtilities_spec.js b/scripts/core/ArrayUtilities_spec.js similarity index 100% rename from scripts/sequence/ArrayUtilities_spec.js rename to scripts/core/ArrayUtilities_spec.js diff --git a/scripts/main.js b/scripts/main.js index 954257d..0acfead 100644 --- a/scripts/main.js +++ b/scripts/main.js @@ -3,16 +3,19 @@ requirejs.config(window.getRequirejsCDN()); + /* jshint -W072 */ requirejs([ 'interface/Interface', 'sequence/Parser', 'sequence/Generator', 'sequence/Renderer', + 'sequence/themes/Basic', ], ( Interface, Parser, Generator, - Renderer + Renderer, + Theme ) => { const defaultCode = ( 'title Labyrinth\n' + @@ -36,7 +39,7 @@ defaultCode, parser: new Parser(), generator: new Generator(), - renderer: new Renderer(), + renderer: new Renderer(new Theme()), }); ui.build(document.body); }); diff --git a/scripts/sequence/Generator.js b/scripts/sequence/Generator.js index d5e6fc1..30c5788 100644 --- a/scripts/sequence/Generator.js +++ b/scripts/sequence/Generator.js @@ -1,4 +1,4 @@ -define(['./ArrayUtilities'], (array) => { +define(['core/ArrayUtilities'], (array) => { 'use strict'; class AgentState { diff --git a/scripts/sequence/Renderer.js b/scripts/sequence/Renderer.js index 57ef413..5e107ea 100644 --- a/scripts/sequence/Renderer.js +++ b/scripts/sequence/Renderer.js @@ -1,8 +1,8 @@ define([ - './ArrayUtilities', - './SVGUtilities', - './SVGTextBlock', - './SVGShapes', + 'core/ArrayUtilities', + 'svg/SVGUtilities', + 'svg/SVGTextBlock', + 'svg/SVGShapes', ], ( array, svg, @@ -13,219 +13,6 @@ define([ const SEP_ZERO = {left: 0, right: 0}; - const LINE_HEIGHT = 1.3; - const TITLE_MARGIN = 10; - const OUTER_MARGIN = 5; - const AGENT_MARGIN = 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': { - 'fill': 'none', - 'stroke': '#000000', - 'stroke-width': 1, - }, - 'dash': { - 'fill': 'none', - 'stroke': '#000000', - 'stroke-width': 1, - 'stroke-dasharray': '4, 2', - }, - }, - arrow: { - width: 4, - height: 8, - attrs: { - 'fill': '#000000', - 'stroke': '#000000', - 'stroke-width': 1, - 'stroke-linejoin': 'miter', - }, - }, - label: { - padding: 6, - margin: {top: 2, bottom: 1}, - attrs: { - 'font-family': 'sans-serif', - 'font-size': 8, - 'line-height': LINE_HEIGHT, - 'text-anchor': 'middle', - }, - }, - mask: { - padding: { - top: 0, - left: 3, - right: 3, - bottom: 0, - }, - maskAttrs: { - 'fill': '#FFFFFF', - }, - }, - }; - - const BLOCK = { - margin: { - top: 0, - bottom: 0, - }, - boxAttrs: { - 'fill': 'none', - 'stroke': '#000000', - 'stroke-width': 1.5, - 'rx': 2, - 'ry': 2, - }, - section: { - padding: { - top: 3, - bottom: 2, - }, - mode: { - padding: { - top: 1, - left: 3, - right: 3, - bottom: 0, - }, - boxAttrs: { - 'fill': '#FFFFFF', - 'stroke': '#000000', - 'stroke-width': 1, - 'rx': 2, - 'ry': 2, - }, - labelAttrs: { - 'font-family': 'sans-serif', - 'font-weight': 'bold', - 'font-size': 9, - 'line-height': LINE_HEIGHT, - 'text-anchor': 'left', - }, - }, - label: { - padding: { - top: 1, - left: 5, - right: 3, - bottom: 0, - }, - maskAttrs: { - 'fill': '#FFFFFF', - }, - labelAttrs: { - 'font-family': 'sans-serif', - 'font-size': 8, - 'line-height': LINE_HEIGHT, - 'text-anchor': 'left', - }, - }, - }, - separator: { - attrs: { - 'stroke': '#000000', - 'stroke-width': 1.5, - 'stroke-dasharray': '4, 2', - }, - }, - }; - - 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: SVGShapes.renderNote.bind(null, { - 'fill': '#FFFFFF', - 'stroke': '#000000', - 'stroke-width': 1, - }, { - 'fill': 'none', - 'stroke': '#000000', - 'stroke-width': 1, - }), - 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: SVGShapes.renderBox.bind(null, { - 'fill': '#FFFFFF', - 'stroke': '#000000', - 'stroke-width': 1, - 'rx': 10, - 'ry': 10, - }), - labelAttrs: { - 'font-family': 'sans-serif', - 'font-size': 8, - 'line-height': LINE_HEIGHT, - }, - }, - }; - - const ATTRS = { - TITLE: { - 'font-family': 'sans-serif', - 'font-size': 20, - 'line-height': LINE_HEIGHT, - 'text-anchor': 'middle', - 'class': 'title', - }, - - AGENT_LINE: { - 'fill': 'none', - 'stroke': '#000000', - 'stroke-width': 1, - }, - }; - function drawHorizontalArrowHead(container, {x, y, dx, dy, attrs}) { container.appendChild(svg.make( attrs.fill === 'none' ? 'polyline' : 'polygon', @@ -265,7 +52,7 @@ define([ } return class Renderer { - constructor() { + constructor(theme) { this.separationAgentCap = { 'box': this.separationAgentCapBox.bind(this), 'cross': this.separationAgentCapCross.bind(this), @@ -317,6 +104,8 @@ define([ this.width = 0; this.height = 0; + this.theme = theme; + this.currentSequence = null; this.buildStaticElements(); } @@ -338,7 +127,7 @@ define([ this.base.appendChild(this.sections); this.base.appendChild(this.actionShapes); this.base.appendChild(this.actionLabels); - this.title = new SVGTextBlock(this.base, ATTRS.TITLE); + this.title = new SVGTextBlock(this.base); this.sizer = new SVGTextBlock.SizeTester(this.base); } @@ -385,17 +174,18 @@ define([ this.addSeparation( agentR, agentL, - sepR.left + sepL.right + AGENT_MARGIN + sepR.left + sepL.right + this.theme.agentMargin ); }); }); } separationAgentCapBox({label}) { + const config = this.theme.agentCap.box; const width = ( - this.sizer.measure(AGENT_CAP.box.labelAttrs, label).width + - AGENT_CAP.box.padding.left + - AGENT_CAP.box.padding.right + this.sizer.measure(config.labelAttrs, label).width + + config.padding.left + + config.padding.right ); return { @@ -405,17 +195,19 @@ define([ } separationAgentCapCross() { + const config = this.theme.agentCap.cross; return { - left: AGENT_CAP.cross.size / 2, - right: AGENT_CAP.cross.size / 2, + left: config.size / 2, + right: config.size / 2, }; } separationAgentCapBar({label}) { + const config = this.theme.agentCap.box; const width = ( - this.sizer.measure(AGENT_CAP.box.labelAttrs, label).width + - AGENT_CAP.box.padding.left + - AGENT_CAP.box.padding.right + this.sizer.measure(config.labelAttrs, label).width + + config.padding.left + + config.padding.right ); return { @@ -447,19 +239,21 @@ define([ } separationConnection({agents, label}) { + const config = this.theme.connect; + this.addSeparation( agents[0], agents[1], - this.sizer.measure(CONNECT.label.attrs, label).width + - CONNECT.arrow.width * 2 + - CONNECT.label.padding * 2 + - ATTRS.AGENT_LINE['stroke-width'] + this.sizer.measure(config.label.attrs, label).width + + config.arrow.width * 2 + + config.label.padding * 2 + + this.theme.agentLineAttrs['stroke-width'] ); } separationNoteOver({agents, mode, label}) { - const config = NOTE[mode]; + const config = this.theme.note[mode]; const width = ( this.sizer.measure(config.labelAttrs, label).width + config.padding.left + @@ -491,7 +285,7 @@ define([ } separationNoteLeft({agents, mode, label}) { - const config = NOTE[mode]; + const config = this.theme.note[mode]; const {left} = this.findExtremes(agents); const agentSpaces = new Map(); @@ -509,7 +303,7 @@ define([ } separationNoteRight({agents, mode, label}) { - const config = NOTE[mode]; + const config = this.theme.note[mode]; const {right} = this.findExtremes(agents); const agentSpaces = new Map(); @@ -527,7 +321,7 @@ define([ } separationNoteBetween({agents, mode, label}) { - const config = NOTE[mode]; + const config = this.theme.note[mode]; const {left, right} = this.findExtremes(agents); this.addSeparation( @@ -548,7 +342,7 @@ define([ } separationSectionBegin(scope, {left, right}, {mode, label}) { - const config = BLOCK.section; + const config = this.theme.block.section; const width = ( this.sizer.measure(config.mode.labelAttrs, mode).width + config.mode.padding.left + @@ -569,12 +363,13 @@ define([ } renderAgentCapBox({x, label}) { + const config = this.theme.agentCap.box; const {height} = SVGShapes.renderBoxedText(label, { x, y: this.currentY, - padding: AGENT_CAP.box.padding, - boxAttrs: AGENT_CAP.box.boxAttrs, - labelAttrs: AGENT_CAP.box.labelAttrs, + padding: config.padding, + boxAttrs: config.boxAttrs, + labelAttrs: config.labelAttrs, boxLayer: this.actionShapes, labelLayer: this.actionLabels, }); @@ -587,8 +382,9 @@ define([ } renderAgentCapCross({x}) { + const config = this.theme.agentCap.cross; const y = this.currentY; - const d = AGENT_CAP.cross.size / 2; + const d = config.size / 2; this.actionShapes.appendChild(svg.make('path', Object.assign({ 'd': ( @@ -597,7 +393,7 @@ define([ ' M ' + (x + d) + ' ' + y + ' L ' + (x - d) + ' ' + (y + d * 2) ), - }, AGENT_CAP.cross.attrs))); + }, config.attrs))); return { lineTop: d, @@ -607,30 +403,33 @@ define([ } renderAgentCapBar({x, label}) { + const configB = this.theme.agentCap.box; + const config = this.theme.agentCap.bar; const width = ( - this.sizer.measure(AGENT_CAP.box.labelAttrs, label).width + - AGENT_CAP.box.padding.left + - AGENT_CAP.box.padding.right + this.sizer.measure(configB.labelAttrs, label).width + + configB.padding.left + + configB.padding.right ); this.actionShapes.appendChild(svg.make('rect', Object.assign({ 'x': x - width / 2, 'y': this.currentY, 'width': width, - }, AGENT_CAP.bar.attrs))); + }, config.attrs))); return { lineTop: 0, - lineBottom: AGENT_CAP.bar.attrs.height, - height: AGENT_CAP.bar.attrs.height, + lineBottom: config.attrs.height, + height: config.attrs.height, }; } renderAgentCapNone() { + const config = this.theme.agentCap.none; return { - lineTop: AGENT_CAP.none.height, + lineTop: config.height, lineBottom: 0, - height: AGENT_CAP.none.height, + height: config.height, }; } @@ -642,7 +441,7 @@ define([ maxHeight = Math.max(maxHeight, shifts.height); agentInfo.latestYStart = this.currentY + shifts.lineBottom; }); - this.currentY += maxHeight + ACTION_MARGIN; + this.currentY += maxHeight + this.theme.actionMargin; } renderAgentEnd({mode, agents}) { @@ -658,34 +457,35 @@ define([ 'x2': x, 'y2': this.currentY + shifts.lineTop, 'class': 'agent-' + agentInfo.index + '-line', - }, ATTRS.AGENT_LINE))); + }, this.theme.agentLineAttrs))); agentInfo.latestYStart = null; }); - this.currentY += maxHeight + ACTION_MARGIN; + this.currentY += maxHeight + this.theme.actionMargin; } renderConnection({label, agents, line, left, right}) { + const config = this.theme.connect; const from = this.agentInfos.get(agents[0]); const to = this.agentInfos.get(agents[1]); - const dy = CONNECT.arrow.height / 2; + const dy = config.arrow.height / 2; const dir = (from.x < to.x) ? 1 : -1; - const short = ATTRS.AGENT_LINE['stroke-width']; + const short = this.theme.agentLineAttrs['stroke-width']; const height = ( - this.sizer.measureHeight(CONNECT.label.attrs, label) + - CONNECT.label.margin.top + - CONNECT.label.margin.bottom + this.sizer.measureHeight(config.label.attrs, label) + + config.label.margin.top + + config.label.margin.bottom ); 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, + y: y - height + config.label.margin.top, + padding: config.mask.padding, + boxAttrs: config.mask.maskAttrs, + labelAttrs: config.label.attrs, boxLayer: this.mask, labelLayer: this.actionLabels, }); @@ -695,15 +495,15 @@ define([ 'y1': y, 'x2': to.x - (right ? short : 0) * dir, 'y2': y, - }, CONNECT.lineAttrs[line]))); + }, config.lineAttrs[line]))); if(left) { drawHorizontalArrowHead(this.actionShapes, { x: from.x + short * dir, y, - dx: CONNECT.arrow.width * dir, + dx: config.arrow.width * dir, dy, - attrs: CONNECT.arrow.attrs, + attrs: config.arrow.attrs, }); } @@ -711,26 +511,26 @@ define([ drawHorizontalArrowHead(this.actionShapes, { x: to.x - short * dir, y, - dx: -CONNECT.arrow.width * dir, + dx: -config.arrow.width * dir, dy, - attrs: CONNECT.arrow.attrs, + attrs: config.arrow.attrs, }); } - this.currentY = y + dy + ACTION_MARGIN; + this.currentY = y + dy + this.theme.actionMargin; } renderNote({xMid = null, x0 = null, x1 = null}, anchor, mode, label) { - const config = NOTE[mode]; + const config = this.theme.note[mode]; this.currentY += config.margin.top; const y = this.currentY + config.padding.top; - const labelNode = new SVGTextBlock( - this.actionLabels, - config.labelAttrs, - {text: label, y} - ); + const labelNode = new SVGTextBlock(this.actionLabels, { + attrs: config.labelAttrs, + text: label, + y, + }); const fullW = ( labelNode.width + @@ -752,16 +552,19 @@ define([ } switch(config.labelAttrs['text-anchor']) { case 'middle': - labelNode.reanchor(( - x0 + config.padding.left + - x1 - config.padding.right - ) / 2, y); + labelNode.set({ + x: ( + x0 + config.padding.left + + x1 - config.padding.right + ) / 2, + y, + }); break; case 'end': - labelNode.reanchor(x1 - config.padding.right, y); + labelNode.set({x: x1 - config.padding.right, y}); break; default: - labelNode.reanchor(x0 + config.padding.left, y); + labelNode.set({x: x0 + config.padding.left, y}); break; } @@ -775,12 +578,12 @@ define([ this.currentY += ( fullH + config.margin.bottom + - ACTION_MARGIN + this.theme.actionMargin ); } renderNoteOver({agents, mode, label}) { - const config = NOTE[mode]; + const config = this.theme.note[mode]; if(agents.length > 1) { const {left, right} = this.findExtremes(agents); @@ -795,7 +598,7 @@ define([ } renderNoteLeft({agents, mode, label}) { - const config = NOTE[mode]; + const config = this.theme.note[mode]; const {left} = this.findExtremes(agents); const x1 = this.agentInfos.get(left).x - config.margin.right; @@ -803,7 +606,7 @@ define([ } renderNoteRight({agents, mode, label}) { - const config = NOTE[mode]; + const config = this.theme.note[mode]; const {right} = this.findExtremes(agents); const x0 = this.agentInfos.get(right).x + config.margin.left; @@ -821,34 +624,35 @@ define([ } renderBlockBegin(scope) { - this.currentY += BLOCK.margin.top; + this.currentY += this.theme.block.margin.top; scope.y = this.currentY; scope.first = true; } renderSectionBegin(scope, {left, right}, {mode, label}) { + const config = this.theme.block; const agentInfoL = this.agentInfos.get(left); const agentInfoR = this.agentInfos.get(right); if(scope.first) { scope.first = false; } else { - this.currentY += BLOCK.section.padding.bottom; + this.currentY += config.section.padding.bottom; this.sections.appendChild(svg.make('line', Object.assign({ 'x1': agentInfoL.x, 'y1': this.currentY, 'x2': agentInfoR.x, 'y2': this.currentY, - }, BLOCK.separator.attrs))); + }, config.separator.attrs))); } 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, + padding: config.section.mode.padding, + boxAttrs: config.section.mode.boxAttrs, + labelAttrs: config.section.mode.labelAttrs, boxLayer: this.blocks, labelLayer: this.actionLabels, }); @@ -856,16 +660,16 @@ define([ 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, + padding: config.section.label.padding, + boxAttrs: config.section.label.maskAttrs, + labelAttrs: config.section.label.labelAttrs, boxLayer: this.mask, labelLayer: this.actionLabels, }); this.currentY += ( Math.max(modeRender.height, labelRender.height) + - BLOCK.section.padding.top + config.section.padding.top ); } @@ -873,7 +677,8 @@ define([ } renderBlockEnd(scope, {left, right}) { - this.currentY += BLOCK.section.padding.bottom; + const config = this.theme.block; + this.currentY += config.section.padding.bottom; const agentInfoL = this.agentInfos.get(left); const agentInfoR = this.agentInfos.get(right); @@ -882,9 +687,9 @@ define([ 'y': scope.y, 'width': agentInfoR.x - agentInfoL.x, 'height': this.currentY - scope.y, - }, BLOCK.boxAttrs))); + }, config.boxAttrs))); - this.currentY += BLOCK.margin.bottom + ACTION_MARGIN; + this.currentY += config.margin.bottom + this.theme.actionMargin; } addAction(stage) { @@ -924,15 +729,16 @@ define([ updateBounds(stagesHeight) { const cx = (this.minX + this.maxX) / 2; const titleY = ((this.title.height > 0) ? - (-TITLE_MARGIN - this.title.height) : 0 + (-this.theme.titleMargin - this.title.height) : 0 ); - this.title.reanchor(cx, titleY); + this.title.set({x: cx, y: titleY}); const halfTitleWidth = this.title.width / 2; - const x0 = Math.min(this.minX, cx - halfTitleWidth) - OUTER_MARGIN; - const x1 = Math.max(this.maxX, cx + halfTitleWidth) + OUTER_MARGIN; - const y0 = titleY - OUTER_MARGIN; - const y1 = stagesHeight + OUTER_MARGIN; + const margin = this.theme.outerMargin; + const x0 = Math.min(this.minX, cx - halfTitleWidth) - margin; + const x1 = Math.max(this.maxX, cx + halfTitleWidth) + margin; + const y0 = titleY - margin; + const y1 = stagesHeight + margin; this.base.setAttribute('viewBox', ( x0 + ' ' + y0 + ' ' + @@ -942,7 +748,17 @@ define([ this.height = (y1 - y0); } - render({meta, agents, stages}) { + setTheme(theme) { + if(this.theme === theme) { + return; + } + this.theme = theme; + if(this.currentSequence) { + this.render(this.currentSequence); + } + } + + render(sequence) { svg.empty(this.agentLines); svg.empty(this.mask); svg.empty(this.blocks); @@ -950,20 +766,27 @@ define([ svg.empty(this.actionShapes); svg.empty(this.actionLabels); - this.title.setText(meta.title); + this.title.set({ + attrs: this.theme.titleAttrs, + text: sequence.meta.title, + }); this.minX = 0; this.maxX = 0; - this.buildAgentInfos(agents, stages); + this.buildAgentInfos(sequence.agents, sequence.stages); this.currentY = 0; - traverse(stages, this.renderTraversalFns); + traverse(sequence.stages, this.renderTraversalFns); - const stagesHeight = Math.max(this.currentY - ACTION_MARGIN, 0); + const stagesHeight = Math.max( + this.currentY - this.theme.actionMargin, + 0 + ); this.updateBounds(stagesHeight); this.sizer.resetCache(); this.sizer.detach(); + this.currentSequence = sequence; } getAgentX(name) { diff --git a/scripts/sequence/Renderer_spec.js b/scripts/sequence/Renderer_spec.js index 515259e..2f7441b 100644 --- a/scripts/sequence/Renderer_spec.js +++ b/scripts/sequence/Renderer_spec.js @@ -1,10 +1,16 @@ -defineDescribe('Sequence Renderer', ['./Renderer'], (Renderer) => { +defineDescribe('Sequence Renderer', [ + './Renderer', + './themes/Basic', +], ( + Renderer, + Theme +) => { 'use strict'; let renderer = null; beforeEach(() => { - renderer = new Renderer(); + renderer = new Renderer(new Theme()); document.body.appendChild(renderer.svg()); }); diff --git a/scripts/sequence/themes/Basic.js b/scripts/sequence/themes/Basic.js new file mode 100644 index 0000000..d44ed96 --- /dev/null +++ b/scripts/sequence/themes/Basic.js @@ -0,0 +1,233 @@ +define([ + 'core/ArrayUtilities', + 'svg/SVGUtilities', + 'svg/SVGTextBlock', + 'svg/SVGShapes', +], ( + array, + svg, + SVGTextBlock, + SVGShapes +) => { + 'use strict'; + + const LINE_HEIGHT = 1.3; + + const SETTINGS = { + titleMargin: 10, + outerMargin: 5, + agentMargin: 10, + actionMargin: 5, + + agentCap: { + 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, + }, + }, + + connect: { + lineAttrs: { + 'solid': { + 'fill': 'none', + 'stroke': '#000000', + 'stroke-width': 1, + }, + 'dash': { + 'fill': 'none', + 'stroke': '#000000', + 'stroke-width': 1, + 'stroke-dasharray': '4, 2', + }, + }, + arrow: { + width: 4, + height: 8, + attrs: { + 'fill': '#000000', + 'stroke': '#000000', + 'stroke-width': 1, + 'stroke-linejoin': 'miter', + }, + }, + label: { + padding: 6, + margin: {top: 2, bottom: 1}, + attrs: { + 'font-family': 'sans-serif', + 'font-size': 8, + 'line-height': LINE_HEIGHT, + 'text-anchor': 'middle', + }, + }, + mask: { + padding: { + top: 0, + left: 3, + right: 3, + bottom: 0, + }, + maskAttrs: { + 'fill': '#FFFFFF', + }, + }, + }, + + block: { + margin: { + top: 0, + bottom: 0, + }, + boxAttrs: { + 'fill': 'none', + 'stroke': '#000000', + 'stroke-width': 1.5, + 'rx': 2, + 'ry': 2, + }, + section: { + padding: { + top: 3, + bottom: 2, + }, + mode: { + padding: { + top: 1, + left: 3, + right: 3, + bottom: 0, + }, + boxAttrs: { + 'fill': '#FFFFFF', + 'stroke': '#000000', + 'stroke-width': 1, + 'rx': 2, + 'ry': 2, + }, + labelAttrs: { + 'font-family': 'sans-serif', + 'font-weight': 'bold', + 'font-size': 9, + 'line-height': LINE_HEIGHT, + 'text-anchor': 'left', + }, + }, + label: { + padding: { + top: 1, + left: 5, + right: 3, + bottom: 0, + }, + maskAttrs: { + 'fill': '#FFFFFF', + }, + labelAttrs: { + 'font-family': 'sans-serif', + 'font-size': 8, + 'line-height': LINE_HEIGHT, + 'text-anchor': 'left', + }, + }, + }, + separator: { + attrs: { + 'stroke': '#000000', + 'stroke-width': 1.5, + 'stroke-dasharray': '4, 2', + }, + }, + }, + + 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: SVGShapes.renderNote.bind(null, { + 'fill': '#FFFFFF', + 'stroke': '#000000', + 'stroke-width': 1, + }, { + 'fill': 'none', + 'stroke': '#000000', + 'stroke-width': 1, + }), + 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: SVGShapes.renderBox.bind(null, { + 'fill': '#FFFFFF', + 'stroke': '#000000', + 'stroke-width': 1, + 'rx': 10, + 'ry': 10, + }), + labelAttrs: { + 'font-family': 'sans-serif', + 'font-size': 8, + 'line-height': LINE_HEIGHT, + }, + }, + }, + + titleAttrs: { + 'font-family': 'sans-serif', + 'font-size': 20, + 'line-height': LINE_HEIGHT, + 'text-anchor': 'middle', + 'class': 'title', + }, + + agentLineAttrs: { + 'fill': 'none', + 'stroke': '#000000', + 'stroke-width': 1, + }, + }; + + return class Theme { + constructor() { + Object.assign(this, SETTINGS); + } + }; +}); diff --git a/scripts/sequence/themes/Basic_spec.js b/scripts/sequence/themes/Basic_spec.js new file mode 100644 index 0000000..02a7ed3 --- /dev/null +++ b/scripts/sequence/themes/Basic_spec.js @@ -0,0 +1,8 @@ +defineDescribe('Basic Theme', ['./Basic'], (Theme) => { + 'use strict'; + + it('contains settings for the theme', () => { + const theme = new Theme(); + expect(theme.outerMargin).toEqual(5); + }); +}); diff --git a/scripts/specs.js b/scripts/specs.js index bd27690..c5295df 100644 --- a/scripts/specs.js +++ b/scripts/specs.js @@ -1,10 +1,11 @@ define([ + 'core/ArrayUtilities_spec', + 'svg/SVGUtilities_spec', + 'svg/SVGTextBlock_spec', + 'svg/SVGShapes_spec', 'interface/Interface_spec', 'sequence/Parser_spec', 'sequence/Generator_spec', 'sequence/Renderer_spec', - 'sequence/ArrayUtilities_spec', - 'sequence/SVGUtilities_spec', - 'sequence/SVGTextBlock_spec', - 'sequence/SVGShapes_spec', + 'sequence/themes/Basic_spec', ]); diff --git a/scripts/sequence/SVGShapes.js b/scripts/svg/SVGShapes.js similarity index 96% rename from scripts/sequence/SVGShapes.js rename to scripts/svg/SVGShapes.js index f70828e..d17c648 100644 --- a/scripts/sequence/SVGShapes.js +++ b/scripts/svg/SVGShapes.js @@ -65,7 +65,8 @@ define(['./SVGUtilities', './SVGTextBlock'], (svg, SVGTextBlock) => { break; } - const label = new SVGTextBlock(labelLayer, labelAttrs, { + const label = new SVGTextBlock(labelLayer, { + attrs: labelAttrs, text, x: anchorX, y: y + padding.top, diff --git a/scripts/sequence/SVGShapes_spec.js b/scripts/svg/SVGShapes_spec.js similarity index 95% rename from scripts/sequence/SVGShapes_spec.js rename to scripts/svg/SVGShapes_spec.js index fc37c88..1b12eb6 100644 --- a/scripts/sequence/SVGShapes_spec.js +++ b/scripts/svg/SVGShapes_spec.js @@ -65,9 +65,9 @@ defineDescribe('SVGShapes', ['./SVGShapes'], (SVGShapes) => { 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.state.text).toEqual('foo'); + expect(rendered.label.state.x).toEqual(5); + expect(rendered.label.state.y).toEqual(10); expect(rendered.label.firstLine().parentNode).toEqual(o); }); diff --git a/scripts/sequence/SVGTextBlock.js b/scripts/svg/SVGTextBlock.js similarity index 69% rename from scripts/sequence/SVGTextBlock.js rename to scripts/svg/SVGTextBlock.js index ee5b748..517cf76 100644 --- a/scripts/sequence/SVGTextBlock.js +++ b/scripts/svg/SVGTextBlock.js @@ -10,37 +10,37 @@ define(['./SVGUtilities'], (svg) => { }; } + function merge(state, newState) { + for(let k in state) { + if(state.hasOwnProperty(k)) { + if(newState[k] !== null && newState[k] !== undefined) { + state[k] = newState[k]; + } + } + } + } + class SVGTextBlock { - constructor( - container, - attrs, - {text = '', x = 0, y = 0} = {} - ) { + constructor(container, initialState = {}) { this.container = container; - this.attrs = attrs; - this.text = ''; - this.x = x; - this.y = y; + this.state = { + attrs: {}, + text: '', + x: 0, + y: 0, + }; this.width = 0; this.height = 0; this.nodes = []; - this.setText(text); - } - - _updateY() { - const {size, lineHeight} = fontDetails(this.attrs); - this.nodes.forEach(({element}, i) => { - element.setAttribute('y', this.y + i * lineHeight + size); - }); - this.height = lineHeight * this.nodes.length; + this.set(initialState); } _rebuildNodes(count) { - if(count === this.nodes.length) { - return; - } if(count > this.nodes.length) { - const attrs = Object.assign({'x': this.x}, this.attrs); + const attrs = Object.assign({ + 'x': this.state.x, + }, this.state.attrs); + while(this.nodes.length < count) { const element = svg.make('text', attrs); const text = svg.makeText(); @@ -54,29 +54,23 @@ define(['./SVGUtilities'], (svg) => { this.container.removeChild(element); } } - this._updateY(); } - firstLine() { - if(this.nodes.length > 0) { - return this.nodes[0].element; - } else { - return null; - } + _reset() { + this._rebuildNodes(0); + this.width = 0; + this.height = 0; } - setText(newText) { - if(newText === this.text) { + _renderText() { + if(!this.state.text) { + this._reset(); return; } - if(!newText) { - this.clear(); - return; - } - this.text = newText; - const lines = this.text.split('\n'); + const lines = this.state.text.split('\n'); this._rebuildNodes(lines.length); + let maxWidth = 0; this.nodes.forEach(({text, element}, i) => { if(text.nodeValue !== lines[i]) { @@ -87,25 +81,50 @@ define(['./SVGUtilities'], (svg) => { this.width = maxWidth; } - reanchor(newX, newY) { - if(newX !== this.x) { - this.x = newX; - this.nodes.forEach(({element}) => { - element.setAttribute('x', this.x); - }); - } + _updateX() { + this.nodes.forEach(({element}) => { + element.setAttribute('x', this.state.x); + }); + } - if(newY !== this.y) { - this.y = newY; - this._updateY(); + _updateY() { + const {size, lineHeight} = fontDetails(this.state.attrs); + this.nodes.forEach(({element}, i) => { + element.setAttribute('y', this.state.y + i * lineHeight + size); + }); + this.height = lineHeight * this.nodes.length; + } + + firstLine() { + if(this.nodes.length > 0) { + return this.nodes[0].element; + } else { + return null; } } - clear() { - this._rebuildNodes(0); - this.text = ''; - this.width = 0; - this.height = 0; + set(newState) { + const oldState = Object.assign({}, this.state); + merge(this.state, newState); + + if(this.state.attrs !== oldState.attrs) { + this._reset(); + oldState.text = ''; + } + + const oldNodes = this.nodes.length; + + if(this.state.text !== oldState.text) { + this._renderText(); + } + + if(this.state.x !== oldState.x) { + this._updateX(); + } + + if(this.state.y !== oldState.y || this.nodes.length !== oldNodes) { + this._updateY(); + } } } diff --git a/scripts/sequence/SVGTextBlock_spec.js b/scripts/svg/SVGTextBlock_spec.js similarity index 80% rename from scripts/sequence/SVGTextBlock_spec.js rename to scripts/svg/SVGTextBlock_spec.js index 2c41719..3d581f8 100644 --- a/scripts/sequence/SVGTextBlock_spec.js +++ b/scripts/svg/SVGTextBlock_spec.js @@ -14,7 +14,7 @@ defineDescribe('SVGTextBlock', [ beforeEach(() => { hold = svg.makeContainer(); document.body.appendChild(hold); - block = new SVGTextBlock(hold, attrs); + block = new SVGTextBlock(hold, {attrs}); }); afterEach(() => { @@ -23,52 +23,60 @@ defineDescribe('SVGTextBlock', [ describe('constructor', () => { it('defaults to blank text at 0, 0', () => { - expect(block.text).toEqual(''); - expect(block.x).toEqual(0); - expect(block.y).toEqual(0); + expect(block.state.text).toEqual(''); + expect(block.state.x).toEqual(0); + expect(block.state.y).toEqual(0); + expect(hold.children.length).toEqual(0); + }); + + it('does not explode if given no setup', () => { + block = new SVGTextBlock(hold); + expect(block.state.text).toEqual(''); + expect(block.state.x).toEqual(0); + expect(block.state.y).toEqual(0); expect(hold.children.length).toEqual(0); }); it('adds the given text if specified', () => { - block = new SVGTextBlock(hold, attrs, {text: 'abc'}); - expect(block.text).toEqual('abc'); + block = new SVGTextBlock(hold, {attrs, text: 'abc'}); + expect(block.state.text).toEqual('abc'); expect(hold.children.length).toEqual(1); }); it('uses the given coordinates if specified', () => { - block = new SVGTextBlock(hold, attrs, {x: 5, y: 7}); - expect(block.x).toEqual(5); - expect(block.y).toEqual(7); + block = new SVGTextBlock(hold, {attrs, x: 5, y: 7}); + expect(block.state.x).toEqual(5); + expect(block.state.y).toEqual(7); }); }); - describe('.setText', () => { + describe('.set', () => { it('sets the text to the given content', () => { - block.setText('foo'); - expect(block.text).toEqual('foo'); + block.set({text: 'foo'}); + expect(block.state.text).toEqual('foo'); expect(hold.children.length).toEqual(1); expect(hold.children[0].innerHTML).toEqual('foo'); }); it('renders multiline text', () => { - block.setText('foo\nbar'); + block.set({text: 'foo\nbar'}); expect(hold.children.length).toEqual(2); expect(hold.children[0].innerHTML).toEqual('foo'); expect(hold.children[1].innerHTML).toEqual('bar'); }); it('populates width and height with the size of the text', () => { - block.setText('foo\nbar'); + block.set({text: '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'); + block.set({text: 'foo\nbar'}); const line0 = hold.children[0]; const line1 = hold.children[1]; - block.setText('zig\nzag\nbaz'); + block.set({text: 'zig\nzag\nbaz'}); expect(hold.children.length).toEqual(3); expect(hold.children[0]).toEqual(line0); @@ -79,10 +87,10 @@ defineDescribe('SVGTextBlock', [ }); it('re-uses text nodes when possible, removing extra if needed', () => { - block.setText('foo\nbar'); + block.set({text: 'foo\nbar'}); const line0 = hold.children[0]; - block.setText('zig'); + block.set({text: 'zig'}); expect(hold.children.length).toEqual(1); expect(hold.children[0]).toEqual(line0); @@ -90,7 +98,7 @@ defineDescribe('SVGTextBlock', [ }); it('positions text nodes and applies attributes', () => { - block.setText('foo\nbar'); + block.set({text: 'foo\nbar'}); expect(hold.children.length).toEqual(2); expect(hold.children[0].getAttribute('x')).toEqual('0'); expect(hold.children[0].getAttribute('y')).toEqual('10'); @@ -99,25 +107,21 @@ defineDescribe('SVGTextBlock', [ expect(hold.children[1].getAttribute('y')).toEqual('25'); expect(hold.children[1].getAttribute('font-size')).toEqual('10'); }); - }); - describe('.reanchor', () => { it('moves all nodes', () => { - block.setText('foo\nbaz'); - block.reanchor(5, 7); + block.set({text: 'foo\nbaz'}); + block.set({x: 5, y: 7}); expect(hold.children[0].getAttribute('x')).toEqual('5'); expect(hold.children[0].getAttribute('y')).toEqual('17'); expect(hold.children[1].getAttribute('x')).toEqual('5'); expect(hold.children[1].getAttribute('y')).toEqual('32'); }); - }); - describe('.clear', () => { - it('resets the text empty', () => { - block.setText('foo\nbaz'); - block.setText(''); + it('clears if the text is empty', () => { + block.set({text: 'foo\nbaz'}); + block.set({text: ''}); expect(hold.children.length).toEqual(0); - expect(block.text).toEqual(''); + expect(block.state.text).toEqual(''); expect(block.width).toEqual(0); expect(block.height).toEqual(0); }); diff --git a/scripts/sequence/SVGUtilities.js b/scripts/svg/SVGUtilities.js similarity index 100% rename from scripts/sequence/SVGUtilities.js rename to scripts/svg/SVGUtilities.js diff --git a/scripts/sequence/SVGUtilities_spec.js b/scripts/svg/SVGUtilities_spec.js similarity index 100% rename from scripts/sequence/SVGUtilities_spec.js rename to scripts/svg/SVGUtilities_spec.js