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 {
meta,
meta: {
title: meta.title,
},
agents,
stages: rootStages,
};

View File

@ -14,13 +14,13 @@ defineDescribe('Sequence Generator', ['./Generator'], (Generator) => {
const BLOCK_END = 'block end';
describe('.generate', () => {
it('propagates metadata', () => {
it('propagates title metadata', () => {
const input = {
meta: {foo: 'bar'},
meta: {title: 'bar'},
stages: [],
};
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', () => {

View File

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

View File

@ -5,6 +5,11 @@ defineDescribe('Sequence Renderer', ['./Renderer'], (Renderer) => {
beforeEach(() => {
renderer = new Renderer();
document.body.appendChild(renderer.svg());
});
afterEach(() => {
document.body.removeChild(renderer.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', () => {
it('populates the SVG with content', () => {
renderer.render({
@ -25,5 +41,101 @@ defineDescribe('Sequence Renderer', ['./Renderer'], (Renderer) => {
const title = element.getElementsByClassName('title')[0];
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);
});
});
});