Add alternative arrow types [#20]

This commit is contained in:
David Evans 2017-11-08 22:50:41 +00:00
parent e6064b72de
commit 121830f69c
17 changed files with 287 additions and 172 deletions

View File

@ -40,25 +40,26 @@ terminators box
``` ```
title Connection Types title Connection Types
begin Foo, Bar, Baz
Foo -> Bar: Simple arrow Foo -> Bar: Simple arrow
Foo --> Bar: Dashed arrow Bar --> Baz: Dashed arrow
Foo <- Bar: Reversed arrow Foo <- Bar: Reversed arrow
Foo <-- Bar: Reversed dashed arrow Bar <-- Baz: Reversed & dashed
Foo <-> Bar: Double arrow Foo <-> Bar: Double arrow
Foo <--> Bar: Double dashed arrow Bar <--> Baz: Double dashed arrow
# An arrow with no label: # An arrow with no label:
Foo -> Bar Foo -> Bar
Foo -> Foo: Foo talks to itself Bar ->> Baz: Different arrow
Foo <<--> Bar: Mix of arrows
Bar -> Bar: Bar talks to itself
Foo -> +Bar: Foo asks Bar Foo -> +Bar: Foo asks Bar
-Bar --> Foo: and Bar replies -Bar --> Foo: and Bar replies
# * and ! cause agents to be created and destroyed inline
Bar -> *Baz
Bar <- !Baz
# Arrows leaving on the left and right of the diagram # Arrows leaving on the left and right of the diagram
[ -> Foo: From the left [ -> Foo: From the left
[ <- Foo: To the left [ <- Foo: To the left
@ -139,12 +140,18 @@ too!'
``` ```
title "Baz doesn't live long" title "Baz doesn't live long"
Foo -> Bar note over Foo, Bar: Using begin / end
begin Baz begin Baz
Bar -> Baz Bar -> Baz
Baz -> Foo Baz -> Foo
end Baz end Baz
Foo -> Bar
note over Foo, Bar: Using * / !
# * and ! cause agents to be created and destroyed inline
Bar -> *Baz: make Baz
Foo <- !Baz: end Baz
# Foo and Bar end with black bars # Foo and Bar end with black bars
terminators bar terminators bar

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

View File

@ -56,6 +56,27 @@ define(() => {
return list[list.length - 1]; return list[list.length - 1];
} }
function combineRecur(parts, position, str, target) {
if(position >= parts.length) {
target.push(str);
return;
}
const choices = parts[position];
if(!Array.isArray(choices)) {
combineRecur(parts, position + 1, str + choices, target);
return;
}
for(let i = 0; i < choices.length; ++ i) {
combineRecur(parts, position + 1, str + choices[i], target);
}
}
function combine(parts) {
const target = [];
combineRecur(parts, 0, '', target);
return target;
}
return { return {
indexOf, indexOf,
mergeSets, mergeSets,
@ -63,5 +84,6 @@ define(() => {
removeAll, removeAll,
remove, remove,
last, last,
combine,
}; };
}); });

View File

@ -169,4 +169,21 @@ defineDescribe('ArrayUtilities', ['./ArrayUtilities'], (array) => {
expect(array.last([])).toEqual(undefined); expect(array.last([])).toEqual(undefined);
}); });
}); });
describe('.combine', () => {
it('returns all combinations of the given arguments', () => {
const list = array.combine([
['Aa', 'Bb'],
['Cc', 'Dd'],
'Ee',
['Ff'],
]);
expect(list).toEqual([
'AaCcEeFf',
'AaDdEeFf',
'BbCcEeFf',
'BbDdEeFf',
]);
});
});
}); });

View File

