Add support for asynchronous communication [#41]

This commit is contained in:
David Evans 2018-01-25 22:59:16 +00:00
parent 8344ab2a44
commit bbb9350e15
30 changed files with 1595 additions and 493 deletions

View File

@ -250,6 +250,28 @@ Foo -> Bar
Bar -> Baz Bar -> Baz
``` ```
### Asynchronous Communication
<img src="screenshots/AsynchronousCommunication.png" alt="Asynchronous Communication preview" width="200" align="right" />
```
begin Initiator as I, Receiver as R
# the '...id' syntax allows connections to span multiple lines
I -> ...fin1
...fin1 -> R: FIN
# they can even inter-mix!
R -> ...ack1
R -> ...fin2
...ack1 -> I: ACK
...fin2 -> I: FIN
!I -> ...ack2
...ack2 -> !R: ACK
```
### Simultaneous Actions (Beta!) ### Simultaneous Actions (Beta!)
This is a work-in-progress feature. There are situations where this can This is a work-in-progress feature. There are situations where this can

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -343,6 +343,25 @@ Foo -> Bar
Bar -> Baz Bar -> Baz
</pre> </pre>
<h3 id="AsynchronousCommunication">Asynchronous Communication</h3>
<pre class="example" data-lang="sequence">
begin Initiator as I, Receiver as R
# the '...id' syntax allows connections to span multiple lines
I -> ...fin1
...fin1 -> R: FIN
# they can even inter-mix!
R -> ...ack1
R -> ...fin2
...ack1 -> I: ACK
...fin2 -> I: FIN
!I -> ...ack2
...ack2 -> !R: ACK
</pre>
<h3 id="LanguageMore">More</h3> <h3 id="LanguageMore">More</h3>
<p> <p>

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@ -56,6 +56,15 @@
title: 'Self-connection', title: 'Self-connection',
code: '{Agent1} -> {Agent1}: {Message}', code: '{Agent1} -> {Agent1}: {Message}',
}, },
{
title: 'Asynchronous message',
code: '{Agent1} -> ...{id}\n...{id} -> {Agent2}: {Message}',
preview: (
'begin A, B\n' +
'A -> ...x\n' +
'...x -> B: Message'
),
},
{ {
title: 'Found message', title: 'Found message',
code: '* -> {Agent1}: {Message}', code: '* -> {Agent1}: {Message}',

View File

@ -2,8 +2,11 @@ define(['core/ArrayUtilities'], (array) => {
'use strict'; 'use strict';
const TRIMMER = /^([ \t]*)(.*)$/; const TRIMMER = /^([ \t]*)(.*)$/;
const SQUASH_START = /^[ \t\r\n:,]/; const SQUASH = {
const SQUASH_END = /[ \t\r\n]$/; start: /^[ \t\r\n:,]/,
end: /[ \t\r\n]$/,
after: '.!+', // cannot squash after * or - in all cases
};
const ONGOING_QUOTE = /^"(\\.|[^"])*$/; const ONGOING_QUOTE = /^"(\\.|[^"])*$/;
const REQUIRED_QUOTED = /[\r\n:,"<>\-~]/; const REQUIRED_QUOTED = /[\r\n:,"<>\-~]/;
const QUOTE_ESCAPE = /["\\]/g; const QUOTE_ESCAPE = /["\\]/g;
@ -24,6 +27,9 @@ define(['core/ArrayUtilities'], (array) => {
squash: {line: line, ch: chFrom}, squash: {line: line, ch: chFrom},
}; };
if(chFrom > 0 && ln[chFrom - 1] === ' ') { if(chFrom > 0 && ln[chFrom - 1] === ' ') {
if(SQUASH.after.includes(ln[chFrom - 2])) {
ranges.word.ch --;
}
ranges.squash.ch --; ranges.squash.ch --;
} }
return ranges; return ranges;
@ -69,8 +75,8 @@ define(['core/ArrayUtilities'], (array) => {
text: quoted, text: quoted,
displayText: quoted.trim(), displayText: quoted.trim(),
className: null, className: null,
from: SQUASH_START.test(quoted) ? from.squash : from.word, from: SQUASH.start.test(quoted) ? from.squash : from.word,
to: SQUASH_END.test(quoted) ? ranges.to.squash : ranges.to.word, to: SQUASH.end.test(quoted) ? ranges.to.squash : ranges.to.word,
displayFrom: from.word, displayFrom: from.word,
}; };
} }

View File

@ -205,24 +205,67 @@ define(['core/ArrayUtilities'], (array) => {
const connect = { const connect = {
type: 'keyword', type: 'keyword',
suggest: true, suggest: true,
then: makeOpBlock(agentToOptText, { then: Object.assign({}, makeOpBlock(agentToOptText, {
':': colonTextToEnd, ':': colonTextToEnd,
'\n': hiddenEnd, '\n': hiddenEnd,
}), {
'...': {type: 'operator', suggest: true, then: {
'': {
type: 'variable',
suggest: {known: 'DelayedAgent'},
then: {
'': 0,
':': CM_ERROR,
'\n': end,
},
},
}},
}), }),
}; };
const then = {'': 0}; const connectors = {};
arrows.forEach((arrow) => (then[arrow] = connect)); arrows.forEach((arrow) => (connectors[arrow] = connect));
then[':'] = {
const labelIndicator = {
type: 'operator', type: 'operator',
suggest: true, suggest: true,
override: 'Label', override: 'Label',
then: {}, then: {},
}; };
return makeOpBlock(
{type: 'variable', suggest: {known: 'Agent'}, then}, const hiddenLabelIndicator = {
then type: 'operator',
); suggest: false,
override: 'Label',
then: {},
};
const firstAgent = {
type: 'variable',
suggest: {known: 'Agent'},
then: Object.assign({
'': 0,
':': labelIndicator,
}, connectors),
};
const firstAgentDelayed = {
type: 'variable',
suggest: {known: 'DelayedAgent'},
then: Object.assign({
'': 0,
':': hiddenLabelIndicator,
}, connectors),
};
return Object.assign({
'...': {type: 'operator', suggest: true, then: {
'': firstAgentDelayed,
}},
}, makeOpBlock(firstAgent, Object.assign({
'': firstAgent,
':': hiddenLabelIndicator,
}, connectors)));
} }
const BASE_THEN = { const BASE_THEN = {
@ -480,6 +523,7 @@ define(['core/ArrayUtilities'], (array) => {
currentSpace: '', currentSpace: '',
currentQuoted: false, currentQuoted: false,
knownAgent: [], knownAgent: [],
knownDelayedAgent: [],
knownLabel: [], knownLabel: [],
beginCompletions: cmMakeCompletions({}, [this.commands]), beginCompletions: cmMakeCompletions({}, [this.commands]),
completions: [], completions: [],

View File

@ -139,7 +139,7 @@ defineDescribe('Code Mirror Mode', [
]); ]);
}); });
it('recognises agent flags', () => { it('recognises agent flags on the right', () => {
cm.getDoc().setValue('Foo -> *Bar'); cm.getDoc().setValue('Foo -> *Bar');
expect(getTokens(0)).toEqual([ expect(getTokens(0)).toEqual([
{v: 'Foo', type: 'variable'}, {v: 'Foo', type: 'variable'},
@ -149,6 +149,16 @@ defineDescribe('Code Mirror Mode', [
]); ]);
}); });
it('recognises agent flags on the left', () => {
cm.getDoc().setValue('*Foo -> Bar');
expect(getTokens(0)).toEqual([
{v: '*', type: 'operator'},
{v: 'Foo', type: 'variable'},
{v: ' ->', type: 'keyword'},
{v: ' Bar', type: 'variable'},
]);
});
it('rejects missing agent names', () => { it('rejects missing agent names', () => {
cm.getDoc().setValue('+ -> Bar'); cm.getDoc().setValue('+ -> Bar');
expect(getTokens(0)[2].type).toContain('line-error'); expect(getTokens(0)[2].type).toContain('line-error');
@ -198,6 +208,30 @@ defineDescribe('Code Mirror Mode', [
expect(getTokens(0)[4].type).toContain('line-error'); expect(getTokens(0)[4].type).toContain('line-error');
}); });
it('highlights delayed message syntax', () => {
cm.getDoc().setValue('A -> ...x\n...x -> B: hello');
expect(getTokens(0)).toEqual([
{v: 'A', type: 'variable'},
{v: ' ->', type: 'keyword'},
{v: ' ...', type: 'operator'},
{v: 'x', type: 'variable'},
]);
expect(getTokens(1)).toEqual([
{v: '...', type: 'operator'},
{v: 'x', type: 'variable'},
{v: ' ->', type: 'keyword'},
{v: ' B', type: 'variable'},
{v: ':', type: 'operator'},
{v: ' hello', type: 'string'},
]);
});
it('recognises invalid delayed messages', () => {
cm.getDoc().setValue('A -> ...x: hello');
expect(getTokens(0)[4].type).toContain('line-error');
});
it('highlights block statements', () => { it('highlights block statements', () => {
cm.getDoc().setValue( cm.getDoc().setValue(
'if\n' + 'if\n' +
@ -491,6 +525,7 @@ defineDescribe('Code Mirror Mode', [
'* ', '* ',
'! ', '! ',
'Foo ', 'Foo ',
'... ',
]); ]);
}); });
@ -572,5 +607,10 @@ defineDescribe('Code Mirror Mode', [
expect(hints).not.toContain('"Zig -> Zag" '); expect(hints).not.toContain('"Zig -> Zag" ');
}); });
it('suggests known delayed agents', () => {
cm.getDoc().setValue('A -> ...woo\n... ');
const hints = getHintTexts({line: 1, ch: 4});
expect(hints).toEqual(['woo ']);
});
}); });
}); });

