Simplify column positioning, automatic reordering where guaranteed safe

This commit is contained in:
David Evans 2017-10-23 22:02:59 +01:00
parent 45295a3843
commit 1449d73194
4 changed files with 255 additions and 120 deletions

View File

@ -184,7 +184,9 @@ define(() => {
); );
return { return {
meta, meta: {
title: meta.title,
},
agents, agents,
stages: rootStages, stages: rootStages,
}; };

View File

@ -14,13 +14,13 @@ defineDescribe('Sequence Generator', ['./Generator'], (Generator) => {
const BLOCK_END = 'block end'; const BLOCK_END = 'block end';
describe('.generate', () => { describe('.generate', () => {
it('propagates metadata', () => { it('propagates title metadata', () => {
const input = { const input = {
meta: {foo: 'bar'}, meta: {title: 'bar'},
stages: [], stages: [],
}; };
const sequence = generator.generate(input); const sequence = generator.generate(input);
expect(sequence.meta).toEqual({foo: 'bar'}); expect(sequence.meta).toEqual({title: 'bar'});
}); });
it('returns an empty sequence for blank input', () => { it('returns an empty sequence for blank input', () => {

View File

@ -32,6 +32,8 @@ define(() => {
} }
} }
const SEP_ZERO = {left: 0, right: 0};
const NS = 'http://www.w3.org/2000/svg'; const NS = 'http://www.w3.org/2000/svg';
const LINE_HEIGHT = 1.3; const LINE_HEIGHT = 1.3;
@ -160,6 +162,24 @@ define(() => {
this.diagram.appendChild(this.actions); this.diagram.appendChild(this.actions);
this.base.appendChild(this.diagram); this.base.appendChild(this.diagram);
this.separationAgentCap = {
'box': this.separationAgentCapBox.bind(this),
'cross': this.separationAgentCapCross.bind(this),
'bar': this.separationAgentCapBar.bind(this),
'none': this.separationAgentCapNone.bind(this),
};
this.separationAction = {
'agent begin': this.separationAgent.bind(this),
'agent end': this.separationAgent.bind(this),
'connection': this.separationConnection.bind(this),
'block': this.separationBlock.bind(this),
'note over': this.separationNoteOver.bind(this),
'note left': this.separationNoteLeft.bind(this),
'note right': this.separationNoteRight.bind(this),
'note between': this.separationNoteBetween.bind(this),
};
this.renderAgentCap = { this.renderAgentCap = {
'box': this.renderAgentCapBox.bind(this), 'box': this.renderAgentCapBox.bind(this),
'cross': this.renderAgentCapCross.bind(this), 'cross': this.renderAgentCapCross.bind(this),
@ -178,121 +198,117 @@ define(() => {
'note between': this.renderNoteBetween.bind(this), 'note between': this.renderNoteBetween.bind(this),
}; };
this.separationAction = {
'agent begin': this.separationAgentCap.bind(this),
'agent end': this.separationAgentCap.bind(this),
'connection': this.separationConnection.bind(this),
'block': this.separationBlock.bind(this),
'note over': this.separationNoteOver.bind(this),
'note left': this.separationNoteLeft.bind(this),
'note right': this.separationNoteRight.bind(this),
'note between': this.separationNoteBetween.bind(this),
};
this.width = 0; this.width = 0;
this.height = 0; this.height = 0;
} }
addSeparation(agentInfo, agent, dist) { addSeparation(agent1, agent2, dist) {
let d = agentInfo.separations.get(agent) || 0; const info1 = this.agentInfos.get(agent1);
agentInfo.separations.set(agent, Math.max(d, dist)); const info2 = this.agentInfos.get(agent2);
const d1 = info1.separations.get(agent2) || 0;
info1.separations.set(agent2, Math.max(d1, dist));
const d2 = info2.separations.get(agent1) || 0;
info2.separations.set(agent1, Math.max(d2, dist));
} }
separationAgentCap(agentInfos, stage) { addSeparations(agents, agentSpaces) {
switch(stage.mode) { agents.forEach((agent1) => {
case 'box': const info1 = this.agentInfos.get(agent1);
case 'bar': const sep1 = agentSpaces.get(agent1) || SEP_ZERO;
stage.agents.forEach((agent1) => { agents.forEach((agent2) => {
const info1 = agentInfos.get(agent1); const info2 = this.agentInfos.get(agent2);
const sep1 = ( if(info2.index >= info1.index) {
info1.labelWidth / 2 +
AGENT_MARGIN
);
stage.agents.forEach((agent2) => {
if(agent1 === agent2) {
return; return;
} }
const info2 = agentInfos.get(agent2); const sep2 = agentSpaces.get(agent2) || SEP_ZERO;
this.addSeparation(info1, agent2, this.addSeparation(
sep1 + info2.labelWidth / 2 agent1,
agent2,
sep1.right + sep2.left + AGENT_MARGIN
); );
}); });
this.visibleAgents.forEach((agent2) => { });
if(stage.agents.indexOf(agent2) === -1) {
const info2 = agentInfos.get(agent2);
this.addSeparation(info1, agent2, sep1);
this.addSeparation(info2, agent1, sep1);
} }
});
}); separationAgentCapBox(agentInfo) {
break; return {
case 'cross': left: agentInfo.labelWidth / 2,
stage.agents.forEach((agent1) => { right: agentInfo.labelWidth / 2,
const info1 = agentInfos.get(agent1); };
const sep1 = (
AGENT_CROSS_SIZE / 2 +
AGENT_MARGIN
);
stage.agents.forEach((agent2) => {
if(agent1 === agent2) {
return;
} }
this.addSeparation(info1, agent2,
sep1 + AGENT_CROSS_SIZE / 2 separationAgentCapCross() {
); return {
}); left: AGENT_CROSS_SIZE / 2,
this.visibleAgents.forEach((agent2) => { right: AGENT_CROSS_SIZE / 2,
if(stage.agents.indexOf(agent2) === -1) { };
const info2 = agentInfos.get(agent2);
this.addSeparation(info1, agent2, sep1);
this.addSeparation(info2, agent1, sep1);
} }
});
}); separationAgentCapBar(agentInfo) {
break; return {
left: agentInfo.labelWidth / 2,
right: agentInfo.labelWidth / 2,
};
} }
if(stage.type === 'agent begin') {
mergeSets(this.visibleAgents, stage.agents); separationAgentCapNone() {
} else if(stage.type === 'agent end') { return {left: 0, right: 0};
removeAll(this.visibleAgents, stage.agents); }
separationAgent({type, mode, agents}) {
if(type === 'agent begin') {
mergeSets(this.visibleAgents, agents);
}
const agentSpaces = new Map();
agents.forEach((agent) => {
const info = this.agentInfos.get(agent);
const separationFn = this.separationAgentCap[mode];
agentSpaces.set(agent, separationFn(info));
});
this.addSeparations(this.visibleAgents, agentSpaces);
if(type === 'agent end') {
removeAll(this.visibleAgents, agents);
} }
} }
separationConnection(agentInfos, stage) { separationConnection({agents, label}) {
const w = ( this.addSeparation(
this.testTextWidth(this.testConnectWidth, stage.label) + agents[0],
agents[1],
this.testTextWidth(this.testConnectWidth, label) +
CONNECT_POINT * 2 + CONNECT_POINT * 2 +
CONNECT_LABEL_PADDING * 2 + CONNECT_LABEL_PADDING * 2 +
ATTRS.AGENT_LINE['stroke-width'] ATTRS.AGENT_LINE['stroke-width']
); );
const agent1 = stage.agents[0];
const agent2 = stage.agents[1];
this.addSeparation(agentInfos.get(agent1), agent2, w);
this.addSeparation(agentInfos.get(agent2), agent1, w);
} }
separationBlock(/*agentInfos, stage*/) { separationBlock(/*stage*/) {
// TODO // TODO
} }
separationNoteOver(/*agentInfos, stage*/) { separationNoteOver(/*stage*/) {
// TODO // TODO
} }
separationNoteLeft(/*agentInfos, stage*/) { separationNoteLeft(/*stage*/) {
// TODO // TODO
} }
separationNoteRight(/*agentInfos, stage*/) { separationNoteRight(/*stage*/) {
// TODO // TODO
} }
separationNoteBetween(/*agentInfos, stage*/) { separationNoteBetween(/*stage*/) {
// TODO // TODO
} }
checkSeparation(agentInfos, stage) { checkSeparation(stage) {
this.separationAction[stage.type](agentInfos, stage); this.separationAction[stage.type](stage);
} }
renderAgentCapBox({x, labelWidth, label}) { renderAgentCapBox({x, labelWidth, label}) {
@ -361,37 +377,38 @@ define(() => {
}; };
} }
renderAgentBegin(agentInfos, stage) { renderAgentBegin({mode, agents}) {
let shifts = {height: 0}; let shifts = {height: 0};
stage.agents.forEach((agent) => { agents.forEach((agent) => {
const agentInfo = agentInfos.get(agent); const agentInfo = this.agentInfos.get(agent);
shifts = this.renderAgentCap[stage.mode](agentInfo); shifts = this.renderAgentCap[mode](agentInfo);
agentInfo.latestYStart = this.currentY + shifts.lineBottom; agentInfo.latestYStart = this.currentY + shifts.lineBottom;
}); });
this.currentY += shifts.height + ACTION_MARGIN; this.currentY += shifts.height + ACTION_MARGIN;
} }
renderAgentEnd(agentInfos, stage) { renderAgentEnd({mode, agents}) {
let shifts = {height: 0}; let shifts = {height: 0};
stage.agents.forEach((agent) => { agents.forEach((agent) => {
const agentInfo = agentInfos.get(agent); const agentInfo = this.agentInfos.get(agent);
const x = agentInfo.x; const x = agentInfo.x;
shifts = this.renderAgentCap[stage.mode](agentInfo); shifts = this.renderAgentCap[mode](agentInfo);
this.agentLines.appendChild(makeSVGNode('path', Object.assign({ this.agentLines.appendChild(makeSVGNode('path', Object.assign({
'd': ( 'd': (
'M ' + x + ' ' + agentInfo.latestYStart + 'M ' + x + ' ' + agentInfo.latestYStart +
' L ' + x + ' ' + (this.currentY + shifts.lineTop) ' L ' + x + ' ' + (this.currentY + shifts.lineTop)
), ),
'class': 'agent-' + agentInfo.index + '-line',
}, ATTRS.AGENT_LINE))); }, ATTRS.AGENT_LINE)));
agentInfo.latestYStart = null; agentInfo.latestYStart = null;
}); });
this.currentY += shifts.height + ACTION_MARGIN; this.currentY += shifts.height + ACTION_MARGIN;
} }
renderConnection(agentInfos, {label, agents, line, left, right}) { renderConnection({label, agents, line, left, right}) {
/* jshint -W074, -W071 */ // TODO: tidy this up /* jshint -W074, -W071 */ // TODO: tidy this up
const from = agentInfos.get(agents[0]); const from = this.agentInfos.get(agents[0]);
const to = agentInfos.get(agents[1]); const to = this.agentInfos.get(agents[1]);
const dy = CONNECT_HEIGHT / 2; const dy = CONNECT_HEIGHT / 2;
const dx = CONNECT_POINT; const dx = CONNECT_POINT;
@ -466,28 +483,28 @@ define(() => {
this.currentY = y + dy + ACTION_MARGIN; this.currentY = y + dy + ACTION_MARGIN;
} }
renderBlock(/*agentInfos, stage*/) { renderBlock(/*stage*/) {
// TODO // TODO
} }
renderNoteOver(/*agentInfos, stage*/) { renderNoteOver(/*stage*/) {
// TODO // TODO
} }
renderNoteLeft(/*agentInfos, stage*/) { renderNoteLeft(/*stage*/) {
// TODO // TODO
} }
renderNoteRight(/*agentInfos, stage*/) { renderNoteRight(/*stage*/) {
// TODO // TODO
} }
renderNoteBetween(/*agentInfos, stage*/) { renderNoteBetween(/*stage*/) {
// TODO // TODO
} }
addAction(agentInfos, stage) { addAction(stage) {
this.renderAction[stage.type](agentInfos, stage); this.renderAction[stage.type](stage);
} }
makeTextTester(attrs) { makeTextTester(attrs) {
@ -510,42 +527,43 @@ define(() => {
buildAgentInfos(agents, stages) { buildAgentInfos(agents, stages) {
const testNameWidth = this.makeTextTester(ATTRS.AGENT_BOX_LABEL); const testNameWidth = this.makeTextTester(ATTRS.AGENT_BOX_LABEL);
const agentInfos = new Map(); this.agentInfos = new Map();
agents.forEach((agent) => { agents.forEach((agent, index) => {
agentInfos.set(agent, { this.agentInfos.set(agent, {
label: agent, label: agent,
labelWidth: ( labelWidth: (
this.testTextWidth(testNameWidth, agent) + this.testTextWidth(testNameWidth, agent) +
BOX_PADDING * 2 BOX_PADDING * 2
), ),
index,
x: null, x: null,
latestYStart: null, latestYStart: null,
separations: new Map(), separations: new Map(),
}); });
}); });
agentInfos.get('[').labelWidth = 0; this.agentInfos.get('[').labelWidth = 0;
agentInfos.get(']').labelWidth = 0; this.agentInfos.get(']').labelWidth = 0;
this.removeTextTester(testNameWidth); this.removeTextTester(testNameWidth);
this.testConnectWidth = this.makeTextTester(ATTRS.CONNECT_LABEL); this.testConnectWidth = this.makeTextTester(ATTRS.CONNECT_LABEL);
this.visibleAgents = ['[', ']']; this.visibleAgents = ['[', ']'];
traverse(stages, this.checkSeparation.bind(this, agentInfos)); traverse(stages, this.checkSeparation.bind(this));
this.removeTextTester(this.testConnectWidth); this.removeTextTester(this.testConnectWidth);
let currentX = 0;
agents.forEach((agent) => { agents.forEach((agent) => {
const agentInfo = agentInfos.get(agent); const agentInfo = this.agentInfos.get(agent);
let currentX = 0;
agentInfo.separations.forEach((dist, otherAgent) => { agentInfo.separations.forEach((dist, otherAgent) => {
const otherAgentInfo = agentInfos.get(otherAgent); const otherAgentInfo = this.agentInfos.get(otherAgent);
if(otherAgentInfo.x !== null) { if(otherAgentInfo.x !== null) {
currentX = Math.max(currentX, otherAgentInfo.x + dist); currentX = Math.max(currentX, otherAgentInfo.x + dist);
} }
}); });
agentInfo.x = currentX; agentInfo.x = currentX;
this.minX = Math.min(this.minX, currentX);
this.maxX = Math.max(this.maxX, currentX);
}); });
return {agentInfos, minX: 0, maxX: currentX};
} }
updateBounds(stagesHeight) { updateBounds(stagesHeight) {
@ -584,17 +602,20 @@ define(() => {
this.titleText.nodeValue = meta.title || ''; this.titleText.nodeValue = meta.title || '';
const info = this.buildAgentInfos(agents, stages); this.minX = 0;
this.maxX = 0;
this.buildAgentInfos(agents, stages);
this.minX = info.minX;
this.maxX = info.maxX;
this.currentY = 0; this.currentY = 0;
traverse(stages, this.addAction.bind(this));
traverse(stages, this.addAction.bind(this, info.agentInfos));
this.updateBounds(Math.max(this.currentY - ACTION_MARGIN, 0)); this.updateBounds(Math.max(this.currentY - ACTION_MARGIN, 0));
} }
getColumnX(name) {
return this.agentInfos.get(name).x;
}
svg() { svg() {
return this.base; return this.base;
} }

View File

@ -5,6 +5,11 @@ defineDescribe('Sequence Renderer', ['./Renderer'], (Renderer) => {
beforeEach(() => { beforeEach(() => {
renderer = new Renderer(); renderer = new Renderer();
document.body.appendChild(renderer.svg());
});
afterEach(() => {
document.body.removeChild(renderer.svg());
}); });
describe('.svg', () => { describe('.svg', () => {
@ -14,6 +19,17 @@ defineDescribe('Sequence Renderer', ['./Renderer'], (Renderer) => {
}); });
}); });
function connectionStage(agents, label = '') {
return {
type: 'connection',
line: 'solid',
left: false,
right: true,
agents,
label,
};
}
describe('.render', () => { describe('.render', () => {
it('populates the SVG with content', () => { it('populates the SVG with content', () => {
renderer.render({ renderer.render({
@ -25,5 +41,101 @@ defineDescribe('Sequence Renderer', ['./Renderer'], (Renderer) => {
const title = element.getElementsByClassName('title')[0]; const title = element.getElementsByClassName('title')[0];
expect(title.innerHTML).toEqual('Title'); expect(title.innerHTML).toEqual('Title');
}); });
it('positions column lines', () => {
/*
A -> B
*/
renderer.render({
meta: {title: ''},
agents: ['[', 'A', 'B', ']'],
stages: [
{type: 'agent begin', agents: ['A', 'B'], mode: 'box'},
connectionStage(['A', 'B']),
{type: 'agent end', agents: ['A', 'B'], mode: 'none'},
],
});
const element = renderer.svg();
const line = element.getElementsByClassName('agent-1-line')[0];
const drawnX = Number(line.getAttribute('d').split(' ')[1]);
expect(drawnX).toEqual(renderer.getColumnX('A'));
});
it('arranges columns left-to-right', () => {
/*
[ -> A
A -> B
B -> C
C -> ]
*/
renderer.render({
meta: {title: ''},
agents: ['[', 'A', 'B', 'C', ']'],
stages: [
{type: 'agent begin', agents: ['A', 'B', 'C'], mode: 'box'},
connectionStage(['[', 'A']),
connectionStage(['A', 'B']),
connectionStage(['B', 'C']),
connectionStage(['C', ']']),
{type: 'agent end', agents: ['A', 'B', 'C'], mode: 'none'},
],
});
const xL = renderer.getColumnX('[');
const xA = renderer.getColumnX('A');
const xB = renderer.getColumnX('B');
const xC = renderer.getColumnX('C');
const xR = renderer.getColumnX(']');
expect(xA).toBeGreaterThan(xL);
expect(xB).toBeGreaterThan(xA);
expect(xC).toBeGreaterThan(xB);
expect(xR).toBeGreaterThan(xC);
});
it('allows column reordering for mutually-exclusive columns', () => {
/*
A -> B: short
end B
A -> C: long description here
end C
A -> D: short again
end A, D
*/
renderer.render({
meta: {title: ''},
agents: ['[', 'A', 'B', 'C', 'D', ']'],
stages: [
{type: 'agent begin', agents: ['A', 'B'], mode: 'box'},
connectionStage(['A', 'B'], 'short'),
{type: 'agent end', agents: ['B'], mode: 'cross'},
{type: 'agent begin', agents: ['C'], mode: 'box'},
connectionStage(['A', 'C'], 'long description here'),
{type: 'agent end', agents: ['C'], mode: 'cross'},
{type: 'agent begin', agents: ['D'], mode: 'box'},
connectionStage(['A', 'D'], 'short again'),
{type: 'agent end', agents: ['A', 'D'], mode: 'cross'},
],
});
const xA = renderer.getColumnX('A');
const xB = renderer.getColumnX('B');
const xC = renderer.getColumnX('C');
const xD = renderer.getColumnX('D');
expect(xB).toBeGreaterThan(xA);
expect(xC).toBeGreaterThan(xA);
expect(xD).toBeGreaterThan(xA);
expect(xC).toBeGreaterThan(xB);
expect(xD).toBeGreaterThan(xB);
expect(xD).toBeLessThan(xC);
});
}); });
}); });