diff --git a/README.md b/README.md index 3abb451..62c1c60 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,9 @@ Foo -> Bar Foo -> Foo: Foo talks to itself +Foo -> +Bar: Foo asks Bar +-Bar --> Foo: and Bar replies + # Arrows leaving on the left and right of the diagram [ -> Foo: From the left [ <- Foo: To the left diff --git a/screenshots/ConnectionTypes.png b/screenshots/ConnectionTypes.png index 0bd2f35..428b4f8 100644 Binary files a/screenshots/ConnectionTypes.png and b/screenshots/ConnectionTypes.png differ diff --git a/scripts/core/ArrayUtilities.js b/scripts/core/ArrayUtilities.js index 1c3efc0..7c2c5a1 100644 --- a/scripts/core/ArrayUtilities.js +++ b/scripts/core/ArrayUtilities.js @@ -24,6 +24,15 @@ define(() => { } } + function hasIntersection(a, b, equalityCheck = null) { + for(let i = 0; i < b.length; ++ i) { + if(indexOf(a, b[i], equalityCheck) !== -1) { + return true; + } + } + return false; + } + function removeAll(target, b = null, equalityCheck = null) { if(!b) { return; @@ -50,6 +59,7 @@ define(() => { return { indexOf, mergeSets, + hasIntersection, removeAll, remove, last, diff --git a/scripts/core/ArrayUtilities_spec.js b/scripts/core/ArrayUtilities_spec.js index f80914a..931b904 100644 --- a/scripts/core/ArrayUtilities_spec.js +++ b/scripts/core/ArrayUtilities_spec.js @@ -65,6 +65,26 @@ defineDescribe('ArrayUtilities', ['./ArrayUtilities'], (array) => { }); }); + describe('.hasIntersection', () => { + it('returns true if any elements are shared between the sets', () => { + const p1 = ['a', 'b']; + const p2 = ['b', 'c']; + expect(array.hasIntersection(p1, p2)).toEqual(true); + }); + + it('returns false if no elements are shared between the sets', () => { + const p1 = ['a', 'b']; + const p2 = ['c', 'd']; + expect(array.hasIntersection(p1, p2)).toEqual(false); + }); + + it('uses the given equality check function', () => { + const p1 = ['a', 'b']; + const p2 = ['B', 'c']; + expect(array.hasIntersection(p1, p2, ignoreCase)).toEqual(true); + }); + }); + describe('.removeAll', () => { it('removes elements from the first array', () => { const p1 = ['a', 'b', 'c']; diff --git a/scripts/sequence/Generator.js b/scripts/sequence/Generator.js index 73a3cb2..f951689 100644 --- a/scripts/sequence/Generator.js +++ b/scripts/sequence/Generator.js @@ -4,6 +4,7 @@ define(['core/ArrayUtilities'], (array) => { class AgentState { constructor(visible, locked = false) { this.visible = visible; + this.highlighted = false; this.locked = locked; } } @@ -24,13 +25,157 @@ define(['core/ArrayUtilities'], (array) => { return agent.name; } + function agentHasFlag(flag) { + return (agent) => agent.flags.includes(flag); + } + + const MERGABLE = { + 'agent begin': { + check: ['mode'], + merge: ['agentNames'], + siblings: new Set(['agent highlight']), + }, + 'agent end': { + check: ['mode'], + merge: ['agentNames'], + siblings: new Set(['agent highlight']), + }, + 'agent highlight': { + check: ['highlighted'], + merge: ['agentNames'], + siblings: new Set(['agent begin', 'agent end']), + }, + }; + + function mergableParallel(target, copy) { + const info = MERGABLE[target.type]; + if(!info || target.type !== copy.type) { + return false; + } + if(info.check.some((c) => target[c] !== copy[c])) { + return false; + } + return true; + } + + function performMerge(target, copy) { + const info = MERGABLE[target.type]; + info.merge.forEach((m) => { + array.mergeSets(target[m], copy[m]); + }); + } + + function iterateRemoval(list, fn) { + for(let i = 0; i < list.length;) { + const remove = fn(list[i], i); + if(remove) { + list.splice(i, 1); + } else { + ++ i; + } + } + } + + function performParallelMergers(stages) { + iterateRemoval(stages, (stage, i) => { + for(let j = 0; j < i; ++ j) { + if(mergableParallel(stages[j], stage)) { + performMerge(stages[j], stage); + return true; + } + } + return false; + }); + } + + function findViableSequentialMergers(stages) { + const mergers = new Set(); + const types = stages.map(({type}) => type); + types.forEach((type) => { + const info = MERGABLE[type]; + if(!info) { + return; + } + if(types.every((sType) => + (type === sType || info.siblings.has(sType)) + )) { + mergers.add(type); + } + }); + return mergers; + } + + function performSequentialMergers(lastViable, viable, lastStages, stages) { + iterateRemoval(stages, (stage) => { + if(!lastViable.has(stage.type) || !viable.has(stage.type)) { + return false; + } + for(let j = 0; j < lastStages.length; ++ j) { + if(mergableParallel(lastStages[j], stage)) { + performMerge(lastStages[j], stage); + return true; + } + } + return false; + }); + } + + function optimiseStages(stages) { + let lastStages = []; + let lastViable = new Set(); + for(let i = 0; i < stages.length;) { + const stage = stages[i]; + let subStages = null; + if(stage.type === 'parallel') { + subStages = stage.stages; + } else { + subStages = [stage]; + } + + performParallelMergers(subStages); + const viable = findViableSequentialMergers(subStages); + performSequentialMergers(lastViable, viable, lastStages, subStages); + + lastViable = viable; + lastStages = subStages; + + if(subStages.length === 0) { + stages.splice(i, 1); + } else if(stage.type === 'parallel' && subStages.length === 1) { + stages.splice(i, 1, subStages[0]); + ++ i; + } else { + ++ i; + } + } + } + + function addBounds(target, agentL, agentR, involvedAgents = null) { + array.remove(target, agentL, agentEqCheck); + array.remove(target, agentR, agentEqCheck); + + let indexL = 0; + let indexR = target.length; + if(involvedAgents) { + const found = (involvedAgents + .map((agent) => array.indexOf(target, agent, agentEqCheck)) + .filter((p) => (p !== -1)) + ); + indexL = found.reduce((a, b) => Math.min(a, b), target.length); + indexR = found.reduce((a, b) => Math.max(a, b), indexL) + 1; + } + + target.splice(indexL, 0, agentL); + target.splice(indexR + 1, 0, agentR); + } + const LOCKED_AGENT = new AgentState(false, true); const DEFAULT_AGENT = new AgentState(false); const NOTE_DEFAULT_AGENTS = { - 'note over': [{name: '['}, {name: ']'}], - 'note left': [{name: '['}], - 'note right': [{name: ']'}], + 'note over': [{name: '[', flags: []}, {name: ']', flags: []}], + 'note left': [{name: '[', flags: []}], + 'note right': [{name: ']', flags: []}], }; return class Generator { @@ -61,32 +206,30 @@ define(['core/ArrayUtilities'], (array) => { this.handleStage = this.handleStage.bind(this); } - addBounds(target, agentL, agentR, involvedAgents = null) { - array.remove(target, agentL, agentEqCheck); - array.remove(target, agentR, agentEqCheck); - - let indexL = 0; - let indexR = target.length; - if(involvedAgents) { - const found = (involvedAgents - .map((agent) => array.indexOf(target, agent, agentEqCheck)) - .filter((p) => (p !== -1)) - ); - indexL = found.reduce((a, b) => Math.min(a, b), target.length); - indexR = found.reduce((a, b) => Math.max(a, b), indexL) + 1; - } - - target.splice(indexL, 0, agentL); - target.splice(indexR + 1, 0, agentR); - } - addStage(stage, isVisible = true) { + if(!stage) { + return; + } this.currentSection.stages.push(stage); if(isVisible) { this.currentNest.hasContent = true; } } + addParallelStages(stages) { + const viableStages = stages.filter((stage) => Boolean(stage)); + if(viableStages.length === 0) { + return; + } + if(viableStages.length === 1) { + return this.addStage(viableStages[0]); + } + return this.addStage({ + type: 'parallel', + stages: viableStages, + }); + } + defineAgents(agents) { array.mergeSets(this.currentNest.agents, agents, agentEqCheck); array.mergeSets(this.agents, agents, agentEqCheck); @@ -97,7 +240,9 @@ define(['core/ArrayUtilities'], (array) => { const state = this.agentStates.get(agent.name) || DEFAULT_AGENT; if(state.locked) { if(checked) { - throw new Error('Cannot begin/end agent: ' + agent); + throw new Error( + 'Cannot begin/end agent: ' + agent.name + ); } else { return false; } @@ -105,7 +250,7 @@ define(['core/ArrayUtilities'], (array) => { return state.visible !== visible; }); if(filteredAgents.length === 0) { - return; + return null; } filteredAgents.forEach((agent) => { const state = this.agentStates.get(agent.name); @@ -115,19 +260,42 @@ define(['core/ArrayUtilities'], (array) => { this.agentStates.set(agent.name, new AgentState(visible)); } }); - const type = (visible ? 'agent begin' : 'agent end'); - const existing = array.last(this.currentSection.stages) || {}; - const agentNames = filteredAgents.map(getAgentName); - if(existing.type === type && existing.mode === mode) { - array.mergeSets(existing.agentNames, agentNames); - } else { - this.addStage({ - type, - agentNames, - mode, - }); - } this.defineAgents(filteredAgents); + + return { + type: (visible ? 'agent begin' : 'agent end'), + agentNames: filteredAgents.map(getAgentName), + mode, + }; + } + + setAgentHighlight(agents, highlighted, checked = false) { + const filteredAgents = agents.filter((agent) => { + const state = this.agentStates.get(agent.name) || DEFAULT_AGENT; + if(state.locked) { + if(checked) { + throw new Error( + 'Cannot highlight agent: ' + agent.name + ); + } else { + return false; + } + } + return state.visible && (state.highlighted !== highlighted); + }); + if(filteredAgents.length === 0) { + return null; + } + filteredAgents.forEach((agent) => { + const state = this.agentStates.get(agent.name); + state.highlighted = highlighted; + }); + + return { + type: 'agent highlight', + agentNames: filteredAgents.map(getAgentName), + highlighted, + }; } beginNested(mode, label, name) { @@ -173,15 +341,26 @@ define(['core/ArrayUtilities'], (array) => { handleConnect({agents, label, options}) { const colAgents = agents.map(convertAgent); - this.setAgentVis(colAgents, true, 'box'); + this.addStage(this.setAgentVis(colAgents, true, 'box')); this.defineAgents(colAgents); - this.addStage({ + const startAgents = agents.filter(agentHasFlag('start')); + const stopAgents = agents.filter(agentHasFlag('stop')); + if(array.hasIntersection(startAgents, stopAgents, agentEqCheck)) { + throw new Error('Cannot set agent highlighting multiple times'); + } + const connectStage = { type: 'connect', agentNames: agents.map(getAgentName), label, options, - }); + }; + + this.addParallelStages([ + this.setAgentHighlight(startAgents, true, true), + connectStage, + this.setAgentHighlight(stopAgents, false, true), + ]); } handleNote({type, agents, mode, label}) { @@ -192,7 +371,7 @@ define(['core/ArrayUtilities'], (array) => { colAgents = agents.map(convertAgent); } - this.setAgentVis(colAgents, true, 'box'); + this.addStage(this.setAgentVis(colAgents, true, 'box')); this.defineAgents(colAgents); this.addStage({ @@ -209,11 +388,19 @@ define(['core/ArrayUtilities'], (array) => { } handleAgentBegin({agents, mode}) { - this.setAgentVis(agents.map(convertAgent), true, mode, true); + this.addStage(this.setAgentVis( + agents.map(convertAgent), + true, + mode, + true + )); } handleAgentEnd({agents, mode}) { - this.setAgentVis(agents.map(convertAgent), false, mode, true); + this.addParallelStages([ + this.setAgentHighlight(agents, false), + this.setAgentVis(agents.map(convertAgent), false, mode, true), + ]); } handleBlockBegin({mode, label}) { @@ -230,6 +417,7 @@ define(['core/ArrayUtilities'], (array) => { containerMode + ')' ); } + optimiseStages(this.currentSection.stages); this.currentSection = { mode, label, @@ -242,12 +430,13 @@ define(['core/ArrayUtilities'], (array) => { if(this.nesting.length <= 1) { throw new Error('Invalid block nesting (too many "end"s)'); } + optimiseStages(this.currentSection.stages); const nested = this.nesting.pop(); this.currentNest = array.last(this.nesting); this.currentSection = array.last(this.currentNest.stage.sections); if(nested.hasContent) { this.defineAgents(nested.agents); - this.addBounds( + addBounds( this.agents, nested.leftAgent, nested.rightAgent, @@ -278,13 +467,19 @@ define(['core/ArrayUtilities'], (array) => { ); } - this.setAgentVis(this.agents, false, meta.terminators || 'none'); + const terminators = meta.terminators || 'none'; - this.addBounds( + this.addParallelStages([ + this.setAgentHighlight(this.agents, false), + this.setAgentVis(this.agents, false, terminators), + ]); + + addBounds( this.agents, this.currentNest.leftAgent, this.currentNest.rightAgent ); + optimiseStages(globals.stages); return { meta: { @@ -296,4 +491,3 @@ define(['core/ArrayUtilities'], (array) => { } }; }); - diff --git a/scripts/sequence/Generator_spec.js b/scripts/sequence/Generator_spec.js index c78831e..ca1cf6e 100644 --- a/scripts/sequence/Generator_spec.js +++ b/scripts/sequence/Generator_spec.js @@ -3,6 +3,16 @@ defineDescribe('Sequence Generator', ['./Generator'], (Generator) => { const generator = new Generator(); + function makeParsedAgents(source) { + return source.map((item) => { + if(typeof item === 'object') { + return item; + } else { + return {name: item, flags: []}; + } + }); + } + const PARSED = { blockBegin: (mode, label) => { return {type: 'block begin', mode, label}; @@ -19,14 +29,14 @@ defineDescribe('Sequence Generator', ['./Generator'], (Generator) => { defineAgents: (agentNames) => { return { type: 'agent define', - agents: agentNames.map((name) => ({name, flags: []})), + agents: makeParsedAgents(agentNames), }; }, beginAgents: (agentNames, {mode = 'box'} = {}) => { return { type: 'agent begin', - agents: agentNames.map((name) => ({name, flags: []})), + agents: makeParsedAgents(agentNames), mode, }; }, @@ -34,7 +44,7 @@ defineDescribe('Sequence Generator', ['./Generator'], (Generator) => { endAgents: (agentNames, {mode = 'cross'} = {}) => { return { type: 'agent end', - agents: agentNames.map((name) => ({name, flags: []})), + agents: makeParsedAgents(agentNames), mode, }; }, @@ -47,7 +57,7 @@ defineDescribe('Sequence Generator', ['./Generator'], (Generator) => { } = {}) => { return { type: 'connect', - agents: agentNames.map((name) => ({name, flags: []})), + agents: makeParsedAgents(agentNames), label, options: { line, @@ -57,14 +67,13 @@ defineDescribe('Sequence Generator', ['./Generator'], (Generator) => { }; }, - note: (agentNames, { - type = 'note over', + note: (type, agentNames, { mode = '', label = '', } = {}) => { return { type, - agents: agentNames.map((name) => ({name, flags: []})), + agents: makeParsedAgents(agentNames), mode, label, }; @@ -110,8 +119,15 @@ defineDescribe('Sequence Generator', ['./Generator'], (Generator) => { }; }, - note: (agentNames, { - type = jasmine.anything(), + highlight: (agentNames, highlighted) => { + return { + type: 'agent highlight', + agentNames, + highlighted, + }; + }, + + note: (type, agentNames, { mode = jasmine.anything(), label = jasmine.anything(), } = {}) => { @@ -122,6 +138,13 @@ defineDescribe('Sequence Generator', ['./Generator'], (Generator) => { label, }; }, + + parallel: (stages) => { + return { + type: 'parallel', + stages, + }; + }, }; describe('.generate', () => { @@ -372,7 +395,7 @@ defineDescribe('Sequence Generator', ['./Generator'], (Generator) => { it('does not merge different modes of end', () => { const sequence = generator.generate({stages: [ - PARSED.beginAgents(['C', 'D']), + PARSED.beginAgents(['A', 'B', 'C', 'D']), PARSED.connect(['A', 'B']), PARSED.endAgents(['A', 'B', 'C']), ]}); @@ -384,6 +407,86 @@ defineDescribe('Sequence Generator', ['./Generator'], (Generator) => { ]); }); + it('adds parallel highlighting stages', () => { + const sequence = generator.generate({stages: [ + PARSED.connect(['A', {name: 'B', flags: ['start']}]), + PARSED.connect(['A', {name: 'B', flags: ['stop']}]), + ]}); + expect(sequence.stages).toEqual([ + jasmine.anything(), + GENERATED.parallel([ + GENERATED.highlight(['B'], true), + GENERATED.connect(['A', 'B']), + ]), + GENERATED.parallel([ + GENERATED.connect(['A', 'B']), + GENERATED.highlight(['B'], false), + ]), + jasmine.anything(), + ]); + }); + + it('rejects conflicting flags', () => { + expect(() => generator.generate({stages: [ + PARSED.connect(['A', {name: 'B', flags: ['start', 'stop']}]), + ]})).toThrow(); + + expect(() => generator.generate({stages: [ + PARSED.connect([ + {name: 'A', flags: ['start']}, + {name: 'A', flags: ['stop']}, + ]), + ]})).toThrow(); + }); + + it('adds implicit highlight end with implicit terminator', () => { + const sequence = generator.generate({stages: [ + PARSED.connect(['A', {name: 'B', flags: ['start']}]), + ]}); + expect(sequence.stages).toEqual([ + jasmine.anything(), + jasmine.anything(), + GENERATED.parallel([ + GENERATED.highlight(['B'], false), + GENERATED.endAgents(['A', 'B']), + ]), + ]); + }); + + it('adds implicit highlight end with explicit terminator', () => { + const sequence = generator.generate({stages: [ + PARSED.connect(['A', {name: 'B', flags: ['start']}]), + PARSED.endAgents(['A', 'B']), + ]}); + expect(sequence.stages).toEqual([ + jasmine.anything(), + jasmine.anything(), + GENERATED.parallel([ + GENERATED.highlight(['B'], false), + GENERATED.endAgents(['A', 'B']), + ]), + ]); + }); + + it('collapses adjacent end statements containing highlighting', () => { + const sequence = generator.generate({stages: [ + PARSED.connect([ + {name: 'A', flags: ['start']}, + {name: 'B', flags: ['start']}, + ]), + PARSED.endAgents(['A']), + PARSED.endAgents(['B']), + ]}); + expect(sequence.stages).toEqual([ + jasmine.anything(), + jasmine.anything(), + GENERATED.parallel([ + GENERATED.highlight(['A', 'B'], false), + GENERATED.endAgents(['A', 'B']), + ]), + ]); + }); + it('creates virtual agents for block statements', () => { const sequence = generator.generate({stages: [ PARSED.blockBegin('if', 'abc'), @@ -675,16 +778,14 @@ defineDescribe('Sequence Generator', ['./Generator'], (Generator) => { it('passes notes through', () => { const sequence = generator.generate({stages: [ - PARSED.note(['A', 'B'], { - type: 'note right', + PARSED.note('note right', ['A', 'B'], { mode: 'foo', label: 'bar', }), ]}); expect(sequence.stages).toEqual([ jasmine.anything(), - GENERATED.note(['A', 'B'], { - type: 'note right', + GENERATED.note('note right', ['A', 'B'], { mode: 'foo', label: 'bar', }), @@ -694,14 +795,14 @@ defineDescribe('Sequence Generator', ['./Generator'], (Generator) => { it('defaults to showing notes around the entire diagram', () => { const sequence = generator.generate({stages: [ - PARSED.note([], {type: 'note right'}), - PARSED.note([], {type: 'note left'}), - PARSED.note([], {type: 'note over'}), + PARSED.note('note right', []), + PARSED.note('note left', []), + PARSED.note('note over', []), ]}); expect(sequence.stages).toEqual([ - GENERATED.note([']'], {type: 'note right'}), - GENERATED.note(['['], {type: 'note left'}), - GENERATED.note(['[', ']'], {type: 'note over'}), + GENERATED.note('note right', [']']), + GENERATED.note('note left', ['[']), + GENERATED.note('note over', ['[', ']']), ]); }); diff --git a/scripts/sequence/Renderer.js b/scripts/sequence/Renderer.js index 6160864..942ec85 100644 --- a/scripts/sequence/Renderer.js +++ b/scripts/sequence/Renderer.js @@ -1,31 +1,21 @@ define([ 'core/ArrayUtilities', 'svg/SVGUtilities', - 'svg/SVGTextBlock', 'svg/SVGShapes', + './components/BaseComponent', + './components/Marker', + './components/AgentCap', + './components/AgentHighlight', + './components/Connect', + './components/Note', ], ( array, svg, - SVGTextBlock, - SVGShapes + SVGShapes, + BaseComponent ) => { 'use strict'; - const SEP_ZERO = {left: 0, right: 0}; - - 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') { @@ -45,76 +35,71 @@ define([ if(callbacks.blockEndFn) { callbacks.blockEndFn(scope, stage); } - } else if(callbacks.stageFn) { - callbacks.stageFn(stage); + } else if(callbacks.stagesFn) { + if(stage.type === 'parallel') { + callbacks.stagesFn(stage.stages); + } else { + callbacks.stagesFn([stage]); + } } }); } + function findExtremes(agentInfos, agentNames) { + let min = null; + let max = null; + agentNames.forEach((name) => { + const info = agentInfos.get(name); + 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, + }; + } + return class Renderer { constructor(theme, { - SVGTextBlockClass = SVGTextBlock, + components = null, + SVGTextBlockClass = SVGShapes.TextBlock, } = {}) { - this.separationAgentCap = { - 'box': this.separationAgentCapBox.bind(this), - 'cross': this.separationAgentCapCross.bind(this), - 'bar': this.separationAgentCapBar.bind(this), - 'none': this.separationAgentCapNone.bind(this), - }; - - this.separationAction = { - 'mark': this.separationMark.bind(this), - 'async': this.separationAsync.bind(this), - 'agent begin': this.separationAgent.bind(this), - 'agent end': this.separationAgent.bind(this), - 'connect': this.separationConnect.bind(this), - 'note over': this.separationNoteOver.bind(this), - 'note left': this.separationNoteSide.bind(this, false), - 'note right': this.separationNoteSide.bind(this, true), - 'note between': this.separationNoteBetween.bind(this), - }; - - this.renderAgentCap = { - 'box': this.renderAgentCapBox.bind(this), - 'cross': this.renderAgentCapCross.bind(this), - 'bar': this.renderAgentCapBar.bind(this), - 'none': this.renderAgentCapNone.bind(this), - }; - - this.renderAction = { - 'mark': this.renderMark.bind(this), - 'async': this.renderAsync.bind(this), - 'agent begin': this.renderAgentBegin.bind(this), - 'agent end': this.renderAgentEnd.bind(this), - 'connect': this.renderConnect.bind(this), - 'note over': this.renderNoteOver.bind(this), - 'note left': this.renderNoteLeft.bind(this), - 'note right': this.renderNoteRight.bind(this), - 'note between': this.renderNoteBetween.bind(this), - }; + if(components === null) { + components = BaseComponent.getComponents(); + } this.separationTraversalFns = { - stageFn: this.checkSeparation.bind(this), + stagesFn: this.separationStages.bind(this), blockBeginFn: this.separationBlockBegin.bind(this), sectionBeginFn: this.separationSectionBegin.bind(this), blockEndFn: this.separationBlockEnd.bind(this), }; this.renderTraversalFns = { - stageFn: this.addAction.bind(this), + stagesFn: this.renderStages.bind(this), blockBeginFn: this.renderBlockBegin.bind(this), sectionBeginFn: this.renderSectionBegin.bind(this), sectionEndFn: this.renderSectionEnd.bind(this), blockEndFn: this.renderBlockEnd.bind(this), }; + this.addSeparation = this.addSeparation.bind(this); + + this.state = {}; this.width = 0; this.height = 0; - this.marks = new Map(); this.theme = theme; + this.components = components; this.SVGTextBlockClass = SVGTextBlockClass; this.currentSequence = null; this.buildStaticElements(); + this.components.forEach((component) => { + component.makeState(this.state); + }); } buildStaticElements() { @@ -140,24 +125,6 @@ define([ this.sizer = new this.SVGTextBlockClass.SizeTester(this.base); } - findExtremes(agentNames) { - let min = null; - let max = null; - agentNames.forEach((name) => { - const info = this.agentInfos.get(name); - 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(agentName1, agentName2, dist) { const info1 = this.agentInfos.get(agentName1); const info2 = this.agentInfos.get(agentName2); @@ -169,202 +136,8 @@ define([ info2.separations.set(agentName1, Math.max(d2, dist)); } - addSeparations(agentNames, agentSpaces) { - agentNames.forEach((agentNameR) => { - const infoR = this.agentInfos.get(agentNameR); - const sepR = agentSpaces.get(agentNameR) || SEP_ZERO; - infoR.maxRPad = Math.max(infoR.maxRPad, sepR.right); - infoR.maxLPad = Math.max(infoR.maxLPad, sepR.left); - agentNames.forEach((agentNameL) => { - const infoL = this.agentInfos.get(agentNameL); - if(infoL.index >= infoR.index) { - return; - } - const sepL = agentSpaces.get(agentNameL) || SEP_ZERO; - this.addSeparation( - agentNameR, - agentNameL, - sepR.left + sepL.right + this.theme.agentMargin - ); - }); - }); - } - - getArrowShort(arrow) { - const h = arrow.height / 2; - const w = arrow.width; - const t = arrow.attrs['stroke-width'] * 0.5; - const lineStroke = this.theme.agentLineAttrs['stroke-width'] * 0.5; - const arrowDistance = t * Math.sqrt((w * w) / (h * h) + 1); - return lineStroke + arrowDistance; - } - - separationMark() { - } - - separationAsync() { - } - - separationAgentCapBox({label}) { - const config = this.theme.agentCap.box; - const width = ( - this.sizer.measure(config.labelAttrs, label).width + - config.padding.left + - config.padding.right - ); - - return { - left: width / 2, - right: width / 2, - }; - } - - separationAgentCapCross() { - const config = this.theme.agentCap.cross; - return { - left: config.size / 2, - right: config.size / 2, - }; - } - - separationAgentCapBar({label}) { - const config = this.theme.agentCap.box; - const width = ( - this.sizer.measure(config.labelAttrs, label).width + - config.padding.left + - config.padding.right - ); - - return { - left: width / 2, - right: width / 2, - }; - } - - separationAgentCapNone() { - return {left: 0, right: 0}; - } - - separationAgent({type, mode, agentNames}) { - if(type === 'agent begin') { - array.mergeSets(this.visibleAgents, agentNames); - } - - const agentSpaces = new Map(); - agentNames.forEach((name) => { - const info = this.agentInfos.get(name); - const separationFn = this.separationAgentCap[mode]; - agentSpaces.set(name, separationFn(info)); - }); - this.addSeparations(this.visibleAgents, agentSpaces); - - if(type === 'agent end') { - array.removeAll(this.visibleAgents, agentNames); - } - } - - separationConnect({agentNames, label}) { - const config = this.theme.connect; - - const labelWidth = ( - this.sizer.measure(config.label.attrs, label).width + - config.label.padding * 2 - ); - - const short = this.getArrowShort(config.arrow); - - if(agentNames[0] === agentNames[1]) { - const agentSpaces = new Map(); - agentSpaces.set(agentNames[0], { - left: 0, - right: ( - labelWidth + - config.arrow.width + - short + - config.loopbackRadius - ), - }); - this.addSeparations(this.visibleAgents, agentSpaces); - } else { - this.addSeparation( - agentNames[0], - agentNames[1], - labelWidth + config.arrow.width * 2 + short * 2 - ); - } - } - - separationNoteOver({agentNames, mode, label}) { - const config = this.theme.note[mode]; - const width = ( - this.sizer.measure(config.labelAttrs, label).width + - config.padding.left + - config.padding.right - ); - - const agentSpaces = new Map(); - if(agentNames.length > 1) { - const {left, right} = this.findExtremes(agentNames); - - 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(agentNames[0], { - left: width / 2, - right: width / 2, - }); - } - this.addSeparations(this.visibleAgents, agentSpaces); - } - - separationNoteSide(isRight, {agentNames, mode, label}) { - const config = this.theme.note[mode]; - const {left, right} = this.findExtremes(agentNames); - const width = ( - this.sizer.measure(config.labelAttrs, label).width + - config.padding.left + - config.padding.right + - config.margin.left + - config.margin.right - ); - - const agentSpaces = new Map(); - if(isRight) { - agentSpaces.set(right, {left: 0, right: width}); - } else { - agentSpaces.set(left, {left: width, right: 0}); - } - this.addSeparations(this.visibleAgents, agentSpaces); - } - - separationNoteBetween({agentNames, mode, label}) { - const config = this.theme.note[mode]; - const {left, right} = this.findExtremes(agentNames); - - this.addSeparation( - left, - right, - - this.sizer.measure(config.labelAttrs, label).width + - config.padding.left + - config.padding.right + - config.margin.left + - config.margin.right - ); - } - separationBlockBegin(scope, {left, right}) { array.mergeSets(this.visibleAgents, [left, right]); - this.addSeparations(this.visibleAgents, new Map()); } separationSectionBegin(scope, {left, right}, {mode, label}) { @@ -384,413 +157,129 @@ define([ array.removeAll(this.visibleAgents, [left, right]); } - checkSeparation(stage) { - this.separationAction[stage.type](stage); + separationStages(stages) { + const agentSpaces = new Map(); + const agentNames = this.visibleAgents.slice(); + + const addSpacing = (agentName, spacing) => { + const current = agentSpaces.get(agentName); + current.left = Math.max(current.left, spacing.left); + current.right = Math.max(current.right, spacing.right); + }; + + this.agentInfos.forEach((agentInfo) => { + const rad = agentInfo.currentRad; + agentInfo.currentMaxRad = rad; + agentSpaces.set(agentInfo.label, {left: rad, right: rad}); + }); + const env = { + theme: this.theme, + agentInfos: this.agentInfos, + visibleAgents: this.visibleAgents, + textSizer: this.sizer, + addSpacing, + addSeparation: this.addSeparation, + }; + stages.forEach((stage) => { + this.components.get(stage.type).separationPre(stage, env); + }); + stages.forEach((stage) => { + this.components.get(stage.type).separation(stage, env); + }); + array.mergeSets(agentNames, this.visibleAgents); + + agentNames.forEach((agentNameR) => { + const infoR = this.agentInfos.get(agentNameR); + const sepR = agentSpaces.get(agentNameR); + infoR.maxRPad = Math.max(infoR.maxRPad, sepR.right); + infoR.maxLPad = Math.max(infoR.maxLPad, sepR.left); + agentNames.forEach((agentNameL) => { + const infoL = this.agentInfos.get(agentNameL); + if(infoL.index >= infoR.index) { + return; + } + const sepL = agentSpaces.get(agentNameL); + this.addSeparation( + agentNameR, + agentNameL, + sepR.left + sepL.right + this.theme.agentMargin + ); + }); + }); } - renderMark({name}) { - this.marks.set(name, this.currentY); - } - - renderAsync({target}) { - if(target) { - this.currentY = this.marks.get(target) || 0; - } else { - this.currentY = 0; + checkAgentRange(agentNames, topY = 0) { + if(agentNames.length === 0) { + return topY; } - } - - renderAgentCapBox({x, label}) { - const config = this.theme.agentCap.box; - const {height} = SVGShapes.renderBoxedText(label, { - x, - y: this.currentY, - padding: config.padding, - boxAttrs: config.boxAttrs, - labelAttrs: config.labelAttrs, - boxLayer: this.actionShapes, - labelLayer: this.actionLabels, - SVGTextBlockClass: this.SVGTextBlockClass, + const {left, right} = findExtremes(this.agentInfos, agentNames); + const leftX = this.agentInfos.get(left).x; + const rightX = this.agentInfos.get(right).x; + let baseY = topY; + this.agentInfos.forEach((agentInfo) => { + if(agentInfo.x >= leftX && agentInfo.x <= rightX) { + baseY = Math.max(baseY, agentInfo.latestY); + } }); - - return { - lineTop: 0, - lineBottom: height, - height, - }; + return baseY; } - renderAgentCapCross({x}) { - const config = this.theme.agentCap.cross; - const y = this.currentY; - const d = config.size / 2; - - 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) - ), - }, config.attrs))); - - return { - lineTop: d, - lineBottom: d, - height: d * 2, - }; - } - - renderAgentCapBar({x, label}) { - const configB = this.theme.agentCap.box; - const config = this.theme.agentCap.bar; - const width = ( - 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, - }, config.attrs))); - - return { - lineTop: 0, - lineBottom: config.attrs.height, - height: config.attrs.height, - }; - } - - renderAgentCapNone() { - const config = this.theme.agentCap.none; - return { - lineTop: config.height, - lineBottom: 0, - height: config.height, - }; - } - - checkAgentRange(agentNames) { - const {left, right} = this.findExtremes(agentNames); + markAgentRange(agentNames, y) { + if(agentNames.length === 0) { + return; + } + const {left, right} = findExtremes(this.agentInfos, agentNames); const leftX = this.agentInfos.get(left).x; const rightX = this.agentInfos.get(right).x; this.agentInfos.forEach((agentInfo) => { if(agentInfo.x >= leftX && agentInfo.x <= rightX) { - this.currentY = Math.max(this.currentY, agentInfo.latestY); + agentInfo.latestY = y; } }); } - markAgentRange(agentNames) { - const {left, right} = this.findExtremes(agentNames); - const leftX = this.agentInfos.get(left).x; - const rightX = this.agentInfos.get(right).x; - this.agentInfos.forEach((agentInfo) => { - if(agentInfo.x >= leftX && agentInfo.x <= rightX) { - agentInfo.latestY = this.currentY; - } - }); - } + drawAgentLine(agentInfo, toY) { + if( + agentInfo.latestYStart === null || + toY <= agentInfo.latestYStart + ) { + return; + } - renderAgentBegin({mode, agentNames}) { - this.checkAgentRange(agentNames); - let maxHeight = 0; - agentNames.forEach((name) => { - const agentInfo = this.agentInfos.get(name); - const shifts = this.renderAgentCap[mode](agentInfo); - maxHeight = Math.max(maxHeight, shifts.height); - agentInfo.latestYStart = this.currentY + shifts.lineBottom; - }); - this.currentY += maxHeight + this.theme.actionMargin; - this.markAgentRange(agentNames); - } + const r = agentInfo.currentRad; - renderAgentEnd({mode, agentNames}) { - this.checkAgentRange(agentNames); - let maxHeight = 0; - agentNames.forEach((name) => { - const agentInfo = this.agentInfos.get(name); - const x = agentInfo.x; - 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, + if(r > 0) { + this.agentLines.appendChild(svg.make('rect', Object.assign({ + 'x': agentInfo.x - r, + 'y': agentInfo.latestYStart, + 'width': r * 2, + 'height': toY - agentInfo.latestYStart, 'class': 'agent-' + agentInfo.index + '-line', }, this.theme.agentLineAttrs))); - agentInfo.latestYStart = null; - }); - this.currentY += maxHeight + this.theme.actionMargin; - this.markAgentRange(agentNames); - } - - renderSelfConnect({label, agentNames, options}) { - const config = this.theme.connect; - const from = this.agentInfos.get(agentNames[0]); - - const dy = config.arrow.height / 2; - const short = this.getArrowShort(config.arrow); - - const height = ( - this.sizer.measureHeight(config.label.attrs, label) + - config.label.margin.top + - config.label.margin.bottom - ); - - const y0 = this.currentY + Math.max(dy, height); - const x0 = ( - from.x + - short + - config.arrow.width + - config.label.padding - ); - - const renderedText = SVGShapes.renderBoxedText(label, { - x: x0 - config.mask.padding.left, - y: y0 - height + config.label.margin.top, - padding: config.mask.padding, - boxAttrs: config.mask.maskAttrs, - labelAttrs: config.label.loopbackAttrs, - boxLayer: this.mask, - labelLayer: this.actionLabels, - SVGTextBlockClass: this.SVGTextBlockClass, - }); - const r = config.loopbackRadius; - const x1 = ( - x0 + - renderedText.width + - config.label.padding - - config.mask.padding.left - - config.mask.padding.right - ); - const y1 = y0 + r * 2; - - this.actionShapes.appendChild(svg.make('path', Object.assign({ - 'd': ( - 'M ' + (from.x + (options.left ? short : 0)) + ' ' + y0 + - ' L ' + x1 + ' ' + y0 + - ' A ' + r + ' ' + r + ' 0 0 1 ' + x1 + ' ' + y1 + - ' L ' + (from.x + (options.right ? short : 0)) + ' ' + y1 - ), - }, config.lineAttrs[options.line]))); - - if(options.left) { - drawHorizontalArrowHead(this.actionShapes, { - x: from.x + short, - y: y0, - dx: config.arrow.width, - dy, - attrs: config.arrow.attrs, - }); - } - - if(options.right) { - drawHorizontalArrowHead(this.actionShapes, { - x: from.x + short, - y: y1, - dx: config.arrow.width, - dy, - attrs: config.arrow.attrs, - }); - } - - this.currentY = y1 + dy + this.theme.actionMargin; - } - - renderSimpleConnect({label, agentNames, options}) { - const config = this.theme.connect; - const from = this.agentInfos.get(agentNames[0]); - const to = this.agentInfos.get(agentNames[1]); - - const dy = config.arrow.height / 2; - const dir = (from.x < to.x) ? 1 : -1; - const short = this.getArrowShort(config.arrow); - - const height = ( - 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 + config.label.margin.top, - padding: config.mask.padding, - boxAttrs: config.mask.maskAttrs, - labelAttrs: config.label.attrs, - boxLayer: this.mask, - labelLayer: this.actionLabels, - SVGTextBlockClass: this.SVGTextBlockClass, - }); - - this.actionShapes.appendChild(svg.make('line', Object.assign({ - 'x1': from.x + (options.left ? short : 0) * dir, - 'y1': y, - 'x2': to.x - (options.right ? short : 0) * dir, - 'y2': y, - }, config.lineAttrs[options.line]))); - - if(options.left) { - drawHorizontalArrowHead(this.actionShapes, { - x: from.x + short * dir, - y, - dx: config.arrow.width * dir, - dy, - attrs: config.arrow.attrs, - }); - } - - if(options.right) { - drawHorizontalArrowHead(this.actionShapes, { - x: to.x - short * dir, - y, - dx: -config.arrow.width * dir, - dy, - attrs: config.arrow.attrs, - }); - } - - this.currentY = y + dy + this.theme.actionMargin; - } - - renderConnect(stage) { - this.checkAgentRange(stage.agentNames); - if(stage.agentNames[0] === stage.agentNames[1]) { - this.renderSelfConnect(stage); } else { - this.renderSimpleConnect(stage); + this.agentLines.appendChild(svg.make('line', Object.assign({ + 'x1': agentInfo.x, + 'y1': agentInfo.latestYStart, + 'x2': agentInfo.x, + 'y2': toY, + 'class': 'agent-' + agentInfo.index + '-line', + }, this.theme.agentLineAttrs))); } - this.markAgentRange(stage.agentNames); - } - - renderNote({xMid = null, x0 = null, x1 = null}, anchor, mode, label) { - const config = this.theme.note[mode]; - - this.currentY += config.margin.top; - - const y = this.currentY + config.padding.top; - const labelNode = new this.SVGTextBlockClass(this.actionLabels, { - attrs: config.labelAttrs, - text: label, - y, - }); - - 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; - } - if(x1 === null && x0 !== null) { - x1 = x0 + fullW; - } else if(x0 === null) { - x0 = x1 - fullW; - } - switch(config.labelAttrs['text-anchor']) { - case 'middle': - labelNode.set({ - x: ( - x0 + config.padding.left + - x1 - config.padding.right - ) / 2, - y, - }); - break; - case 'end': - labelNode.set({x: x1 - config.padding.right, y}); - break; - default: - labelNode.set({x: x0 + config.padding.left, y}); - break; - } - - this.actionShapes.appendChild(config.boxRenderer({ - x: x0, - y: this.currentY, - width: x1 - x0, - height: fullH, - })); - - this.currentY += ( - fullH + - config.margin.bottom + - this.theme.actionMargin - ); - } - - renderNoteOver({agentNames, mode, label}) { - this.checkAgentRange(agentNames); - const config = this.theme.note[mode]; - - if(agentNames.length > 1) { - const {left, right} = this.findExtremes(agentNames); - 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(agentNames[0]).x; - this.renderNote({xMid}, 'middle', mode, label); - } - this.markAgentRange(agentNames); - } - - renderNoteLeft({agentNames, mode, label}) { - this.checkAgentRange(agentNames); - const config = this.theme.note[mode]; - - const {left} = this.findExtremes(agentNames); - const x1 = this.agentInfos.get(left).x - config.margin.right; - this.renderNote({x1}, 'end', mode, label); - this.markAgentRange(agentNames); - } - - renderNoteRight({agentNames, mode, label}) { - this.checkAgentRange(agentNames); - const config = this.theme.note[mode]; - - const {right} = this.findExtremes(agentNames); - const x0 = this.agentInfos.get(right).x + config.margin.left; - this.renderNote({x0}, 'start', mode, label); - this.markAgentRange(agentNames); - } - - renderNoteBetween({agentNames, mode, label}) { - this.checkAgentRange(agentNames); - const {left, right} = this.findExtremes(agentNames); - const xMid = ( - this.agentInfos.get(left).x + - this.agentInfos.get(right).x - ) / 2; - - this.renderNote({xMid}, 'middle', mode, label); - this.markAgentRange(agentNames); } renderBlockBegin(scope, {left, right}) { - this.checkAgentRange([left, right]); - this.currentY += this.theme.block.margin.top; + this.currentY = ( + this.checkAgentRange([left, right], this.currentY) + + this.theme.block.margin.top + ); scope.y = this.currentY; scope.first = true; - this.markAgentRange([left, right]); + this.markAgentRange([left, right], this.currentY); } renderSectionBegin(scope, {left, right}, {mode, label}) { - this.checkAgentRange([left, right]); + this.currentY = this.checkAgentRange([left, right], this.currentY); const config = this.theme.block; const agentInfoL = this.agentInfos.get(left); const agentInfoR = this.agentInfos.get(right); @@ -833,16 +322,18 @@ define([ Math.max(modeRender.height, labelRender.height) + config.section.padding.top ); - this.markAgentRange([left, right]); + this.markAgentRange([left, right], this.currentY); } renderSectionEnd(/*scope, block, section*/) { } renderBlockEnd(scope, {left, right}) { - this.checkAgentRange([left, right]); const config = this.theme.block; - this.currentY += config.section.padding.bottom; + this.currentY = ( + this.checkAgentRange([left, right], this.currentY) + + config.section.padding.bottom + ); const agentInfoL = this.agentInfos.get(left); const agentInfoR = this.agentInfos.get(right); @@ -854,11 +345,72 @@ define([ }, config.boxAttrs))); this.currentY += config.margin.bottom + this.theme.actionMargin; - this.markAgentRange([left, right]); + this.markAgentRange([left, right], this.currentY); } - addAction(stage) { - this.renderAction[stage.type](stage); + renderStages(stages) { + this.agentInfos.forEach((agentInfo) => { + const rad = agentInfo.currentRad; + agentInfo.currentMaxRad = rad; + }); + + let topY = 0; + let maxTopShift = 0; + let sequential = true; + const envPre = { + theme: this.theme, + agentInfos: this.agentInfos, + textSizer: this.sizer, + state: this.state, + }; + const touchedAgentNames = []; + stages.forEach((stage) => { + const component = this.components.get(stage.type); + const r = component.renderPre(stage, envPre); + if(r.topShift !== undefined) { + maxTopShift = Math.max(maxTopShift, r.topShift); + } + if(r.agentNames) { + array.mergeSets(touchedAgentNames, r.agentNames); + } + if(r.asynchronousY !== undefined) { + topY = Math.max(topY, r.asynchronousY); + sequential = false; + } + }); + topY = this.checkAgentRange(touchedAgentNames, topY); + if(sequential) { + topY = Math.max(topY, this.currentY); + } + + const env = { + topY, + primaryY: topY + maxTopShift, + shapeLayer: this.actionShapes, + labelLayer: this.actionLabels, + maskLayer: this.mask, + theme: this.theme, + agentInfos: this.agentInfos, + textSizer: this.sizer, + SVGTextBlockClass: this.SVGTextBlockClass, + state: this.state, + drawAgentLine: (agentName, toY, andStop = false) => { + const agentInfo = this.agentInfos.get(agentName); + this.drawAgentLine(agentInfo, toY); + agentInfo.latestYStart = andStop ? null : toY; + }, + }; + let bottomY = topY; + stages.forEach((stage) => { + const component = this.components.get(stage.type); + const baseY = component.render(stage, env); + if(baseY !== undefined) { + bottomY = Math.max(bottomY, baseY); + } + }); + this.markAgentRange(touchedAgentNames, bottomY); + + this.currentY = bottomY; } positionAgents() { @@ -907,6 +459,7 @@ define([ index, x: null, latestYStart: null, + currentRad: 0, latestY: 0, maxRPad: 0, maxLPad: 0, @@ -959,7 +512,9 @@ define([ svg.empty(this.sections); svg.empty(this.actionShapes); svg.empty(this.actionLabels); - this.marks.clear(); + this.components.forEach((component) => { + component.resetState(this.state); + }); this.title.set({ attrs: this.theme.titleAttrs, @@ -972,12 +527,9 @@ define([ this.currentY = 0; traverse(sequence.stages, this.renderTraversalFns); - this.checkAgentRange(['[', ']']); + const bottomY = this.checkAgentRange(['[', ']'], this.currentY); - const stagesHeight = Math.max( - this.currentY - this.theme.actionMargin, - 0 - ); + const stagesHeight = Math.max(bottomY - this.theme.actionMargin, 0); this.updateBounds(stagesHeight); this.sizer.resetCache(); diff --git a/scripts/sequence/components/AgentCap.js b/scripts/sequence/components/AgentCap.js new file mode 100644 index 0000000..c492cf5 --- /dev/null +++ b/scripts/sequence/components/AgentCap.js @@ -0,0 +1,218 @@ +define([ + './BaseComponent', + 'core/ArrayUtilities', + 'svg/SVGUtilities', + 'svg/SVGShapes', +], ( + BaseComponent, + array, + svg, + SVGShapes +) => { + 'use strict'; + + class CapBox { + separation({label}, env) { + const config = env.theme.agentCap.box; + const width = ( + env.textSizer.measure(config.labelAttrs, label).width + + config.padding.left + + config.padding.right + ); + + return { + left: width / 2, + right: width / 2, + }; + } + + topShift() { + return 0; + } + + render(y, {x, label}, env) { + const config = env.theme.agentCap.box; + const {height} = SVGShapes.renderBoxedText(label, { + x, + y, + padding: config.padding, + boxAttrs: config.boxAttrs, + labelAttrs: config.labelAttrs, + boxLayer: env.shapeLayer, + labelLayer: env.labelLayer, + SVGTextBlockClass: env.SVGTextBlockClass, + }); + + return { + lineTop: 0, + lineBottom: height, + height, + }; + } + } + + class CapCross { + separation(agentInfo, env) { + const config = env.theme.agentCap.cross; + return { + left: config.size / 2, + right: config.size / 2, + }; + } + + topShift(agentInfo, env) { + const config = env.theme.agentCap.cross; + return config.size / 2; + } + + render(y, {x}, env) { + const config = env.theme.agentCap.cross; + const d = config.size / 2; + + env.shapeLayer.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) + ), + }, config.attrs))); + + return { + lineTop: d, + lineBottom: d, + height: d * 2, + }; + } + } + + class CapBar { + separation({label}, env) { + const config = env.theme.agentCap.box; + const width = ( + env.textSizer.measure(config.labelAttrs, label).width + + config.padding.left + + config.padding.right + ); + + return { + left: width / 2, + right: width / 2, + }; + } + + topShift(agentInfo, env) { + const config = env.theme.agentCap.bar; + return config.attrs.height / 2; + } + + render(y, {x, label}, env) { + const configB = env.theme.agentCap.box; + const config = env.theme.agentCap.bar; + const width = ( + env.textSizer.measure(configB.labelAttrs, label).width + + configB.padding.left + + configB.padding.right + ); + + env.shapeLayer.appendChild(svg.make('rect', Object.assign({ + 'x': x - width / 2, + 'y': y, + 'width': width, + }, config.attrs))); + + return { + lineTop: 0, + lineBottom: config.attrs.height, + height: config.attrs.height, + }; + } + } + + class CapNone { + separation({currentRad}) { + return {left: currentRad, right: currentRad}; + } + + topShift(agentInfo, env) { + const config = env.theme.agentCap.none; + return config.height; + } + + render(y, agentInfo, env) { + const config = env.theme.agentCap.none; + return { + lineTop: config.height, + lineBottom: 0, + height: config.height, + }; + } + } + + const AGENT_CAPS = { + 'box': new CapBox(), + 'cross': new CapCross(), + 'bar': new CapBar(), + 'none': new CapNone(), + }; + + class AgentCap extends BaseComponent { + constructor(begin) { + super(); + this.begin = begin; + } + + separation({mode, agentNames}, env) { + if(this.begin) { + array.mergeSets(env.visibleAgents, agentNames); + } else { + array.removeAll(env.visibleAgents, agentNames); + } + + agentNames.forEach((name) => { + const agentInfo = env.agentInfos.get(name); + const separationFn = AGENT_CAPS[mode].separation; + env.addSpacing(name, separationFn(agentInfo, env)); + }); + } + + renderPre({mode, agentNames}, env) { + let maxTopShift = 0; + agentNames.forEach((name) => { + const agentInfo = env.agentInfos.get(name); + const topShift = AGENT_CAPS[mode].topShift(agentInfo, env); + maxTopShift = Math.max(maxTopShift, topShift); + }); + return { + agentNames, + topShift: maxTopShift, + }; + } + + render({mode, agentNames}, env) { + let maxEnd = 0; + agentNames.forEach((name) => { + const agentInfo = env.agentInfos.get(name); + const topShift = AGENT_CAPS[mode].topShift(agentInfo, env); + const y0 = env.primaryY - topShift; + const shifts = AGENT_CAPS[mode].render( + y0, + agentInfo, + env + ); + maxEnd = Math.max(maxEnd, y0 + shifts.height); + if(this.begin) { + env.drawAgentLine(name, y0 + shifts.lineBottom); + } else { + env.drawAgentLine(name, y0 + shifts.lineTop, true); + } + }); + return maxEnd + env.theme.actionMargin; + } + } + + BaseComponent.register('agent begin', new AgentCap(true)); + BaseComponent.register('agent end', new AgentCap(false)); + + return AgentCap; +}); diff --git a/scripts/sequence/components/AgentCap_spec.js b/scripts/sequence/components/AgentCap_spec.js new file mode 100644 index 0000000..53d0830 --- /dev/null +++ b/scripts/sequence/components/AgentCap_spec.js @@ -0,0 +1,15 @@ +defineDescribe('AgentCap', [ + './AgentCap', + './BaseComponent', +], ( + AgentCap, + BaseComponent +) => { + 'use strict'; + + it('registers itself with the component store', () => { + const components = BaseComponent.getComponents(); + expect(components.get('agent begin')).toEqual(jasmine.any(AgentCap)); + expect(components.get('agent end')).toEqual(jasmine.any(AgentCap)); + }); +}); diff --git a/scripts/sequence/components/AgentHighlight.js b/scripts/sequence/components/AgentHighlight.js new file mode 100644 index 0000000..b379283 --- /dev/null +++ b/scripts/sequence/components/AgentHighlight.js @@ -0,0 +1,27 @@ +define(['./BaseComponent'], (BaseComponent) => { + 'use strict'; + + class AgentHighlight extends BaseComponent { + separationPre({agentNames, highlighted}, env) { + const rad = highlighted ? env.theme.agentLineHighlightRadius : 0; + agentNames.forEach((name) => { + const agentInfo = env.agentInfos.get(name); + const maxRad = Math.max(agentInfo.currentMaxRad, rad); + agentInfo.currentRad = rad; + agentInfo.currentMaxRad = maxRad; + }); + } + + render({agentNames, highlighted}, env) { + const rad = highlighted ? env.theme.agentLineHighlightRadius : 0; + agentNames.forEach((name) => { + env.drawAgentLine(name, env.primaryY); + env.agentInfos.get(name).currentRad = rad; + }); + } + } + + BaseComponent.register('agent highlight', new AgentHighlight()); + + return AgentHighlight; +}); diff --git a/scripts/sequence/components/AgentHighlight_spec.js b/scripts/sequence/components/AgentHighlight_spec.js new file mode 100644 index 0000000..e61e62b --- /dev/null +++ b/scripts/sequence/components/AgentHighlight_spec.js @@ -0,0 +1,61 @@ +defineDescribe('AgentHighlight', [ + './AgentHighlight', + './BaseComponent', +], ( + AgentHighlight, + BaseComponent +) => { + 'use strict'; + + const highlight = new AgentHighlight(); + + const theme = { + agentLineHighlightRadius: 2, + }; + + it('registers itself with the component store', () => { + const components = BaseComponent.getComponents(); + expect(components.get('agent highlight')).toEqual( + jasmine.any(AgentHighlight) + ); + }); + + it('updates the radius of the agent lines when checking separation', () => { + const agentInfo = {currentRad: 0, currentMaxRad: 1}; + const agentInfos = new Map(); + agentInfos.set('foo', agentInfo); + const env = { + theme, + agentInfos, + }; + highlight.separationPre({agentNames: ['foo'], highlighted: true}, env); + expect(agentInfo.currentRad).toEqual(2); + expect(agentInfo.currentMaxRad).toEqual(2); + }); + + it('keeps the largest maximum radius', () => { + const agentInfo = {currentRad: 0, currentMaxRad: 3}; + const agentInfos = new Map(); + agentInfos.set('foo', agentInfo); + const env = { + theme, + agentInfos, + }; + highlight.separationPre({agentNames: ['foo'], highlighted: true}, env); + expect(agentInfo.currentRad).toEqual(2); + expect(agentInfo.currentMaxRad).toEqual(3); + }); + + it('sets the radius to 0 when highlighting is disabled', () => { + const agentInfo = {currentRad: 0, currentMaxRad: 1}; + const agentInfos = new Map(); + agentInfos.set('foo', agentInfo); + const env = { + theme, + agentInfos, + }; + highlight.separationPre({agentNames: ['foo'], highlighted: false}, env); + expect(agentInfo.currentRad).toEqual(0); + expect(agentInfo.currentMaxRad).toEqual(1); + }); +}); diff --git a/scripts/sequence/components/BaseComponent.js b/scripts/sequence/components/BaseComponent.js new file mode 100644 index 0000000..89ebab3 --- /dev/null +++ b/scripts/sequence/components/BaseComponent.js @@ -0,0 +1,67 @@ +define(() => { + 'use strict'; + + class BaseComponent { + makeState(/*state*/) { + } + + resetState(state) { + this.makeState(state); + } + + separationPre(/*stage, { + theme, + agentInfos, + visibleAgents, + textSizer, + addSpacing, + addSeparation, + }*/) { + } + + separation(/*stage, { + theme, + agentInfos, + visibleAgents, + textSizer, + addSpacing, + addSeparation, + }*/) { + } + + renderPre(/*stage, { + theme, + agentInfos, + textSizer, + state, + }*/) { + return {}; + } + + render(/*stage, { + topY, + primaryY, + shapeLayer, + labelLayer, + theme, + agentInfos, + textSizer, + SVGTextBlockClass, + state, + }*/) { + return 0; + } + } + + const components = new Map(); + + BaseComponent.register = (name, component) => { + components.set(name, component); + }; + + BaseComponent.getComponents = () => { + return components; + }; + + return BaseComponent; +}); diff --git a/scripts/sequence/components/Connect.js b/scripts/sequence/components/Connect.js new file mode 100644 index 0000000..1af5be6 --- /dev/null +++ b/scripts/sequence/components/Connect.js @@ -0,0 +1,234 @@ +define([ + './BaseComponent', + 'svg/SVGUtilities', + 'svg/SVGShapes', +], ( + BaseComponent, + svg, + SVGShapes +) => { + 'use strict'; + + 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 getArrowShort(theme) { + const arrow = theme.connect.arrow; + const h = arrow.height / 2; + const w = arrow.width; + const t = arrow.attrs['stroke-width'] * 0.5; + const lineStroke = theme.agentLineAttrs['stroke-width'] * 0.5; + const arrowDistance = t * Math.sqrt((w * w) / (h * h) + 1); + return lineStroke + arrowDistance; + } + + class Connect extends BaseComponent { + separation({agentNames, label}, env) { + const config = env.theme.connect; + + const labelWidth = ( + env.textSizer.measure(config.label.attrs, label).width + + config.label.padding * 2 + ); + + const short = getArrowShort(env.theme); + + const info1 = env.agentInfos.get(agentNames[0]); + if(agentNames[0] === agentNames[1]) { + env.addSpacing(agentNames[0], { + left: 0, + right: ( + info1.currentMaxRad + + labelWidth + + config.arrow.width + + short + + config.loopbackRadius + ), + }); + } else { + const info2 = env.agentInfos.get(agentNames[1]); + env.addSeparation( + agentNames[0], + agentNames[1], + + info1.currentMaxRad + + info2.currentMaxRad + + labelWidth + + config.arrow.width * 2 + + short * 2 + ); + } + } + + renderSelfConnect({label, agentNames, options}, env) { + const config = env.theme.connect; + const from = env.agentInfos.get(agentNames[0]); + + const dy = config.arrow.height / 2; + const short = getArrowShort(env.theme); + + const height = ( + env.textSizer.measureHeight(config.label.attrs, label) + + config.label.margin.top + + config.label.margin.bottom + ); + + const lineX = from.x + from.currentRad; + const y0 = env.primaryY; + const x0 = ( + lineX + + short + + config.arrow.width + + config.label.padding + ); + + const renderedText = SVGShapes.renderBoxedText(label, { + x: x0 - config.mask.padding.left, + y: y0 - height + config.label.margin.top, + padding: config.mask.padding, + boxAttrs: config.mask.maskAttrs, + labelAttrs: config.label.loopbackAttrs, + boxLayer: env.maskLayer, + labelLayer: env.labelLayer, + SVGTextBlockClass: env.SVGTextBlockClass, + }); + const r = config.loopbackRadius; + const x1 = ( + x0 + + renderedText.width + + config.label.padding - + config.mask.padding.left - + config.mask.padding.right + ); + const y1 = y0 + r * 2; + + env.shapeLayer.appendChild(svg.make('path', Object.assign({ + 'd': ( + 'M ' + (lineX + (options.left ? short : 0)) + ' ' + y0 + + ' L ' + x1 + ' ' + y0 + + ' A ' + r + ' ' + r + ' 0 0 1 ' + x1 + ' ' + y1 + + ' L ' + (lineX + (options.right ? short : 0)) + ' ' + y1 + ), + }, config.lineAttrs[options.line]))); + + if(options.left) { + drawHorizontalArrowHead(env.shapeLayer, { + x: lineX + short, + y: y0, + dx: config.arrow.width, + dy, + attrs: config.arrow.attrs, + }); + } + + if(options.right) { + drawHorizontalArrowHead(env.shapeLayer, { + x: lineX + short, + y: y1, + dx: config.arrow.width, + dy, + attrs: config.arrow.attrs, + }); + } + + return y1 + dy + env.theme.actionMargin; + } + + renderSimpleConnect({label, agentNames, options}, env) { + const config = env.theme.connect; + const from = env.agentInfos.get(agentNames[0]); + const to = env.agentInfos.get(agentNames[1]); + + const dy = config.arrow.height / 2; + const dir = (from.x < to.x) ? 1 : -1; + const short = getArrowShort(env.theme); + + const height = ( + env.textSizer.measureHeight(config.label.attrs, label) + + config.label.margin.top + + config.label.margin.bottom + ); + + const x0 = from.x + from.currentRad * dir; + const x1 = to.x - to.currentRad * dir; + let y = env.primaryY; + + SVGShapes.renderBoxedText(label, { + x: (x0 + x1) / 2, + y: y - height + config.label.margin.top, + padding: config.mask.padding, + boxAttrs: config.mask.maskAttrs, + labelAttrs: config.label.attrs, + boxLayer: env.maskLayer, + labelLayer: env.labelLayer, + SVGTextBlockClass: env.SVGTextBlockClass, + }); + + env.shapeLayer.appendChild(svg.make('line', Object.assign({ + 'x1': x0 + (options.left ? short : 0) * dir, + 'y1': y, + 'x2': x1 - (options.right ? short : 0) * dir, + 'y2': y, + }, config.lineAttrs[options.line]))); + + if(options.left) { + drawHorizontalArrowHead(env.shapeLayer, { + x: x0 + short * dir, + y, + dx: config.arrow.width * dir, + dy, + attrs: config.arrow.attrs, + }); + } + + if(options.right) { + drawHorizontalArrowHead(env.shapeLayer, { + x: x1 - short * dir, + y, + dx: -config.arrow.width * dir, + dy, + attrs: config.arrow.attrs, + }); + } + + return y + dy + env.theme.actionMargin; + } + + renderPre({label, agentNames}, env) { + const config = env.theme.connect; + + const height = ( + env.textSizer.measureHeight(config.label.attrs, label) + + config.label.margin.top + + config.label.margin.bottom + ); + + return { + agentNames, + topShift: Math.max(config.arrow.height / 2, height), + }; + } + + render(stage, env) { + if(stage.agentNames[0] === stage.agentNames[1]) { + return this.renderSelfConnect(stage, env); + } else { + return this.renderSimpleConnect(stage, env); + } + } + } + + BaseComponent.register('connect', new Connect()); + + return Connect; +}); diff --git a/scripts/sequence/components/Connect_spec.js b/scripts/sequence/components/Connect_spec.js new file mode 100644 index 0000000..19e0ab0 --- /dev/null +++ b/scripts/sequence/components/Connect_spec.js @@ -0,0 +1,14 @@ +defineDescribe('Connect', [ + './Connect', + './BaseComponent', +], ( + Connect, + BaseComponent +) => { + 'use strict'; + + it('registers itself with the component store', () => { + const components = BaseComponent.getComponents(); + expect(components.get('connect')).toEqual(jasmine.any(Connect)); + }); +}); diff --git a/scripts/sequence/components/Marker.js b/scripts/sequence/components/Marker.js new file mode 100644 index 0000000..80ce288 --- /dev/null +++ b/scripts/sequence/components/Marker.js @@ -0,0 +1,37 @@ +define(['./BaseComponent'], (BaseComponent) => { + 'use strict'; + + class Mark extends BaseComponent { + makeState(state) { + state.marks = new Map(); + } + + resetState(state) { + state.marks.clear(); + } + + render({name}, {topY, state}) { + state.marks.set(name, topY); + } + } + + class Async extends BaseComponent { + renderPre({target}, {state}) { + let y = 0; + if(target && state.marks) { + y = state.marks.get(target) || 0; + } + return { + asynchronousY: y, + }; + } + } + + BaseComponent.register('mark', new Mark()); + BaseComponent.register('async', new Async()); + + return { + Mark, + Async, + }; +}); diff --git a/scripts/sequence/components/Marker_spec.js b/scripts/sequence/components/Marker_spec.js new file mode 100644 index 0000000..2c397d1 --- /dev/null +++ b/scripts/sequence/components/Marker_spec.js @@ -0,0 +1,57 @@ +defineDescribe('Marker', [ + './Marker', + './BaseComponent', +], ( + Marker, + BaseComponent +) => { + 'use strict'; + + const mark = new Marker.Mark(); + const async = new Marker.Async(); + + describe('Mark', () => { + it('registers itself with the component store', () => { + const components = BaseComponent.getComponents(); + expect(components.get('mark')).toEqual(jasmine.any(Marker.Mark)); + }); + + it('records y coordinates when rendered', () => { + const state = {}; + mark.makeState(state); + mark.render({name: 'foo'}, {topY: 7, state}); + expect(state.marks.get('foo')).toEqual(7); + }); + }); + + describe('Async', () => { + it('registers itself with the component store', () => { + const components = BaseComponent.getComponents(); + expect(components.get('async')).toEqual(jasmine.any(Marker.Async)); + }); + + it('retrieves y coordinates when rendered', () => { + const state = {}; + mark.makeState(state); + mark.render({name: 'foo'}, {topY: 7, state}); + const result = async.renderPre({target: 'foo'}, {state}); + expect(result.asynchronousY).toEqual(7); + }); + + it('returns 0 if no target is given', () => { + const state = {}; + mark.makeState(state); + mark.render({name: 'foo'}, {topY: 7, state}); + const result = async.renderPre({target: ''}, {state}); + expect(result.asynchronousY).toEqual(0); + }); + + it('falls-back to 0 if the target is not found', () => { + const state = {}; + mark.makeState(state); + mark.render({name: 'foo'}, {topY: 7, state}); + const result = async.renderPre({target: 'bar'}, {state}); + expect(result.asynchronousY).toEqual(0); + }); + }); +}); diff --git a/scripts/sequence/components/Note.js b/scripts/sequence/components/Note.js new file mode 100644 index 0000000..4f41388 --- /dev/null +++ b/scripts/sequence/components/Note.js @@ -0,0 +1,258 @@ +define(['./BaseComponent'], (BaseComponent) => { + 'use strict'; + + function findExtremes(agentInfos, agentNames) { + let min = null; + let max = null; + agentNames.forEach((name) => { + const info = agentInfos.get(name); + 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, + }; + } + + class NoteComponent extends BaseComponent { + renderPre({agentNames}) { + return {agentNames}; + } + + renderNote({ + xMid = null, + x0 = null, + x1 = null, + anchor, + mode, + label, + }, env) { + const config = env.theme.note[mode]; + + const y = env.topY + config.margin.top + config.padding.top; + const labelNode = new env.SVGTextBlockClass(env.labelLayer, { + attrs: config.labelAttrs, + text: label, + y, + }); + + 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; + } + if(x1 === null && x0 !== null) { + x1 = x0 + fullW; + } else if(x0 === null) { + x0 = x1 - fullW; + } + switch(config.labelAttrs['text-anchor']) { + case 'middle': + labelNode.set({ + x: ( + x0 + config.padding.left + + x1 - config.padding.right + ) / 2, + y, + }); + break; + case 'end': + labelNode.set({x: x1 - config.padding.right, y}); + break; + default: + labelNode.set({x: x0 + config.padding.left, y}); + break; + } + + env.shapeLayer.appendChild(config.boxRenderer({ + x: x0, + y: env.topY + config.margin.top, + width: x1 - x0, + height: fullH, + })); + + return ( + env.topY + + config.margin.top + + fullH + + config.margin.bottom + + env.theme.actionMargin + ); + } + } + + class NoteOver extends NoteComponent { + separation({agentNames, mode, label}, env) { + const config = env.theme.note[mode]; + const width = ( + env.textSizer.measure(config.labelAttrs, label).width + + config.padding.left + + config.padding.right + ); + + if(agentNames.length > 1) { + const {left, right} = findExtremes(env.agentInfos, agentNames); + const infoL = env.agentInfos.get(left); + const infoR = env.agentInfos.get(right); + + const hangL = infoL.currentMaxRad + config.overlap.left; + const hangR = infoR.currentMaxRad + config.overlap.right; + + env.addSeparation(left, right, width - hangL - hangR); + + env.addSpacing(left, {left: hangL, right: 0}); + env.addSpacing(right, {left: 0, right: hangR}); + } else { + env.addSpacing(agentNames[0], { + left: width / 2, + right: width / 2, + }); + } + } + + render({agentNames, mode, label}, env) { + const config = env.theme.note[mode]; + + if(agentNames.length > 1) { + const {left, right} = findExtremes(env.agentInfos, agentNames); + const infoL = env.agentInfos.get(left); + const infoR = env.agentInfos.get(right); + return this.renderNote({ + x0: infoL.x - infoL.currentRad - config.overlap.left, + x1: infoR.x + infoR.currentRad + config.overlap.right, + anchor: 'middle', + mode, + label, + }, env); + } else { + const xMid = env.agentInfos.get(agentNames[0]).x; + return this.renderNote({ + xMid, + anchor: 'middle', + mode, + label, + }, env); + } + } + } + + class NoteSide extends NoteComponent { + constructor(isRight) { + super(); + this.isRight = isRight; + } + + separation({agentNames, mode, label}, env) { + const config = env.theme.note[mode]; + const {left, right} = findExtremes(env.agentInfos, agentNames); + const width = ( + env.textSizer.measure(config.labelAttrs, label).width + + config.padding.left + + config.padding.right + + config.margin.left + + config.margin.right + ); + + if(this.isRight) { + const info = env.agentInfos.get(right); + env.addSpacing(right, { + left: 0, + right: width + info.currentMaxRad, + }); + } else { + const info = env.agentInfos.get(left); + env.addSpacing(left, { + left: width + info.currentMaxRad, + right: 0, + }); + } + } + + render({agentNames, mode, label}, env) { + const config = env.theme.note[mode]; + const {left, right} = findExtremes(env.agentInfos, agentNames); + if(this.isRight) { + const info = env.agentInfos.get(right); + const x0 = info.x + info.currentRad + config.margin.left; + return this.renderNote({ + x0, + anchor: 'start', + mode, + label, + }, env); + } else { + const info = env.agentInfos.get(left); + const x1 = info.x - info.currentRad - config.margin.right; + return this.renderNote({ + x1, + anchor: 'end', + mode, + label, + }, env); + } + } + } + + class NoteBetween extends NoteComponent { + separation({agentNames, mode, label}, env) { + const config = env.theme.note[mode]; + const {left, right} = findExtremes(env.agentInfos, agentNames); + const infoL = env.agentInfos.get(left); + const infoR = env.agentInfos.get(right); + + env.addSeparation( + left, + right, + + env.textSizer.measure(config.labelAttrs, label).width + + config.padding.left + + config.padding.right + + config.margin.left + + config.margin.right + + infoL.currentMaxRad + + infoR.currentMaxRad + ); + } + + render({agentNames, mode, label}, env) { + const {left, right} = findExtremes(env.agentInfos, agentNames); + const infoL = env.agentInfos.get(left); + const infoR = env.agentInfos.get(right); + const xMid = ( + infoL.x + infoL.currentRad + + infoR.x - infoR.currentRad + ) / 2; + + return this.renderNote({ + xMid, + anchor: 'middle', + mode, + label, + }, env); + } + } + + NoteComponent.NoteOver = NoteOver; + NoteComponent.NoteSide = NoteSide; + NoteComponent.NoteBetween = NoteBetween; + + BaseComponent.register('note over', new NoteOver()); + BaseComponent.register('note left', new NoteSide(false)); + BaseComponent.register('note right', new NoteSide(true)); + BaseComponent.register('note between', new NoteBetween()); + + return NoteComponent; +}); diff --git a/scripts/sequence/components/Note_spec.js b/scripts/sequence/components/Note_spec.js new file mode 100644 index 0000000..bf05774 --- /dev/null +++ b/scripts/sequence/components/Note_spec.js @@ -0,0 +1,39 @@ +defineDescribe('Note', [ + './Note', + './BaseComponent', +], ( + Note, + BaseComponent +) => { + 'use strict'; + + describe('NoteOver', () => { + it('registers itself with the component store', () => { + const components = BaseComponent.getComponents(); + expect(components.get('note over')).toEqual( + jasmine.any(Note.NoteOver) + ); + }); + }); + + describe('NoteSide', () => { + it('registers itself with the component store', () => { + const components = BaseComponent.getComponents(); + expect(components.get('note left')).toEqual( + jasmine.any(Note.NoteSide) + ); + expect(components.get('note right')).toEqual( + jasmine.any(Note.NoteSide) + ); + }); + }); + + describe('NoteBetween', () => { + it('registers itself with the component store', () => { + const components = BaseComponent.getComponents(); + expect(components.get('note between')).toEqual( + jasmine.any(Note.NoteBetween) + ); + }); + }); +}); diff --git a/scripts/sequence/themes/Basic.js b/scripts/sequence/themes/Basic.js index e0386b0..12efaa6 100644 --- a/scripts/sequence/themes/Basic.js +++ b/scripts/sequence/themes/Basic.js @@ -1,14 +1,4 @@ -define([ - 'core/ArrayUtilities', - 'svg/SVGUtilities', - 'svg/SVGTextBlock', - 'svg/SVGShapes', -], ( - array, - svg, - SVGTextBlock, - SVGShapes -) => { +define(['core/ArrayUtilities', 'svg/SVGShapes'], (array, SVGShapes) => { 'use strict'; const LINE_HEIGHT = 1.3; @@ -18,6 +8,7 @@ define([ outerMargin: 5, agentMargin: 10, actionMargin: 5, + agentLineHighlightRadius: 4, agentCap: { box: { diff --git a/scripts/specs.js b/scripts/specs.js index ba295c7..1a9ca45 100644 --- a/scripts/specs.js +++ b/scripts/specs.js @@ -9,5 +9,10 @@ define([ 'sequence/Generator_spec', 'sequence/Renderer_spec', 'sequence/themes/Basic_spec', + 'sequence/components/AgentCap_spec', + 'sequence/components/AgentHighlight_spec', + 'sequence/components/Connect_spec', + 'sequence/components/Marker_spec', + 'sequence/components/Note_spec', 'sequence/sequence_integration_spec', ]); diff --git a/scripts/svg/SVGShapes.js b/scripts/svg/SVGShapes.js index 9521e12..1f8c9ef 100644 --- a/scripts/svg/SVGShapes.js +++ b/scripts/svg/SVGShapes.js @@ -106,5 +106,6 @@ define(['./SVGUtilities', './SVGTextBlock'], (svg, SVGTextBlock) => { renderBox, renderNote, renderBoxedText, + TextBlock: SVGTextBlock, }; });