View File

@ -256,6 +256,8 @@ define(['core/ArrayUtilities'], (array) => {
'divider': this.handleDivider.bind(this), 'divider': this.handleDivider.bind(this),
'label pattern': this.handleLabelPattern.bind(this), 'label pattern': this.handleLabelPattern.bind(this),
'connect': this.handleConnect.bind(this), 'connect': this.handleConnect.bind(this),
'connect-delay-begin': this.handleConnectDelayBegin.bind(this),
'connect-delay-end': this.handleConnectDelayEnd.bind(this),
'note over': this.handleNote.bind(this), 'note over': this.handleNote.bind(this),
'note left': this.handleNote.bind(this), 'note left': this.handleNote.bind(this),
'note right': this.handleNote.bind(this), 'note right': this.handleNote.bind(this),
@ -436,13 +438,31 @@ define(['core/ArrayUtilities'], (array) => {
}; };
} }
_makeSection(header, stages) {
return {
header,
delayedConnections: new Map(),
stages,
};
}
_checkSectionEnd() {
const dcs = this.currentSection.delayedConnections;
if(dcs.size > 0) {
const dc = dcs.values().next().value;
throw new Error(
'Unused delayed connection "' + dc.tag +
'" at line ' + (dc.ln + 1)
);
}
}
beginNested(blockType, {tag, label, name, ln}) { beginNested(blockType, {tag, label, name, ln}) {
const leftGAgent = GAgent.make(name + '[', {anchorRight: true}); const leftGAgent = GAgent.make(name + '[', {anchorRight: true});
const rightGAgent = GAgent.make(name + ']'); const rightGAgent = GAgent.make(name + ']');
const gAgents = [leftGAgent, rightGAgent]; const gAgents = [leftGAgent, rightGAgent];
const stages = []; const stages = [];
this.currentSection = { this.currentSection = this._makeSection({
header: {
type: 'block begin', type: 'block begin',
blockType, blockType,
tag: this.textFormatter(tag), tag: this.textFormatter(tag),
@ -450,9 +470,7 @@ define(['core/ArrayUtilities'], (array) => {
left: leftGAgent.id, left: leftGAgent.id,
right: rightGAgent.id, right: rightGAgent.id,
ln, ln,
}, }, stages);
stages,
};
this.currentNest = { this.currentNest = {
blockType, blockType,
gAgents, gAgents,
@ -496,8 +514,8 @@ define(['core/ArrayUtilities'], (array) => {
this.currentNest.blockType + ')' this.currentNest.blockType + ')'
); );
} }
this.currentSection = { this._checkSectionEnd();
header: { this.currentSection = this._makeSection({
type: 'block split', type: 'block split',
blockType, blockType,
tag: this.textFormatter(tag), tag: this.textFormatter(tag),
@ -505,9 +523,7 @@ define(['core/ArrayUtilities'], (array) => {
left: this.currentNest.leftGAgent.id, left: this.currentNest.leftGAgent.id,
right: this.currentNest.rightGAgent.id, right: this.currentNest.rightGAgent.id,
ln, ln,
}, }, []);
stages: [],
};
this.currentNest.sections.push(this.currentSection); this.currentNest.sections.push(this.currentSection);
} }
@ -515,6 +531,7 @@ define(['core/ArrayUtilities'], (array) => {
if(this.nesting.length <= 1) { if(this.nesting.length <= 1) {
throw new Error('Invalid block nesting (too many "end"s)'); throw new Error('Invalid block nesting (too many "end"s)');
} }
this._checkSectionEnd();
const nested = this.nesting.pop(); const nested = this.nesting.pop();
this.currentNest = array.last(this.nesting); this.currentNest = array.last(this.nesting);
this.currentSection = array.last(this.currentNest.sections); this.currentSection = array.last(this.currentNest.sections);
@ -793,24 +810,20 @@ define(['core/ArrayUtilities'], (array) => {
return gAgents; return gAgents;
} }
handleConnect({agents, label, options}) { _handlePartialConnect(agents) {
const flags = this.filterConnectFlags(agents); const flags = this.filterConnectFlags(agents);
let gAgents = agents.map(this.toGAgent); const gAgents = agents.map(this.toGAgent);
this.validateGAgents(gAgents, { this.validateGAgents(gAgents, {
allowGrouped: true, allowGrouped: true,
allowVirtual: true, allowVirtual: true,
}); });
const allGAgents = array.flatMap(gAgents, this.expandGroupedGAgent); this.defineGAgents(array
this.defineGAgents(allGAgents .flatMap(gAgents, this.expandGroupedGAgent)
.filter((gAgent) => !gAgent.isVirtualSource) .filter((gAgent) => !gAgent.isVirtualSource)
); );
gAgents = this.expandGroupedGAgentConnection(gAgents);
gAgents = this.expandVirtualSourceAgents(gAgents);
const agentIDs = gAgents.map((gAgent) => gAgent.id);
const implicitBeginGAgents = (agents const implicitBeginGAgents = (agents
.filter(PAgent.hasFlag('begin', false)) .filter(PAgent.hasFlag('begin', false))
.map(this.toGAgent) .map(this.toGAgent)
@ -818,20 +831,105 @@ define(['core/ArrayUtilities'], (array) => {
); );
this.addStage(this.setGAgentVis(implicitBeginGAgents, true, 'box')); this.addStage(this.setGAgentVis(implicitBeginGAgents, true, 'box'));
const connectStage = { return {flags, gAgents};
type: 'connect', }
agentIDs,
label: this.textFormatter(this.applyLabelPattern(label)),
options,
};
this.addParallelStages([ _makeConnectParallelStages(flags, connectStage) {
return [
this.setGAgentVis(flags.beginGAgents, true, 'box', true), this.setGAgentVis(flags.beginGAgents, true, 'box', true),
this.setGAgentHighlight(flags.startGAgents, true, true), this.setGAgentHighlight(flags.startGAgents, true, true),
connectStage, connectStage,
this.setGAgentHighlight(flags.stopGAgents, false, true), this.setGAgentHighlight(flags.stopGAgents, false, true),
this.setGAgentVis(flags.endGAgents, false, 'cross', true), this.setGAgentVis(flags.endGAgents, false, 'cross', true),
];
}
handleConnect({agents, label, options}) {
let {flags, gAgents} = this._handlePartialConnect(agents);
gAgents = this.expandGroupedGAgentConnection(gAgents);
gAgents = this.expandVirtualSourceAgents(gAgents);
const connectStage = {
type: 'connect',
agentIDs: gAgents.map((gAgent) => gAgent.id),
label: this.textFormatter(this.applyLabelPattern(label)),
options,
};
this.addParallelStages(this._makeConnectParallelStages(
flags,
connectStage
));
}
handleConnectDelayBegin({agent, tag, options, ln}) {
const dcs = this.currentSection.delayedConnections;
if(dcs.has(tag)) {
throw new Error('Duplicate delayed connection "' + tag + '"');
}
const {flags, gAgents} = this._handlePartialConnect([agent]);
const uniqueTag = this.nextVirtualAgentName();
const connectStage = {
type: 'connect-delay-begin',
tag: uniqueTag,
agentIDs: null,
label: null,
options,
};
dcs.set(tag, {tag, uniqueTag, ln, gAgents, connectStage});
this.addParallelStages(this._makeConnectParallelStages(
flags,
connectStage
));
}
handleConnectDelayEnd({agent, tag, label, options}) {
const dcs = this.currentSection.delayedConnections;
const dcInfo = dcs.get(tag);
if(!dcInfo) {
throw new Error('Unknown delayed connection "' + tag + '"');
}
let {flags, gAgents} = this._handlePartialConnect([agent]);
gAgents = this.expandGroupedGAgentConnection([
...dcInfo.gAgents,
...gAgents,
]); ]);
gAgents = this.expandVirtualSourceAgents(gAgents);
let combinedOptions = dcInfo.connectStage.options;
if(combinedOptions.line !== options.line) {
throw new Error('Mismatched delayed connection arrows');
}
if(options.right) {
combinedOptions = Object.assign({}, combinedOptions, {
right: options.right,
});
}
Object.assign(dcInfo.connectStage, {
agentIDs: gAgents.map((gAgent) => gAgent.id),
label: this.textFormatter(this.applyLabelPattern(label)),
options: combinedOptions,
});
const connectEndStage = {
type: 'connect-delay-end',
tag: dcInfo.uniqueTag,
};
this.addParallelStages(this._makeConnectParallelStages(
flags,
connectEndStage
));
dcs.delete(tag);
} }
handleNote({type, agents, mode, label}) { handleNote({type, agents, mode, label}) {
@ -952,6 +1050,8 @@ define(['core/ArrayUtilities'], (array) => {
throw new Error('Unterminated group'); throw new Error('Unterminated group');
} }
this._checkSectionEnd();
const terminators = meta.terminators || 'none'; const terminators = meta.terminators || 'none';
this.addParallelStages([ this.addParallelStages([
this.setGAgentHighlight(this.gAgents, false), this.setGAgentHighlight(this.gAgents, false),

View File

@ -94,6 +94,49 @@ defineDescribe('Sequence Generator', ['./Generator'], (Generator) => {
}; };
}, },
connectDelayBegin: (agentID, {
label = '',
tag = '',
line = '',
left = 0,
right = 0,
ln = 0,
} = {}) => {
return {
type: 'connect-delay-begin',
ln,
tag,
agent: makeParsedAgents([agentID])[0],
options: {
line,
left,
right,
},
};
},
connectDelayEnd: (agentID, {
label = '',
tag = '',
line = '',
left = 0,
right = 0,
ln = 0,
} = {}) => {
return {
type: 'connect-delay-end',
ln,
tag,
agent: makeParsedAgents([agentID])[0],
label,
options: {
line,
left,
right,
},
};
},
note: (type, agentIDs, { note: (type, agentIDs, {
mode = '', mode = '',
label = '', label = '',
@ -211,6 +254,39 @@ defineDescribe('Sequence Generator', ['./Generator'], (Generator) => {
}; };
}, },
connectDelayBegin: (agentIDs, {
label = any(),
tag = any(),
line = any(),
left = any(),
right = any(),
ln = any(),
} = {}) => {
return {
type: 'connect-delay-begin',
agentIDs,
label,
tag,
options: {
line,
left,
right,
},
ln,
};
},
connectDelayEnd: ({
tag = any(),
ln = any(),
} = {}) => {
return {
type: 'connect-delay-end',
tag,
ln,
};
},
highlight: (agentIDs, highlighted, { highlight: (agentIDs, highlighted, {
ln = any(), ln = any(),
} = {}) => { } = {}) => {
@ -607,6 +683,183 @@ defineDescribe('Sequence Generator', ['./Generator'], (Generator) => {
]); ]);
}); });
it('aggregates delayed connect information in the first entry', () => {
const sequence = invoke([
PARSED.beginAgents(['A', 'B']),
PARSED.connectDelayBegin('A', {
ln: 0,
tag: 'foo',
line: 'solid',
left: 0,
right: 1,
}),
PARSED.connectDelayEnd('B', {
ln: 1,
tag: 'foo',
label: 'woo',
line: 'solid',
left: 0,
right: 1,
}),
]);
expect(sequence.stages).toEqual([
any(),
GENERATED.connectDelayBegin(['A', 'B'], {
label: 'woo!',
tag: '__0',
line: 'solid',
left: 0,
right: 1,
ln: 0,
}),
GENERATED.connectDelayEnd({
tag: '__0',
ln: 1,
}),
any(),
]);
});
it('merges delayed connect arrows', () => {
const sequence = invoke([
PARSED.beginAgents(['A', 'B']),
PARSED.connectDelayBegin('A', {
tag: 'foo',
line: 'solid',
left: 1,
right: 0,
}),
PARSED.connectDelayEnd('B', {
tag: 'foo',
line: 'solid',
left: 0,
right: 1,
}),
]);
expect(sequence.stages).toEqual([
any(),
GENERATED.connectDelayBegin(['A', 'B'], {
line: 'solid',
left: 1,
right: 1,
}),
any(),
any(),
]);
});
it('rejects conflicting delayed message arrows', () => {
expect(() => invoke([
PARSED.beginAgents(['A', 'B']),
PARSED.connectDelayBegin('A', {
tag: 'foo',
line: 'abc',
}),
PARSED.connectDelayEnd('B', {
ln: 1,
tag: 'foo',
line: 'def',
}),
])).toThrow(new Error(
'Mismatched delayed connection arrows at line 2'
));
});
it('implicitly begins agents in delayed connections', () => {
const sequence = invoke([
PARSED.connectDelayBegin('A', {tag: 'foo'}),
PARSED.connectDelayEnd('B', {tag: 'foo'}),
]);
expect(sequence.stages).toEqual([
GENERATED.beginAgents(['A']),
GENERATED.connectDelayBegin(['A', 'B']),
GENERATED.beginAgents(['B']),
GENERATED.connectDelayEnd(),
GENERATED.endAgents(['A', 'B']),
]);
});
it('rejects unknown delayed connections', () => {
expect(() => invoke([
PARSED.connectDelayBegin('A', {
ln: 0,
tag: 'foo',
}),
PARSED.connectDelayEnd('B', {
ln: 1,
tag: 'foo',
}),
PARSED.connectDelayEnd('B', {
ln: 2,
tag: 'bar',
}),
])).toThrow(new Error(
'Unknown delayed connection "bar" at line 3'
));
});
it('rejects overused delayed connections', () => {
expect(() => invoke([
PARSED.connectDelayBegin('A', {
ln: 0,
tag: 'foo',
}),
PARSED.connectDelayEnd('B', {
ln: 1,
tag: 'foo',
}),
PARSED.connectDelayEnd('B', {
ln: 2,
tag: 'foo',
}),
])).toThrow(new Error(
'Unknown delayed connection "foo" at line 3'
));
});
it('rejects unused delayed connections', () => {
expect(() => invoke([
PARSED.connectDelayBegin('A', {
ln: 0,
tag: 'foo',
}),
])).toThrow(new Error(
'Unused delayed connection "foo" at line 1'
));
});
it('rejects duplicate delayed connection names', () => {
expect(() => invoke([
PARSED.connectDelayBegin('A', {
ln: 0,
tag: 'foo',
}),
PARSED.connectDelayBegin('B', {
ln: 1,
tag: 'foo',
}),
])).toThrow(new Error(
'Duplicate delayed connection "foo" at line 2'
));
});
it('rejects delayed connections passing block boundaries', () => {
expect(() => invoke([
PARSED.connectDelayBegin('A', {
ln: 0,
tag: 'foo',
}),
PARSED.blockBegin('if', ''),
PARSED.connectDelayEnd('B', {
ln: 1,
tag: 'foo',
}),
PARSED.blockEnd(),
])).toThrow(new Error(
'Unknown delayed connection "foo" at line 2'
));
});
it('creates implicit end stages for all remaining agents', () => { it('creates implicit end stages for all remaining agents', () => {
const sequence = invoke([ const sequence = invoke([
PARSED.connect(['A', 'B']), PARSED.connect(['A', 'B']),

View File

@ -132,17 +132,6 @@ define([
return new Error(message + suffix); return new Error(message + suffix);
} }
function errToken(line, pos) {
if(pos < line.length) {
return line[pos];
}
const last = array.last(line);
if(!last) {
return null;
}
return {b: last.e};
}
function joinLabel(line, begin = 0, end = null) { function joinLabel(line, begin = 0, end = null) {
if(end === null) { if(end === null) {
end = line.length; end = line.length;
@ -206,6 +195,19 @@ define([
return orEnd ? limit : -1; return orEnd ? limit : -1;
} }
function findFirstToken(line, tokenMap, {start = 0, limit = null} = {}) {
if(limit === null) {
limit = line.length;
}
for(let pos = start; pos < limit; ++ pos) {
const value = tokenMap.get(tokenKeyword(line[pos]));
if(value) {
return {pos, value};
}
}
return null;
}
function readAgentAlias(line, start, end, {enableAlias, allowBlankName}) { function readAgentAlias(line, start, end, {enableAlias, allowBlankName}) {
let aliasSep = -1; let aliasSep = -1;
if(enableAlias) { if(enableAlias) {
@ -215,7 +217,11 @@ define([
aliasSep = end; aliasSep = end;
} }
if(start >= aliasSep && !allowBlankName) { if(start >= aliasSep && !allowBlankName) {
throw makeError('Missing agent name', errToken(line, start)); let errPosToken = line[start];
if(!errPosToken) {
errPosToken = {b: array.last(line).e};
}
throw makeError('Missing agent name', errPosToken);
} }
return { return {
name: joinLabel(line, start, aliasSep), name: joinLabel(line, start, aliasSep),
@ -491,35 +497,54 @@ define([
}, },
(line) => { // connect (line) => { // connect
let labelSep = findToken(line, ':'); const labelSep = findToken(line, ':', {orEnd: true});
if(labelSep === -1) { const connectionToken = findFirstToken(
labelSep = line.length; line,
} CONNECT.types,
let typePos = -1; {start: 0, limit: labelSep - 1}
let options = null; );
for(let j = 0; j < line.length; ++ j) { if(!connectionToken) {
const opts = CONNECT.types.get(tokenKeyword(line[j]));
if(opts) {
typePos = j;
options = opts;
break;
}
}
if(typePos <= 0 || typePos >= labelSep - 1) {
return null; return null;
} }
const readAgentOpts = {
const connectPos = connectionToken.pos;
const readOpts = {
flagTypes: CONNECT.agentFlags, flagTypes: CONNECT.agentFlags,
}; };
if(tokenKeyword(line[0]) === '...') {
return {
type: 'connect-delay-end',
tag: joinLabel(line, 1, connectPos),
agent: readAgent(line, connectPos + 1, labelSep, readOpts),
label: joinLabel(line, labelSep + 1),
options: connectionToken.value,
};
} else if(tokenKeyword(line[connectPos + 1]) === '...') {
if(labelSep !== line.length) {
throw makeError(
'Cannot label beginning of delayed connection',
line[labelSep]
);
}
return {
type: 'connect-delay-begin',
tag: joinLabel(line, connectPos + 2, labelSep),
agent: readAgent(line, 0, connectPos, readOpts),
options: connectionToken.value,
};
} else {
return { return {
type: 'connect', type: 'connect',
agents: [ agents: [
readAgent(line, 0, typePos, readAgentOpts), readAgent(line, 0, connectPos, readOpts),
readAgent(line, typePos + 1, labelSep, readAgentOpts), readAgent(line, connectPos + 1, labelSep, readOpts),
], ],
label: joinLabel(line, labelSep + 1), label: joinLabel(line, labelSep + 1),
options, options: connectionToken.value,
}; };
}
}, },
(line) => { // marker (line) => { // marker

View File

@ -328,6 +328,43 @@ defineDescribe('Sequence Parser', ['./Parser'], (Parser) => {
]); ]);
}); });
it('converts delayed connections', () => {
const parsed = parser.parse('+A <- ...foo\n...foo -> -B: woo');
expect(parsed.stages).toEqual([
{
type: 'connect-delay-begin',
ln: jasmine.anything(),
tag: 'foo',
agent: {
name: 'A',
alias: '',
flags: ['start'],
},
options: {
line: 'solid',
left: 1,
right: 0,
},
},
{
type: 'connect-delay-end',
ln: jasmine.anything(),
tag: 'foo',
agent: {
name: 'B',
alias: '',
flags: ['stop'],
},
label: 'woo',
options: {
line: 'solid',
left: 0,
right: 1,
},
},
]);
});
it('converts notes', () => { it('converts notes', () => {
const parsed = parser.parse('note over A: hello there'); const parsed = parser.parse('note over A: hello there');
expect(parsed.stages).toEqual([{ expect(parsed.stages).toEqual([{
@ -722,6 +759,13 @@ defineDescribe('Sequence Parser', ['./Parser'], (Parser) => {
expect(() => parser.parse('A -> : hello')).toThrow(); expect(() => parser.parse('A -> : hello')).toThrow();
}); });
it('rejects messages on delayed connections', () => {
expect(() => parser.parse('A -> ...a: nope')).toThrow(new Error(
'Cannot label beginning of delayed connection' +
' at line 1, character 9'
));
});
it('rejects invalid terminators', () => { it('rejects invalid terminators', () => {
expect(() => parser.parse('terminators foo')).toThrow(new Error( expect(() => parser.parse('terminators foo')).toThrow(new Error(
'Unknown termination "foo" at line 1, character 12' 'Unknown termination "foo" at line 1, character 12'

View File

@ -121,7 +121,7 @@ defineDescribe('SequenceDiagram', [
'<path d="M20.5 26L48.5 26"' '<path d="M20.5 26L48.5 26"'
); );
expect(content).toContain( expect(content).toContain(
'<polygon points="46 21 51 26 46 31"' '<polygon points="46 31 51 26 46 21"'
); );
}); });

View File

@ -23,6 +23,7 @@ define(['./CodeMirrorMode'], (CMMode) => {
escapeWith: unescape, escapeWith: unescape,
baseToken: {q: true}, baseToken: {q: true},
}, },
{start: /\.\.\./y, baseToken: {v: '...'}},
{start: /(?=[^ \t\r\n:+\-~*!<>,])/y, end: /(?=[ \t\r\n:+\-~*!<>,])|$/y}, {start: /(?=[^ \t\r\n:+\-~*!<>,])/y, end: /(?=[ \t\r\n:+\-~*!<>,])|$/y},
{ {
start: /(?=[\-~<])/y, start: /(?=[\-~<])/y,

View File

@ -69,6 +69,20 @@ defineDescribe('Sequence Tokeniser', ['./Tokeniser'], (Tokeniser) => {
]); ]);
}); });
it('parses special characters as tokens', () => {
const input = ',:!+*...abc';
const tokens = tokeniser.tokenise(input);
expect(tokens).toEqual([
token({s: '', v: ','}),
token({s: '', v: ':'}),
token({s: '', v: '!'}),
token({s: '', v: '+'}),
token({s: '', v: '*'}),
token({s: '', v: '...'}),
token({s: '', v: 'abc'}),
]);
});
it('stores line numbers', () => { it('stores line numbers', () => {
const input = 'foo bar\nbaz'; const input = 'foo bar\nbaz';
const tokens = tokeniser.tokenise(input); const tokens = tokeniser.tokenise(input);

View File

@ -57,6 +57,7 @@ define([
'width': width, 'width': width,
'height': height, 'height': height,
'fill': 'transparent', 'fill': 'transparent',
'class': 'vis',
}), clickable.firstChild); }), clickable.firstChild);
return { return {
@ -94,6 +95,7 @@ define([
'width': d * 2, 'width': d * 2,
'height': d * 2, 'height': d * 2,
'fill': 'transparent', 'fill': 'transparent',
'class': 'vis',
})); }));
return { return {
@ -148,6 +150,7 @@ define([
'width': width, 'width': width,
'height': height, 'height': height,
'fill': 'transparent', 'fill': 'transparent',
'class': 'vis',
})); }));
return { return {
@ -208,6 +211,7 @@ define([
'width': config.width, 'width': config.width,
'height': config.height, 'height': config.height,
'fill': 'transparent', 'fill': 'transparent',
'class': 'vis',
})); }));
return { return {
@ -242,6 +246,7 @@ define([
'width': w, 'width': w,
'height': config.height, 'height': config.height,
'fill': 'transparent', 'fill': 'transparent',
'class': 'vis',
})); }));
return { return {

View File

@ -77,6 +77,7 @@ define([
'width': agentInfoR.x - agentInfoL.x, 'width': agentInfoR.x - agentInfoL.x,
'height': labelHeight, 'height': labelHeight,
'fill': 'transparent', 'fill': 'transparent',
'class': 'vis',
}), clickable.firstChild); }), clickable.firstChild);
if(!first) { if(!first) {

View File

@ -37,11 +37,13 @@ define([
render(layer, theme, pt, dir) { render(layer, theme, pt, dir) {
const config = this.getConfig(theme); const config = this.getConfig(theme);
const short = this.short(theme);
layer.appendChild(config.render(config.attrs, { layer.appendChild(config.render(config.attrs, {
x: pt.x + this.short(theme) * dir, x: pt.x + short * dir.dx,
y: pt.y, y: pt.y + short * dir.dy,
dx: config.width * dir, width: config.width,
dy: config.height / 2, height: config.height,
dir,
})); }));
} }
@ -75,8 +77,8 @@ define([
render(layer, theme, pt, dir) { render(layer, theme, pt, dir) {
const config = this.getConfig(theme); const config = this.getConfig(theme);
layer.appendChild(config.render({ layer.appendChild(config.render({
x: pt.x + config.short * dir, x: pt.x + config.short * dir.dx,
y: pt.y, y: pt.y + config.short * dir.dy,
radius: config.radius, radius: config.radius,
})); }));
} }
@ -165,24 +167,51 @@ define([
array.mergeSets(env.momentaryAgentIDs, agentIDs); array.mergeSets(env.momentaryAgentIDs, agentIDs);
} }
renderSelfConnect({label, agentIDs, options}, env) { renderRevArrowLine({x1, y1, x2, y2, xR}, options, env) {
/* jshint -W071 */ // TODO: find appropriate abstractions const config = env.theme.connect;
const line = config.line[options.line];
const lArrow = ARROWHEADS[options.left];
const rArrow = ARROWHEADS[options.right];
const dx1 = lArrow.lineGap(env.theme, line.attrs);
const dx2 = rArrow.lineGap(env.theme, line.attrs);
const rendered = line.renderRev(line.attrs, {
x1: x1 + dx1,
y1,
x2: x2 + dx2,
y2,
xR,
rad: config.loopbackRadius,
});
env.shapeLayer.appendChild(rendered.shape);
lArrow.render(env.shapeLayer, env.theme, {
x: rendered.p1.x - dx1,
y: rendered.p1.y,
}, {dx: 1, dy: 0});
rArrow.render(env.shapeLayer, env.theme, {
x: rendered.p2.x - dx2,
y: rendered.p2.y,
}, {dx: 1, dy: 0});
}
renderSelfConnect({label, agentIDs, options}, env, from, yBegin) {
const config = env.theme.connect; const config = env.theme.connect;
const from = env.agentInfos.get(agentIDs[0]);
const lArrow = ARROWHEADS[options.left]; const lArrow = ARROWHEADS[options.left];
const rArrow = ARROWHEADS[options.right]; const rArrow = ARROWHEADS[options.right];
const to = env.agentInfos.get(agentIDs[1]);
const height = label ? ( const height = label ? (
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
) : 0; ) : 0;
const lineX = from.x + from.currentMaxRad; const xL = (
const y0 = env.primaryY; from.x + from.currentMaxRad +
const x0 = (
lineX +
lArrow.width(env.theme) + lArrow.width(env.theme) +
(label ? config.label.padding : 0) (label ? config.label.padding : 0)
); );
@ -190,8 +219,8 @@ define([
const clickable = env.makeRegion(); const clickable = env.makeRegion();
const renderedText = SVGShapes.renderBoxedText(label, { const renderedText = SVGShapes.renderBoxedText(label, {
x: x0 - config.mask.padding.left, x: xL - config.mask.padding.left,
y: y0 - height + config.label.margin.top, y: yBegin - height + config.label.margin.top,
padding: config.mask.padding, padding: config.mask.padding,
boxAttrs: {'fill': '#000000'}, boxAttrs: {'fill': '#000000'},
labelAttrs: config.label.loopbackAttrs, labelAttrs: config.label.loopbackAttrs,
@ -205,68 +234,74 @@ define([
config.mask.padding.left - config.mask.padding.left -
config.mask.padding.right config.mask.padding.right
) : 0); ) : 0);
const r = config.loopbackRadius;
const x1 = Math.max(lineX + rArrow.width(env.theme), x0 + labelW);
const y1 = y0 + r * 2;
const line = config.line[options.line]; const xR = Math.max(
const rendered = line.renderRev(line.attrs, { to.x + to.currentMaxRad + rArrow.width(env.theme),
xL: lineX, xL + labelW
dx1: lArrow.lineGap(env.theme, line.attrs), );
dx2: rArrow.lineGap(env.theme, line.attrs), const y2 = Math.max(
y1: y0, yBegin + config.loopbackRadius * 2,
y2: y1, env.primaryY
xR: x1, );
});
env.shapeLayer.appendChild(rendered.shape);
lArrow.render(env.shapeLayer, env.theme, rendered.p1, 1); this.renderRevArrowLine({
rArrow.render(env.shapeLayer, env.theme, rendered.p2, 1); x1: from.x + from.currentMaxRad,
y1: yBegin,
x2: to.x + to.currentMaxRad,
y2,
xR,
}, options, env);
const raise = Math.max(height, lArrow.height(env.theme) / 2); const raise = Math.max(height, lArrow.height(env.theme) / 2);
const arrowDip = rArrow.height(env.theme) / 2; const arrowDip = rArrow.height(env.theme) / 2;
clickable.insertBefore(svg.make('rect', { clickable.insertBefore(svg.make('rect', {
'x': lineX, 'x': from.x,
'y': y0 - raise, 'y': yBegin - raise,
'width': x1 + r - lineX, 'width': xR + config.loopbackRadius - from.x,
'height': raise + r * 2 + arrowDip, 'height': raise + y2 - yBegin + arrowDip,
'fill': 'transparent', 'fill': 'transparent',
'class': 'vis',
}), clickable.firstChild); }), clickable.firstChild);
return y1 + Math.max( return y2 + Math.max(arrowDip, 0) + env.theme.actionMargin;
arrowDip + env.theme.minActionMargin,
env.theme.actionMargin
);
} }
renderSimpleLine(x0, x1, options, env) { renderArrowLine({x1, y1, x2, y2}, options, env) {
const dir = (x0 < x1) ? 1 : -1;
const config = env.theme.connect; const config = env.theme.connect;
const line = config.line[options.line]; const line = config.line[options.line];
const lArrow = ARROWHEADS[options.left]; const lArrow = ARROWHEADS[options.left];
const rArrow = ARROWHEADS[options.right]; const rArrow = ARROWHEADS[options.right];
const len = Math.sqrt(
(x2 - x1) * (x2 - x1) +
(y2 - y1) * (y2 - y1)
);
const d1 = lArrow.lineGap(env.theme, line.attrs);
const d2 = rArrow.lineGap(env.theme, line.attrs);
const dx = (x2 - x1) / len;
const dy = (y2 - y1) / len;
const rendered = line.renderFlat(line.attrs, { const rendered = line.renderFlat(line.attrs, {
x1: x0, x1: x1 + d1 * dx,
dx1: lArrow.lineGap(env.theme, line.attrs) * dir, y1: y1 + d1 * dy,
x2: x1, x2: x2 - d2 * dx,
dx2: -rArrow.lineGap(env.theme, line.attrs) * dir, y2: y2 - d2 * dy,
y: env.primaryY,
}); });
env.shapeLayer.appendChild(rendered.shape); env.shapeLayer.appendChild(rendered.shape);
return rendered;
}
renderSimpleArrowheads(options, renderedLine, env, dir) { const p1 = {x: rendered.p1.x - d1 * dx, y: rendered.p1.y - d1 * dy};
const lArrow = ARROWHEADS[options.left]; const p2 = {x: rendered.p2.x + d2 * dx, y: rendered.p2.y + d2 * dy};
const rArrow = ARROWHEADS[options.right];
lArrow.render(env.shapeLayer, env.theme, renderedLine.p1, dir); lArrow.render(env.shapeLayer, env.theme, p1, {dx, dy});
rArrow.render(env.shapeLayer, env.theme, renderedLine.p2, -dir); rArrow.render(env.shapeLayer, env.theme, p2, {dx: -dx, dy: -dy});
return {lArrow, rArrow}; return {
p1,
p2,
lArrow,
rArrow,
};
} }
renderVirtualSources(from, to, renderedLine, env) { renderVirtualSources(from, to, renderedLine, env) {
@ -288,9 +323,41 @@ define([
} }
} }
renderSimpleConnect({label, agentIDs, options}, env) { renderSimpleLabel(label, {layer, x1, x2, y1, y2, height}, env) {
const config = env.theme.connect;
const midX = (x1 + x2) / 2;
const midY = (y1 + y2) / 2;
let labelLayer = layer;
const boxAttrs = {'fill': '#000000'};
if(y1 !== y2) {
const angle = Math.atan((y2 - y1) / (x2 - x1));
const transform = (
'rotate(' +
(angle * 180 / Math.PI) +
' ' + midX + ',' + midY +
')'
);
boxAttrs.transform = transform;
labelLayer = svg.make('g', {'transform': transform});
layer.appendChild(labelLayer);
}
SVGShapes.renderBoxedText(label, {
x: midX,
y: midY + config.label.margin.top - height,
padding: config.mask.padding,
boxAttrs,
labelAttrs: config.label.attrs,
boxLayer: env.maskLayer,
labelLayer,
SVGTextBlockClass: env.SVGTextBlockClass,
});
}
renderSimpleConnect({label, agentIDs, options}, env, from, yBegin) {
const config = env.theme.connect; const config = env.theme.connect;
const from = env.agentInfos.get(agentIDs[0]);
const to = env.agentInfos.get(agentIDs[1]); const to = env.agentInfos.get(agentIDs[1]);
const dir = (from.x < to.x) ? 1 : -1; const dir = (from.x < to.x) ? 1 : -1;
@ -301,44 +368,49 @@ define([
config.label.margin.bottom config.label.margin.bottom
); );
const x0 = from.x + from.currentMaxRad * dir; const x1 = from.x + from.currentMaxRad * dir;
const x1 = to.x - to.currentMaxRad * dir; const x2 = to.x - to.currentMaxRad * dir;
const y = env.primaryY;
const clickable = env.makeRegion(); const clickable = env.makeRegion();
SVGShapes.renderBoxedText(label, { const rendered = this.renderArrowLine({
x: (x0 + x1) / 2, x1,
y: y - height + config.label.margin.top, y1: yBegin,
padding: config.mask.padding, x2,
boxAttrs: {'fill': '#000000'}, y2: env.primaryY,
labelAttrs: config.label.attrs, }, options, env);
boxLayer: env.maskLayer,
labelLayer: clickable,
SVGTextBlockClass: env.SVGTextBlockClass,
});
const rendered = this.renderSimpleLine(x0, x1, options, env);
const {
lArrow,
rArrow
} = this.renderSimpleArrowheads(options, rendered, env, dir);
this.renderVirtualSources(from, to, rendered, env);
const arrowSpread = Math.max( const arrowSpread = Math.max(
lArrow.height(env.theme), rendered.lArrow.height(env.theme),
rArrow.height(env.theme) rendered.rArrow.height(env.theme)
) / 2; ) / 2;
clickable.insertBefore(svg.make('rect', { const lift = Math.max(height, arrowSpread);
'x': Math.min(x0, x1),
'y': y - Math.max(height, arrowSpread),
'width': Math.abs(x1 - x0),
'height': Math.max(height, arrowSpread) + arrowSpread,
'fill': 'transparent',
}), clickable.firstChild);
return y + Math.max( this.renderVirtualSources(from, to, rendered, env);
clickable.appendChild(svg.make('path', {
'd': (
'M' + x1 + ',' + (yBegin - lift) +
'L' + x2 + ',' + (env.primaryY - lift) +
'L' + x2 + ',' + (env.primaryY + arrowSpread) +
'L' + x1 + ',' + (yBegin + arrowSpread) +
'Z'
),
'fill': 'transparent',
'class': 'vis',
}));
this.renderSimpleLabel(label, {
layer: clickable,
x1,
y1: yBegin,
x2,
y2: env.primaryY,
height,
}, env);
return env.primaryY + Math.max(
arrowSpread + env.theme.minActionMargin, arrowSpread + env.theme.minActionMargin,
env.theme.actionMargin env.theme.actionMargin
); );
@ -367,16 +439,77 @@ define([
}; };
} }
render(stage, env) { render(stage, env, from = null, yBegin = null) {
if(stage.agentIDs[0] === stage.agentIDs[1]) { if(from === null) {
return this.renderSelfConnect(stage, env); from = env.agentInfos.get(stage.agentIDs[0]);
} else { yBegin = env.primaryY;
return this.renderSimpleConnect(stage, env);
} }
if(stage.agentIDs[0] === stage.agentIDs[1]) {
return this.renderSelfConnect(stage, env, from, yBegin);
} else {
return this.renderSimpleConnect(stage, env, from, yBegin);
}
}
}
class ConnectDelayBegin extends Connect {
makeState(state) {
state.delayedConnections = new Map();
}
resetState(state) {
state.delayedConnections.clear();
}
separation(stage, env) {
super.separation(stage, env);
array.mergeSets(env.momentaryAgentIDs, [stage.agentIDs[0]]);
}
renderPre(stage, env) {
return Object.assign(super.renderPre(stage, env), {
agentIDs: [stage.agentIDs[0]],
});
}
render(stage, env) {
const dc = env.state.delayedConnections;
dc.set(stage.tag, {
stage,
from: Object.assign({}, env.agentInfos.get(stage.agentIDs[0])),
y: env.primaryY,
});
return env.primaryY + env.theme.actionMargin;
}
}
class ConnectDelayEnd extends Connect {
separationPre() {}
separation() {}
renderPre({tag}, env) {
const dc = env.state.delayedConnections;
const beginStage = dc.get(tag).stage;
return Object.assign(super.renderPre(beginStage, env), {
agentIDs: [beginStage.agentIDs[1]],
});
}
render({tag}, env) {
const dc = env.state.delayedConnections;
const begin = dc.get(tag);
return super.render(begin.stage, env, begin.from, begin.y);
} }
} }
BaseComponent.register('connect', new Connect()); BaseComponent.register('connect', new Connect());
BaseComponent.register('connect-delay-begin', new ConnectDelayBegin());
BaseComponent.register('connect-delay-end', new ConnectDelayEnd());
return Connect; return {
Connect,
ConnectDelayBegin,
ConnectDelayEnd,
};
}); });

View File

@ -9,6 +9,11 @@ defineDescribe('Connect', [
it('registers itself with the component store', () => { it('registers itself with the component store', () => {
const components = BaseComponent.getComponents(); const components = BaseComponent.getComponents();
expect(components.get('connect')).toEqual(jasmine.any(Connect)); expect(components.get('connect'))
.toEqual(jasmine.any(Connect.Connect));
expect(components.get('connect-delay-begin'))
.toEqual(jasmine.any(Connect.ConnectDelayBegin));
expect(components.get('connect-delay-end'))
.toEqual(jasmine.any(Connect.ConnectDelayEnd));
}); });
}); });

View File

@ -82,6 +82,7 @@ define([
'width': right.x - left.x + config.extend * 2, 'width': right.x - left.x + config.extend * 2,
'height': fullHeight, 'height': fullHeight,
'fill': 'transparent', 'fill': 'transparent',
'class': 'vis',
}), clickable.firstChild); }), clickable.firstChild);
return env.primaryY + fullHeight + env.theme.actionMargin; return env.primaryY + fullHeight + env.theme.actionMargin;

View File

@ -92,6 +92,7 @@ define(['./BaseComponent', 'svg/SVGUtilities'], (BaseComponent, svg) => {
'width': x1 - x0, 'width': x1 - x0,
'height': fullH, 'height': fullH,
'fill': 'transparent', 'fill': 'transparent',
'class': 'vis',
}), clickable.firstChild); }), clickable.firstChild);
return ( return (

View File

@ -68,14 +68,18 @@ define([
} }
} }
BaseTheme.renderHorizArrowHead = (attrs, {x, y, dx, dy}) => { BaseTheme.renderArrowHead = (attrs, {x, y, width, height, dir}) => {
const wx = width * dir.dx;
const wy = width * dir.dy;
const hy = height * 0.5 * dir.dx;
const hx = -height * 0.5 * dir.dy;
return svg.make( return svg.make(
attrs.fill === 'none' ? 'polyline' : 'polygon', attrs.fill === 'none' ? 'polyline' : 'polygon',
Object.assign({ Object.assign({
'points': ( 'points': (
(x + dx) + ' ' + (y - dy) + ' ' + (x + wx - hx) + ' ' + (y + wy - hy) + ' ' +
x + ' ' + y + ' ' + x + ' ' + y + ' ' +
(x + dx) + ' ' + (y + dy) (x + wx + hx) + ' ' + (y + wy + hy)
), ),
}, attrs) }, attrs)
); );
@ -144,37 +148,50 @@ define([
} }
}; };
BaseTheme.renderFlatConnector = (pattern, attrs, {x1, dx1, x2, dx2, y}) => { BaseTheme.renderFlatConnector = (
pattern,
attrs,
{x1, y1, x2, y2}
) => {
return { return {
shape: svg.make('path', Object.assign({ shape: svg.make('path', Object.assign({
d: new SVGShapes.PatternedLine(pattern) d: new SVGShapes.PatternedLine(pattern)
.move(x1 + dx1, y) .move(x1, y1)
.line(x2 + dx2, y) .line(x2, y2)
.cap() .cap()
.asPath(), .asPath(),
}, attrs)), }, attrs)),
p1: {x: x1, y}, p1: {x: x1, y: y1},
p2: {x: x2, y}, p2: {x: x2, y: y2},
}; };
}; };
BaseTheme.renderRevConnector = ( BaseTheme.renderRevConnector = (
pattern, pattern,
attrs, attrs,
{xL, dx1, dx2, y1, y2, xR} {x1, y1, x2, y2, xR, rad}
) => { ) => {
const maxRad = (y2 - y1) / 2;
const line = new SVGShapes.PatternedLine(pattern)
.move(x1, y1)
.line(xR, y1);
if(rad < maxRad) {
line
.arc(xR, y1 + rad, Math.PI / 2)
.line(xR + rad, y2 - rad)
.arc(xR, y2 - rad, Math.PI / 2);
} else {
line.arc(xR, (y1 + y2) / 2, Math.PI);
}
return { return {
shape: svg.make('path', Object.assign({ shape: svg.make('path', Object.assign({
d: new SVGShapes.PatternedLine(pattern) d: line
.move(xL + dx1, y1) .line(x2, y2)
.line(xR, y1)
.arc(xR, (y1 + y2) / 2, Math.PI)
.line(xL + dx2, y2)
.cap() .cap()
.asPath(), .asPath(),
}, attrs)), }, attrs)),
p1: {x: xL, y: y1}, p1: {x: x1, y: y1},
p2: {x: xL, y: y2}, p2: {x: x2, y: y2},
}; };
}; };

View File

@ -107,7 +107,7 @@ define([
'single': { 'single': {
width: 5, width: 5,
height: 10, height: 10,
render: BaseTheme.renderHorizArrowHead, render: BaseTheme.renderArrowHead,
attrs: { attrs: {
'fill': '#000000', 'fill': '#000000',
'stroke-width': 0, 'stroke-width': 0,
@ -117,7 +117,7 @@ define([
'double': { 'double': {
width: 4, width: 4,
height: 6, height: 6,
render: BaseTheme.renderHorizArrowHead, render: BaseTheme.renderArrowHead,
attrs: { attrs: {
'fill': 'none', 'fill': 'none',
'stroke': '#000000', 'stroke': '#000000',

View File

@ -113,7 +113,7 @@ define([
'single': { 'single': {
width: 10, width: 10,
height: 12, height: 12,
render: BaseTheme.renderHorizArrowHead, render: BaseTheme.renderArrowHead,
attrs: { attrs: {
'fill': '#000000', 'fill': '#000000',
'stroke': '#000000', 'stroke': '#000000',
@ -124,7 +124,7 @@ define([
'double': { 'double': {
width: 10, width: 10,
height: 12, height: 12,
render: BaseTheme.renderHorizArrowHead, render: BaseTheme.renderArrowHead,
attrs: { attrs: {
'fill': 'none', 'fill': 'none',
'stroke': '#000000', 'stroke': '#000000',

View File

@ -114,7 +114,7 @@ define([
'single': { 'single': {
width: 4, width: 4,
height: 8, height: 8,
render: BaseTheme.renderHorizArrowHead, render: BaseTheme.renderArrowHead,
attrs: { attrs: {
'fill': '#000000', 'fill': '#000000',
'stroke-width': 0, 'stroke-width': 0,
@ -124,7 +124,7 @@ define([
'double': { 'double': {
width: 3, width: 3,
height: 6, height: 6,
render: BaseTheme.renderHorizArrowHead, render: BaseTheme.renderArrowHead,
attrs: { attrs: {
'fill': 'none', 'fill': 'none',
'stroke': '#000000', 'stroke': '#000000',

View File

@ -658,23 +658,23 @@ define([
return {shape}; return {shape};
} }
renderFlatConnector(attrs, {x1, dx1, x2, dx2, y}) { renderFlatConnector(attrs, {x1, y1, x2, y2}) {
const ln = this.lineNodes( const ln = this.lineNodes(
{x: x1 + dx1, y}, {x: x1, y: y1},
{x: x2 + dx2, y}, {x: x2, y: y2},
{varX: 0.3} {varX: 0.3}
); );
return { return {
shape: svg.make('path', Object.assign({'d': ln.nodes}, attrs)), shape: svg.make('path', Object.assign({'d': ln.nodes}, attrs)),
p1: {x: ln.p1.x - dx1, y: ln.p1.y}, p1: ln.p1,
p2: {x: ln.p2.x - dx2, y: ln.p2.y}, p2: ln.p2,
}; };
} }
renderRevConnector(attrs, {xL, dx1, dx2, y1, y2, xR}) { renderRevConnector(attrs, {x1, y1, x2, y2, xR}) {
const variance = Math.min((xR - xL) * 0.06, 3); const variance = Math.min((xR - x1) * 0.06, 3);
const overshoot = Math.min((xR - xL) * 0.5, 6); const overshoot = Math.min((xR - x1) * 0.5, 6);
const p1x = xL + dx1 + this.vary(variance, -1); const p1x = x1 + this.vary(variance, -1);
const p1y = y1 + this.vary(variance, -1); const p1y = y1 + this.vary(variance, -1);
const b1x = xR - overshoot * this.vary(0.2, 1); const b1x = xR - overshoot * this.vary(0.2, 1);
const b1y = y1 - this.vary(1, 2); const b1y = y1 - this.vary(1, 2);
@ -682,7 +682,7 @@ define([
const p2y = y1 + this.vary(1, 1); const p2y = y1 + this.vary(1, 1);
const b2x = xR; const b2x = xR;
const b2y = y2 + this.vary(2); const b2y = y2 + this.vary(2);
const p3x = xL + dx2 + this.vary(variance, -1); const p3x = x2 + this.vary(variance, -1);
const p3y = y2 + this.vary(variance, -1); const p3y = y2 + this.vary(variance, -1);
return { return {
@ -696,41 +696,21 @@ define([
',' + p3x + ' ' + p3y ',' + p3x + ' ' + p3y
), ),
}, attrs)), }, attrs)),
p1: {x: p1x - dx1, y: p1y}, p1: {x: p1x, y: p1y},
p2: {x: p3x - dx2, y: p3y}, p2: {x: p3x, y: p3y},
}; };
} }
renderFlatConnectorWave(attrs, {x1, dx1, x2, dx2, y}) { renderFlatConnectorWave(attrs, {x1, y1, x2, y2}) {
const x1v = x1 + this.vary(0.3); const x1v = x1 + this.vary(0.3);
const x2v = x2 + this.vary(0.3); const x2v = x2 + this.vary(0.3);
const y1v = y + this.vary(1);
const y2v = y + this.vary(1);
return {
shape: svg.make('path', Object.assign({
d: new SVGShapes.PatternedLine(this.wave)
.move(x1v + dx1, y1v)
.line(x2v + dx2, y2v)
.cap()
.asPath(),
}, attrs)),
p1: {x: x1v, y: y1v},
p2: {x: x2v, y: y2v},
};
}
renderRevConnectorWave(attrs, {xL, dx1, dx2, y1, y2, xR}) {
const x1v = xL + this.vary(0.3);
const x2v = xL + this.vary(0.3);
const y1v = y1 + this.vary(1); const y1v = y1 + this.vary(1);
const y2v = y2 + this.vary(1); const y2v = y2 + this.vary(1);
return { return {
shape: svg.make('path', Object.assign({ shape: svg.make('path', Object.assign({
d: new SVGShapes.PatternedLine(this.wave) d: new SVGShapes.PatternedLine(this.wave)
.move(x1v + dx1, y1v) .move(x1v, y1v)
.line(xR, y1) .line(x2v, y2v)
.arc(xR, (y1 + y2) / 2, Math.PI)
.line(x2v + dx2, y2v)
.cap() .cap()
.asPath(), .asPath(),
}, attrs)), }, attrs)),
@ -739,17 +719,41 @@ define([
}; };
} }
renderArrowHead(attrs, {x, y, dx, dy}) { renderRevConnectorWave(attrs, {x1, y1, x2, y2, xR}) {
const w = dx * this.vary(0.2, 1); const x1v = x1 + this.vary(0.3);
const h = dy * this.vary(0.3, 1); const x2v = x2 + this.vary(0.3);
const y1v = y1 + this.vary(1);
const y2v = y2 + this.vary(1);
return {
shape: svg.make('path', Object.assign({
d: new SVGShapes.PatternedLine(this.wave)
.move(x1v, y1v)
.line(xR, y1)
.arc(xR, (y1 + y2) / 2, Math.PI)
.line(x2v, y2v)
.cap()
.asPath(),
}, attrs)),
p1: {x: x1v, y: y1v},
p2: {x: x2v, y: y2v},
};
}
renderArrowHead(attrs, {x, y, width, height, dir}) {
const w = width * this.vary(0.2, 1);
const h = height * this.vary(0.3, 1);
const wx = w * dir.dx;
const wy = w * dir.dy;
const hy = h * 0.5 * dir.dx;
const hx = -h * 0.5 * dir.dy;
const l1 = this.lineNodes( const l1 = this.lineNodes(
{x: x + w, y: y - h}, {x: x + wx - hx, y: y + wy - hy},
{x, y}, {x, y},
{var1: 2.0, var2: 0.2} {var1: 2.0, var2: 0.2}
); );
const l2 = this.lineNodes( const l2 = this.lineNodes(
l1.p2, l1.p2,
{x: x + w, y: y + h}, {x: x + wx + hx, y: y + wy + hy},
{var1: 0, var2: 2.0, move: false} {var1: 0, var2: 2.0, move: false}
); );
const l3 = (attrs.fill === 'none') ? {nodes: ''} : this.lineNodes( const l3 = (attrs.fill === 'none') ? {nodes: ''} : this.lineNodes(

View File

@ -73,7 +73,7 @@ define(() => {
(cx - this.x) * (cx - this.x) + (cx - this.x) * (cx - this.x) +
(cy - this.y) * (cy - this.y) (cy - this.y) * (cy - this.y)
); );
const theta1 = Math.atan2(cx - this.x, cy - this.y); const theta1 = Math.atan2(this.x - cx, cy - this.y);
const nextX = cx + Math.sin(theta1 + theta) * radius; const nextX = cx + Math.sin(theta1 + theta) * radius;
const nextY = cy - Math.cos(theta1 + theta) * radius; const nextY = cy - Math.cos(theta1 + theta) * radius;
@ -93,8 +93,8 @@ define(() => {
this.points.push( this.points.push(
this.x + ' ' + this.y + this.x + ' ' + this.y +
'A' + radius + ' ' + radius + ' 0 ' + 'A' + radius + ' ' + radius + ' 0 ' +
((Math.abs(theta) >= Math.PI) ? '1 ' : '0 ') +
((theta < 0) ? '0 ' : '1 ') + ((theta < 0) ? '0 ' : '1 ') +
'1 ' +
nextX + ' ' + nextY nextX + ' ' + nextY
); );
this.disconnect = 0; this.disconnect = 0;

View File

@ -30,6 +30,26 @@ defineDescribe('PatternedLine', ['./PatternedLine'], (PatternedLine) => {
); );
}); });
it('supports quarter arcs', () => {
const ln = new PatternedLine()
.move(10, 20)
.arc(10, 30, Math.PI / 2)
.arc(10, 30, Math.PI / 2)
.arc(10, 30, Math.PI / 2)
.arc(10, 30, Math.PI / 2);
expect(simplify(ln.asPath(), 0)).toEqual(
'M10 20' +
'A10 10 0 0 1 20 30' +
'L20 30' +
'A10 10 0 0 1 10 40' +
'L10 40' +
'A10 10 0 0 1 0 30' +
'L0 30' +
'A10 10 0 0 1 10 20'
);
});
it('can combine lines and arcs', () => { it('can combine lines and arcs', () => {
const ln = new PatternedLine() const ln = new PatternedLine()
.move(10, 20) .move(10, 20)

View File

@ -153,17 +153,17 @@ html, body {
height: 100%; height: 100%;
} }
.pane-view .region:hover rect { .pane-view .region:hover .vis {
stroke-width: 5px; stroke-width: 5px;
stroke: rgba(255, 255, 0, 0.5); stroke: rgba(255, 255, 0, 0.5);
} }
.pane-view .region.focus rect { .pane-view .region.focus .vis {
stroke-width: 5px; stroke-width: 5px;
stroke: rgba(255, 128, 0, 0.5); stroke: rgba(255, 128, 0, 0.5);
} }
.pane-view .region.focus:hover rect { .pane-view .region.focus:hover .vis {
stroke-width: 5px; stroke-width: 5px;
stroke: rgba(255, 192, 0, 0.5); stroke: rgba(255, 192, 0, 0.5);
} }