Add support for agent line highlighting, also sets groundwork for parallel actions [#10]

This commit is contained in:
David Evans 2017-11-04 22:18:57 +00:00
parent dc3d930544
commit 71437d2576
21 changed files with 1644 additions and 740 deletions

View File

@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 49 KiB

View File

@ -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,

View File

@ -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'];

View File

@ -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) => {
}
};
});

View File

@ -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', ['[', ']']),
]);
});

File diff suppressed because it is too large Load Diff

View File

@ -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;
});

View File

@ -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));
});
});

View File

@ -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;
});

View File

@ -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);
});
});

View File

@ -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;
});

View File

@ -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;
});

View File

@ -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));
});
});

View File

@ -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,
};
});

View File

@ -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);
});
});
});

View File

@ -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;
});

View File

@ -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)
);
});
});
});

View File

@ -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: {

View File

@ -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',
]);

View File

@ -106,5 +106,6 @@ define(['./SVGUtilities', './SVGTextBlock'], (svg, SVGTextBlock) => {
renderBox,
renderNote,
renderBoxedText,
TextBlock: SVGTextBlock,
};
});