@ -7,11 +7,12 @@ define(['core/ArrayUtilities'], (array) => {
const end = {type: '', suggest: '\n', then: {}}; const end = {type: '', suggest: '\n', then: {}};
const hiddenEnd = {type: '', then: {}}; const hiddenEnd = {type: '', then: {}};
const ARROWS = [ const ARROWS = array.combine([
'->', '-->', ['', '<', '<<'],
'<-', '<--', ['-', '--'],
'<->', '<-->', ['', '>', '>>'],
]; ]);
array.removeAll(ARROWS, ['-', '--']);
const textToEnd = {type: 'string', then: {'': 0, '\n': end}}; const textToEnd = {type: 'string', then: {'': 0, '\n': end}};
const aliasListToEnd = {type: 'variable', suggest: 'Agent', then: { const aliasListToEnd = {type: 'variable', suggest: 'Agent', then: {

View File

@ -52,8 +52,8 @@ defineDescribe('Sequence Generator', ['./Generator'], (Generator) => {
connect: (agentNames, { connect: (agentNames, {
label = '', label = '',
line = '', line = '',
left = false, left = 0,
right = false, right = 0,
} = {}) => { } = {}) => {
return { return {
type: 'connect', type: 'connect',
@ -288,8 +288,8 @@ defineDescribe('Sequence Generator', ['./Generator'], (Generator) => {
PARSED.connect(['A', 'B'], { PARSED.connect(['A', 'B'], {
label: 'foo', label: 'foo',
line: 'bar', line: 'bar',
left: true, left: 1,
right: false, right: 0,
}), }),
]}); ]});
expect(sequence.stages).toEqual([ expect(sequence.stages).toEqual([
@ -297,8 +297,8 @@ defineDescribe('Sequence Generator', ['./Generator'], (Generator) => {
GENERATED.connect(['A', 'B'], { GENERATED.connect(['A', 'B'], {
label: 'foo', label: 'foo',
line: 'bar', line: 'bar',
left: true, left: 1,
right: false, right: 0,
}), }),
jasmine.anything(), jasmine.anything(),
]); ]);

View File

@ -12,18 +12,28 @@ define([
const BLOCK_TYPES = { const BLOCK_TYPES = {
'if': {type: 'block begin', mode: 'if', skip: []}, 'if': {type: 'block begin', mode: 'if', skip: []},
'else': {type: 'block split', mode: 'else', skip: ['if']}, 'else': {type: 'block split', mode: 'else', skip: ['if']},
'elif': {type: 'block split', mode: 'else', skip: []},
'repeat': {type: 'block begin', mode: 'repeat', skip: []}, 'repeat': {type: 'block begin', mode: 'repeat', skip: []},
}; };
const CONNECT_TYPES = { const CONNECT_TYPES = ((() => {
'->': {line: 'solid', left: false, right: true}, const lTypes = ['', '<', '<<'];
'<-': {line: 'solid', left: true, right: false}, const mTypes = ['-', '--'];
'<->': {line: 'solid', left: true, right: true}, const rTypes = ['', '>', '>>'];
'-->': {line: 'dash', left: false, right: true}, const arrows = array.combine([lTypes, mTypes, rTypes]);
'<--': {line: 'dash', left: true, right: false}, array.removeAll(arrows, mTypes);
'<-->': {line: 'dash', left: true, right: true},
}; const types = new Map();
arrows.forEach((arrow) => {
types.set(arrow, {
line: arrow.includes('--') ? 'dash' : 'solid',
left: lTypes.indexOf(arrow.substr(0, arrow.indexOf('-'))),
right: rTypes.indexOf(arrow.substr(arrow.lastIndexOf('-') + 1)),
});
});
return types;
})());
const CONNECT_AGENT_FLAGS = { const CONNECT_AGENT_FLAGS = {
'*': 'begin', '*': 'begin',
@ -323,7 +333,7 @@ define([
let typePos = -1; let typePos = -1;
let options = null; let options = null;
for(let j = 0; j < line.length; ++ j) { for(let j = 0; j < line.length; ++ j) {
const opts = CONNECT_TYPES[tokenKeyword(line[j])]; const opts = CONNECT_TYPES.get(tokenKeyword(line[j]));
if(opts) { if(opts) {
typePos = j; typePos = j;
options = opts; options = opts;

View File

@ -200,45 +200,40 @@ defineDescribe('Sequence Parser', ['./Parser'], (Parser) => {
'A <-> B\n' + 'A <-> B\n' +
'A --> B\n' + 'A --> B\n' +
'A <-- B\n' + 'A <-- B\n' +
'A <--> B\n' 'A <--> B\n' +
'A ->> B\n' +
'A <<- B\n' +
'A <<->> B\n' +
'A <->> B\n' +
'A <<-> B\n' +
'A -->> B\n' +
'A <<-- B\n' +
'A <<-->> B\n' +
'A <-->> B\n' +
'A <<--> B\n'
); );
expect(parsed.stages).toEqual([ expect(parsed.stages).toEqual([
PARSED.connect(['A', 'B'], { PARSED.connect(['A', 'B'], {
line: 'solid', line: 'solid',
left: false, left: 0,
right: true, right: 1,
label: '',
}),
PARSED.connect(['A', 'B'], {
line: 'solid',
left: true,
right: false,
label: '',
}),
PARSED.connect(['A', 'B'], {
line: 'solid',
left: true,
right: true,
label: '',
}),
PARSED.connect(['A', 'B'], {
line: 'dash',
left: false,
right: true,
label: '',
}),
PARSED.connect(['A', 'B'], {
line: 'dash',
left: true,
right: false,
label: '',
}),
PARSED.connect(['A', 'B'], {
line: 'dash',
left: true,
right: true,
label: '', label: '',
}), }),
PARSED.connect(['A', 'B'], {line: 'solid', left: 1, right: 0}),
PARSED.connect(['A', 'B'], {line: 'solid', left: 1, right: 1}),
PARSED.connect(['A', 'B'], {line: 'dash', left: 0, right: 1}),
PARSED.connect(['A', 'B'], {line: 'dash', left: 1, right: 0}),
PARSED.connect(['A', 'B'], {line: 'dash', left: 1, right: 1}),
PARSED.connect(['A', 'B'], {line: 'solid', left: 0, right: 2}),
PARSED.connect(['A', 'B'], {line: 'solid', left: 2, right: 0}),
PARSED.connect(['A', 'B'], {line: 'solid', left: 2, right: 2}),
PARSED.connect(['A', 'B'], {line: 'solid', left: 1, right: 2}),
PARSED.connect(['A', 'B'], {line: 'solid', left: 2, right: 1}),
PARSED.connect(['A', 'B'], {line: 'dash', left: 0, right: 2}),
PARSED.connect(['A', 'B'], {line: 'dash', left: 2, right: 0}),
PARSED.connect(['A', 'B'], {line: 'dash', left: 2, right: 2}),
PARSED.connect(['A', 'B'], {line: 'dash', left: 1, right: 2}),
PARSED.connect(['A', 'B'], {line: 'dash', left: 2, right: 1}),
]); ]);
}); });
@ -250,14 +245,14 @@ defineDescribe('Sequence Parser', ['./Parser'], (Parser) => {
expect(parsed.stages).toEqual([ expect(parsed.stages).toEqual([
PARSED.connect(['A', 'B'], { PARSED.connect(['A', 'B'], {
line: 'solid', line: 'solid',
left: true, left: 1,
right: false, right: 0,
label: 'B -> A', label: 'B -> A',
}), }),
PARSED.connect(['A', 'B'], { PARSED.connect(['A', 'B'], {
line: 'solid', line: 'solid',
left: false, left: 0,
right: true, right: 1,
label: 'B <- A', label: 'B <- A',
}), }),
]); ]);

View File

@ -33,8 +33,8 @@ defineDescribe('Sequence Renderer', [
label, label,
options: { options: {
line: 'solid', line: 'solid',
left: false, left: 0,
right: true, right: 1,
}, },
}; };
}, },

View File

@ -22,8 +22,17 @@ define([
)); ));
} }
function getArrowShort(theme) { class Arrowhead {
const arrow = theme.connect.arrow; constructor(propName) {
this.propName = propName;
}
getConfig(theme) {
return theme.connect.arrow[this.propName];
}
short(theme) {
const arrow = this.getConfig(theme);
const join = arrow.attrs['stroke-linejoin'] || 'miter'; const join = arrow.attrs['stroke-linejoin'] || 'miter';
const t = arrow.attrs['stroke-width'] * 0.5; const t = arrow.attrs['stroke-width'] * 0.5;
const lineStroke = theme.agentLineAttrs['stroke-width'] * 0.5; const lineStroke = theme.agentLineAttrs['stroke-width'] * 0.5;
@ -37,16 +46,63 @@ define([
} }
} }
render(layer, theme, {x, y, dir}) {
const config = this.getConfig(theme);
drawHorizontalArrowHead(layer, {
x: x + this.short(theme) * dir,
y,
dx: config.width * dir,
dy: config.height / 2,
attrs: config.attrs,
});
}
width(theme) {
return this.short(theme) + this.getConfig(theme).width;
}
height(theme) {
return this.getConfig(theme).height;
}
lineGap(theme, lineAttrs) {
const arrow = this.getConfig(theme);
const short = this.short(theme);
if(arrow.attrs.fill === 'none') {
const h = arrow.height / 2;
const w = arrow.width;
const safe = short + (lineAttrs['stroke-width'] / 2) * (w / h);
return (short + safe) / 2;
} else {
return short + arrow.width / 2;
}
}
}
const ARROWHEADS = [
{
render: () => {},
width: () => 0,
height: () => 0,
lineGap: () => 0,
},
new Arrowhead('single'),
new Arrowhead('double'),
];
class Connect extends BaseComponent { class Connect extends BaseComponent {
separation({agentNames, label}, env) { separation({label, agentNames, options}, env) {
const config = env.theme.connect; const config = env.theme.connect;
const labelWidth = ( const lArrow = ARROWHEADS[options.left];
env.textSizer.measure(config.label.attrs, label).width + const rArrow = ARROWHEADS[options.right];
config.label.padding * 2
);
const short = getArrowShort(env.theme); let labelWidth = (
env.textSizer.measure(config.label.attrs, label).width
);
if(labelWidth > 0) {
labelWidth += config.label.padding * 2;
}
const info1 = env.agentInfos.get(agentNames[0]); const info1 = env.agentInfos.get(agentNames[0]);
if(agentNames[0] === agentNames[1]) { if(agentNames[0] === agentNames[1]) {
@ -54,9 +110,10 @@ define([
left: 0, left: 0,
right: ( right: (
info1.currentMaxRad + info1.currentMaxRad +
labelWidth + Math.max(
config.arrow.width + labelWidth + lArrow.width(env.theme),
short + rArrow.width(env.theme)
) +
config.loopbackRadius config.loopbackRadius
), ),
}); });
@ -69,8 +126,10 @@ define([
info1.currentMaxRad + info1.currentMaxRad +
info2.currentMaxRad + info2.currentMaxRad +
labelWidth + labelWidth +
config.arrow.width * 2 + Math.max(
short * 2 lArrow.width(env.theme),
rArrow.width(env.theme)
) * 2
); );
} }
} }
@ -79,9 +138,8 @@ define([
const config = env.theme.connect; const config = env.theme.connect;
const from = env.agentInfos.get(agentNames[0]); const from = env.agentInfos.get(agentNames[0]);
const dx = config.arrow.width; const lArrow = ARROWHEADS[options.left];
const dy = config.arrow.height / 2; const rArrow = ARROWHEADS[options.right];
const short = getArrowShort(env.theme);
const height = ( const height = (
env.textSizer.measureHeight(config.label.attrs, label) + env.textSizer.measureHeight(config.label.attrs, label) +
@ -93,9 +151,8 @@ define([
const y0 = env.primaryY; const y0 = env.primaryY;
const x0 = ( const x0 = (
lineX + lineX +
short + lArrow.width(env.theme) +
dx + (label ? config.label.padding : 0)
config.label.padding
); );
const renderedText = SVGShapes.renderBoxedText(label, { const renderedText = SVGShapes.renderBoxedText(label, {
@ -108,48 +165,32 @@ define([
labelLayer: env.labelLayer, labelLayer: env.labelLayer,
SVGTextBlockClass: env.SVGTextBlockClass, SVGTextBlockClass: env.SVGTextBlockClass,
}); });
const r = config.loopbackRadius; const labelW = (label ? (
const x1 = (
x0 +
renderedText.width + renderedText.width +
config.label.padding - config.label.padding -
config.mask.padding.left - config.mask.padding.left -
config.mask.padding.right config.mask.padding.right
); ) : 0);
const r = config.loopbackRadius;
const x1 = Math.max(lineX + rArrow.width(env.theme), x0 + labelW);
const y1 = y0 + r * 2; const y1 = y0 + r * 2;
const space = short + dx / 2; const lineAttrs = config.lineAttrs[options.line];
env.shapeLayer.appendChild(svg.make('path', Object.assign({ env.shapeLayer.appendChild(svg.make('path', Object.assign({
'd': ( 'd': (
'M ' + (lineX + (options.left ? space : 0)) + ' ' + y0 + 'M ' + (lineX + lArrow.lineGap(env.theme, lineAttrs)) +
' ' + y0 +
' L ' + x1 + ' ' + y0 + ' L ' + x1 + ' ' + y0 +
' A ' + r + ' ' + r + ' 0 0 1 ' + x1 + ' ' + y1 + ' A ' + r + ' ' + r + ' 0 0 1 ' + x1 + ' ' + y1 +
' L ' + (lineX + (options.right ? space : 0)) + ' ' + y1 ' L ' + (lineX + rArrow.lineGap(env.theme, lineAttrs)) +
' ' + y1
), ),
}, config.lineAttrs[options.line]))); }, lineAttrs)));
if(options.left) { lArrow.render(env.shapeLayer, env.theme, {x: lineX, y: y0, dir: 1});
drawHorizontalArrowHead(env.shapeLayer, { rArrow.render(env.shapeLayer, env.theme, {x: lineX, y: y1, dir: 1});
x: lineX + short,
y: y0,
dx,
dy,
attrs: config.arrow.attrs,
});
}
if(options.right) { return y1 + rArrow.height(env.theme) / 2 + env.theme.actionMargin;
drawHorizontalArrowHead(env.shapeLayer, {
x: lineX + short,
y: y1,
dx,
dy,
attrs: config.arrow.attrs,
});
}
return y1 + dy + env.theme.actionMargin;
} }
renderSimpleConnect({label, agentNames, options}, env) { renderSimpleConnect({label, agentNames, options}, env) {
@ -157,10 +198,10 @@ define([
const from = env.agentInfos.get(agentNames[0]); const from = env.agentInfos.get(agentNames[0]);
const to = env.agentInfos.get(agentNames[1]); const to = env.agentInfos.get(agentNames[1]);
const dx = config.arrow.width; const lArrow = ARROWHEADS[options.left];
const dy = config.arrow.height / 2; const rArrow = ARROWHEADS[options.right];
const dir = (from.x < to.x) ? 1 : -1; const dir = (from.x < to.x) ? 1 : -1;
const short = getArrowShort(env.theme);
const height = ( const height = (
env.textSizer.measureHeight(config.label.attrs, label) + env.textSizer.measureHeight(config.label.attrs, label) +
@ -183,50 +224,47 @@ define([
SVGTextBlockClass: env.SVGTextBlockClass, SVGTextBlockClass: env.SVGTextBlockClass,
}); });
const space = short + dx / 2; const lineAttrs = config.lineAttrs[options.line];
env.shapeLayer.appendChild(svg.make('line', Object.assign({ env.shapeLayer.appendChild(svg.make('line', Object.assign({
'x1': x0 + (options.left ? space : 0) * dir, 'x1': x0 + lArrow.lineGap(env.theme, lineAttrs) * dir,
'y1': y, 'y1': y,
'x2': x1 - (options.right ? space : 0) * dir, 'x2': x1 - rArrow.lineGap(env.theme, lineAttrs) * dir,
'y2': y, 'y2': y,
}, config.lineAttrs[options.line]))); }, lineAttrs)));
if(options.left) { lArrow.render(env.shapeLayer, env.theme, {x: x0, y, dir});
drawHorizontalArrowHead(env.shapeLayer, { rArrow.render(env.shapeLayer, env.theme, {x: x1, y, dir: -dir});
x: x0 + short * dir,
y, return (
dx: dx * dir, y +
dy, Math.max(
attrs: config.arrow.attrs, lArrow.height(env.theme),
}); rArrow.height(env.theme)
) / 2 +
env.theme.actionMargin
);
} }
if(options.right) { renderPre({label, agentNames, options}, env) {
drawHorizontalArrowHead(env.shapeLayer, {
x: x1 - short * dir,
y,
dx: -dx * dir,
dy,
attrs: config.arrow.attrs,
});
}
return y + dy + env.theme.actionMargin;
}
renderPre({label, agentNames}, env) {
const config = env.theme.connect; const config = env.theme.connect;
const lArrow = ARROWHEADS[options.left];
const rArrow = ARROWHEADS[options.right];
const height = ( const height = (
env.textSizer.measureHeight(config.label.attrs, label) + env.textSizer.measureHeight(config.label.attrs, label) +
config.label.margin.top + config.label.margin.top +
config.label.margin.bottom config.label.margin.bottom
); );
let arrowH = lArrow.height(env.theme);
if(agentNames[0] !== agentNames[1]) {
arrowH = Math.max(arrowH, rArrow.height(env.theme));
}
return { return {
agentNames, agentNames,
topShift: Math.max(config.arrow.height / 2, height), topShift: Math.max(arrowH / 2, height),
}; };
} }

View File

@ -72,6 +72,7 @@ define(['core/ArrayUtilities', 'svg/SVGShapes'], (array, SVGShapes) => {
}, },
}, },
arrow: { arrow: {
single: {
width: 5, width: 5,
height: 10, height: 10,
attrs: { attrs: {
@ -80,6 +81,17 @@ define(['core/ArrayUtilities', 'svg/SVGShapes'], (array, SVGShapes) => {
'stroke-linejoin': 'miter', 'stroke-linejoin': 'miter',
}, },
}, },
double: {
width: 4,
height: 6,
attrs: {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 1,
'stroke-linejoin': 'miter',
},
},
},
label: { label: {
padding: 6, padding: 6,
margin: {top: 2, bottom: 1}, margin: {top: 2, bottom: 1},

View File

@ -78,6 +78,7 @@ define(['core/ArrayUtilities', 'svg/SVGShapes'], (array, SVGShapes) => {
}, },
}, },
arrow: { arrow: {
single: {
width: 10, width: 10,
height: 12, height: 12,
attrs: { attrs: {
@ -87,8 +88,20 @@ define(['core/ArrayUtilities', 'svg/SVGShapes'], (array, SVGShapes) => {
'stroke-linejoin': 'round', 'stroke-linejoin': 'round',
}, },
}, },
double: {
width: 10,
height: 12,
attrs: {
'fill': 'none',
'stroke': '#000000',
'stroke-width': 3,
'stroke-linejoin': 'round',
'stroke-linecap': 'round',
},
},
},
label: { label: {
padding: 6, padding: 7,
margin: {top: 2, bottom: 3}, margin: {top: 2, bottom: 3},
attrs: { attrs: {
'font-family': 'sans-serif', 'font-family': 'sans-serif',