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
```
### 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!)
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
</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>
<p>

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@ -56,6 +56,15 @@
title: 'Self-connection',
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',
code: '* -> {Agent1}: {Message}',

View File

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

View File

@ -205,24 +205,67 @@ define(['core/ArrayUtilities'], (array) => {
const connect = {
type: 'keyword',
suggest: true,
then: makeOpBlock(agentToOptText, {
then: Object.assign({}, makeOpBlock(agentToOptText, {
':': colonTextToEnd,
'\n': hiddenEnd,
}), {
'...': {type: 'operator', suggest: true, then: {
'': {
type: 'variable',
suggest: {known: 'DelayedAgent'},
then: {
'': 0,
':': CM_ERROR,
'\n': end,
},
},
}},
}),
};
const then = {'': 0};
arrows.forEach((arrow) => (then[arrow] = connect));
then[':'] = {
const connectors = {};
arrows.forEach((arrow) => (connectors[arrow] = connect));
const labelIndicator = {
type: 'operator',
suggest: true,
override: 'Label',
then: {},
};
return makeOpBlock(
{type: 'variable', suggest: {known: 'Agent'}, then},
then
);
const hiddenLabelIndicator = {
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 = {
@ -480,6 +523,7 @@ define(['core/ArrayUtilities'], (array) => {
currentSpace: '',
currentQuoted: false,
knownAgent: [],
knownDelayedAgent: [],
knownLabel: [],
beginCompletions: cmMakeCompletions({}, [this.commands]),
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');
expect(getTokens(0)).toEqual([
{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', () => {
cm.getDoc().setValue('+ -> Bar');
expect(getTokens(0)[2].type).toContain('line-error');
@ -198,6 +208,30 @@ defineDescribe('Code Mirror Mode', [
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', () => {
cm.getDoc().setValue(
'if\n' +
@ -491,6 +525,7 @@ defineDescribe('Code Mirror Mode', [
'* ',
'! ',
'Foo ',
'... ',
]);
});
@ -572,5 +607,10 @@ defineDescribe('Code Mirror Mode', [
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),
'label pattern': this.handleLabelPattern.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 left': this.handleNote.bind(this),
'note right': this.handleNote.bind(this),
@ -436,23 +438,39 @@ 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}) {
const leftGAgent = GAgent.make(name + '[', {anchorRight: true});
const rightGAgent = GAgent.make(name + ']');
const gAgents = [leftGAgent, rightGAgent];
const stages = [];
this.currentSection = {
header: {
type: 'block begin',
blockType,
tag: this.textFormatter(tag),
label: this.textFormatter(label),
left: leftGAgent.id,
right: rightGAgent.id,
ln,
},
stages,
};
this.currentSection = this._makeSection({
type: 'block begin',
blockType,
tag: this.textFormatter(tag),
label: this.textFormatter(label),
left: leftGAgent.id,
right: rightGAgent.id,
ln,
}, stages);
this.currentNest = {
blockType,
gAgents,
@ -496,18 +514,16 @@ define(['core/ArrayUtilities'], (array) => {
this.currentNest.blockType + ')'
);
}
this.currentSection = {
header: {
type: 'block split',
blockType,
tag: this.textFormatter(tag),
label: this.textFormatter(label),
left: this.currentNest.leftGAgent.id,
right: this.currentNest.rightGAgent.id,
ln,
},
stages: [],
};
this._checkSectionEnd();
this.currentSection = this._makeSection({
type: 'block split',
blockType,
tag: this.textFormatter(tag),
label: this.textFormatter(label),
left: this.currentNest.leftGAgent.id,
right: this.currentNest.rightGAgent.id,
ln,
}, []);
this.currentNest.sections.push(this.currentSection);
}
@ -515,6 +531,7 @@ define(['core/ArrayUtilities'], (array) => {
if(this.nesting.length <= 1) {
throw new Error('Invalid block nesting (too many "end"s)');
}
this._checkSectionEnd();
const nested = this.nesting.pop();
this.currentNest = array.last(this.nesting);
this.currentSection = array.last(this.currentNest.sections);
@ -793,24 +810,20 @@ define(['core/ArrayUtilities'], (array) => {
return gAgents;
}
handleConnect({agents, label, options}) {
_handlePartialConnect(agents) {
const flags = this.filterConnectFlags(agents);
let gAgents = agents.map(this.toGAgent);
const gAgents = agents.map(this.toGAgent);
this.validateGAgents(gAgents, {
allowGrouped: true,
allowVirtual: true,
});
const allGAgents = array.flatMap(gAgents, this.expandGroupedGAgent);
this.defineGAgents(allGAgents
this.defineGAgents(array
.flatMap(gAgents, this.expandGroupedGAgent)
.filter((gAgent) => !gAgent.isVirtualSource)
);
gAgents = this.expandGroupedGAgentConnection(gAgents);
gAgents = this.expandVirtualSourceAgents(gAgents);
const agentIDs = gAgents.map((gAgent) => gAgent.id);
const implicitBeginGAgents = (agents
.filter(PAgent.hasFlag('begin', false))
.map(this.toGAgent)
@ -818,20 +831,105 @@ define(['core/ArrayUtilities'], (array) => {
);
this.addStage(this.setGAgentVis(implicitBeginGAgents, true, 'box'));
const connectStage = {
type: 'connect',
agentIDs,
label: this.textFormatter(this.applyLabelPattern(label)),
options,
};
return {flags, gAgents};
}
this.addParallelStages([
_makeConnectParallelStages(flags, connectStage) {
return [
this.setGAgentVis(flags.beginGAgents, true, 'box', true),
this.setGAgentHighlight(flags.startGAgents, true, true),
connectStage,
this.setGAgentHighlight(flags.stopGAgents, false, 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}) {
@ -952,6 +1050,8 @@ define(['core/ArrayUtilities'], (array) => {
throw new Error('Unterminated group');
}
this._checkSectionEnd();
const terminators = meta.terminators || 'none';
this.addParallelStages([
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, {
mode = '',
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, {
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', () => {
const sequence = invoke([
PARSED.connect(['A', 'B']),

View File

@ -132,17 +132,6 @@ define([
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) {
if(end === null) {
end = line.length;
@ -206,6 +195,19 @@ define([
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}) {
let aliasSep = -1;
if(enableAlias) {
@ -215,7 +217,11 @@ define([
aliasSep = end;
}
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 {
name: joinLabel(line, start, aliasSep),
@ -491,35 +497,54 @@ define([
},
(line) => { // connect
let labelSep = findToken(line, ':');
if(labelSep === -1) {
labelSep = line.length;
}
let typePos = -1;
let options = null;
for(let j = 0; j < line.length; ++ j) {
const opts = CONNECT.types.get(tokenKeyword(line[j]));
if(opts) {
typePos = j;
options = opts;
break;
}
}
if(typePos <= 0 || typePos >= labelSep - 1) {
const labelSep = findToken(line, ':', {orEnd: true});
const connectionToken = findFirstToken(
line,
CONNECT.types,
{start: 0, limit: labelSep - 1}
);
if(!connectionToken) {
return null;
}
const readAgentOpts = {
const connectPos = connectionToken.pos;
const readOpts = {
flagTypes: CONNECT.agentFlags,
};
return {
type: 'connect',
agents: [
readAgent(line, 0, typePos, readAgentOpts),
readAgent(line, typePos + 1, labelSep, readAgentOpts),
],
label: joinLabel(line, labelSep + 1),
options,
};
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 {
type: 'connect',
agents: [
readAgent(line, 0, connectPos, readOpts),
readAgent(line, connectPos + 1, labelSep, readOpts),
],
label: joinLabel(line, labelSep + 1),
options: connectionToken.value,
};
}
},
(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', () => {
const parsed = parser.parse('note over A: hello there');
expect(parsed.stages).toEqual([{
@ -722,6 +759,13 @@ defineDescribe('Sequence Parser', ['./Parser'], (Parser) => {
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', () => {
expect(() => parser.parse('terminators foo')).toThrow(new Error(
'Unknown termination "foo" at line 1, character 12'

View File

@ -121,7 +121,7 @@ defineDescribe('SequenceDiagram', [
'<path d="M20.5 26L48.5 26"'
);
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,
baseToken: {q: true},
},
{start: /\.\.\./y, baseToken: {v: '...'}},
{start: /(?=[^ \t\r\n:+\-~*!<>,])/y, end: /(?=[ \t\r\n:+\-~*!<>,])|$/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', () => {
const input = 'foo bar\nbaz';
const tokens = tokeniser.tokenise(input);

View File

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

View File

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

View File

@ -37,11 +37,13 @@ define([
render(layer, theme, pt, dir) {
const config = this.getConfig(theme);
const short = this.short(theme);
layer.appendChild(config.render(config.attrs, {
x: pt.x + this.short(theme) * dir,
y: pt.y,
dx: config.width * dir,
dy: config.height / 2,
x: pt.x + short * dir.dx,
y: pt.y + short * dir.dy,
width: config.width,
height: config.height,
dir,
}));
}
@ -75,8 +77,8 @@ define([
render(layer, theme, pt, dir) {
const config = this.getConfig(theme);
layer.appendChild(config.render({
x: pt.x + config.short * dir,
y: pt.y,
x: pt.x + config.short * dir.dx,
y: pt.y + config.short * dir.dy,
radius: config.radius,
}));
}
@ -165,24 +167,51 @@ define([
array.mergeSets(env.momentaryAgentIDs, agentIDs);
}
renderSelfConnect({label, agentIDs, options}, env) {
/* jshint -W071 */ // TODO: find appropriate abstractions
renderRevArrowLine({x1, y1, x2, y2, xR}, options, env) {
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 from = env.agentInfos.get(agentIDs[0]);
const lArrow = ARROWHEADS[options.left];
const rArrow = ARROWHEADS[options.right];
const to = env.agentInfos.get(agentIDs[1]);
const height = label ? (
env.textSizer.measureHeight(config.label.attrs, label) +
config.label.margin.top +
config.label.margin.bottom
) : 0;
const lineX = from.x + from.currentMaxRad;
const y0 = env.primaryY;
const x0 = (
lineX +
const xL = (
from.x + from.currentMaxRad +
lArrow.width(env.theme) +
(label ? config.label.padding : 0)
);
@ -190,8 +219,8 @@ define([
const clickable = env.makeRegion();
const renderedText = SVGShapes.renderBoxedText(label, {
x: x0 - config.mask.padding.left,
y: y0 - height + config.label.margin.top,
x: xL - config.mask.padding.left,
y: yBegin - height + config.label.margin.top,
padding: config.mask.padding,
boxAttrs: {'fill': '#000000'},
labelAttrs: config.label.loopbackAttrs,
@ -205,68 +234,74 @@ define([
config.mask.padding.left -
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 line = config.line[options.line];
const rendered = line.renderRev(line.attrs, {
xL: lineX,
dx1: lArrow.lineGap(env.theme, line.attrs),
dx2: rArrow.lineGap(env.theme, line.attrs),
y1: y0,
y2: y1,
xR: x1,
});
env.shapeLayer.appendChild(rendered.shape);
const xR = Math.max(
to.x + to.currentMaxRad + rArrow.width(env.theme),
xL + labelW
);
const y2 = Math.max(
yBegin + config.loopbackRadius * 2,
env.primaryY
);
lArrow.render(env.shapeLayer, env.theme, rendered.p1, 1);
rArrow.render(env.shapeLayer, env.theme, rendered.p2, 1);
this.renderRevArrowLine({
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 arrowDip = rArrow.height(env.theme) / 2;
clickable.insertBefore(svg.make('rect', {
'x': lineX,
'y': y0 - raise,
'width': x1 + r - lineX,
'height': raise + r * 2 + arrowDip,
'x': from.x,
'y': yBegin - raise,
'width': xR + config.loopbackRadius - from.x,
'height': raise + y2 - yBegin + arrowDip,
'fill': 'transparent',
'class': 'vis',
}), clickable.firstChild);
return y1 + Math.max(
arrowDip + env.theme.minActionMargin,
env.theme.actionMargin
);
return y2 + Math.max(arrowDip, 0) + env.theme.actionMargin;
}
renderSimpleLine(x0, x1, options, env) {
const dir = (x0 < x1) ? 1 : -1;
renderArrowLine({x1, y1, x2, y2}, options, env) {
const config = env.theme.connect;
const line = config.line[options.line];
const lArrow = ARROWHEADS[options.left];
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, {
x1: x0,
dx1: lArrow.lineGap(env.theme, line.attrs) * dir,
x2: x1,
dx2: -rArrow.lineGap(env.theme, line.attrs) * dir,
y: env.primaryY,
x1: x1 + d1 * dx,
y1: y1 + d1 * dy,
x2: x2 - d2 * dx,
y2: y2 - d2 * dy,
});
env.shapeLayer.appendChild(rendered.shape);
return rendered;
}
renderSimpleArrowheads(options, renderedLine, env, dir) {
const lArrow = ARROWHEADS[options.left];
const rArrow = ARROWHEADS[options.right];
const p1 = {x: rendered.p1.x - d1 * dx, y: rendered.p1.y - d1 * dy};
const p2 = {x: rendered.p2.x + d2 * dx, y: rendered.p2.y + d2 * dy};
lArrow.render(env.shapeLayer, env.theme, renderedLine.p1, dir);
rArrow.render(env.shapeLayer, env.theme, renderedLine.p2, -dir);
lArrow.render(env.shapeLayer, env.theme, p1, {dx, dy});
rArrow.render(env.shapeLayer, env.theme, p2, {dx: -dx, dy: -dy});
return {lArrow, rArrow};
return {
p1,
p2,
lArrow,
rArrow,
};
}
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 from = env.agentInfos.get(agentIDs[0]);
const to = env.agentInfos.get(agentIDs[1]);
const dir = (from.x < to.x) ? 1 : -1;
@ -301,44 +368,49 @@ define([
config.label.margin.bottom
);
const x0 = from.x + from.currentMaxRad * dir;
const x1 = to.x - to.currentMaxRad * dir;
const y = env.primaryY;
const x1 = from.x + from.currentMaxRad * dir;
const x2 = to.x - to.currentMaxRad * dir;
const clickable = env.makeRegion();
SVGShapes.renderBoxedText(label, {
x: (x0 + x1) / 2,
y: y - height + config.label.margin.top,
padding: config.mask.padding,
boxAttrs: {'fill': '#000000'},
labelAttrs: config.label.attrs,
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 rendered = this.renderArrowLine({
x1,
y1: yBegin,
x2,
y2: env.primaryY,
}, options, env);
const arrowSpread = Math.max(
lArrow.height(env.theme),
rArrow.height(env.theme)
rendered.lArrow.height(env.theme),
rendered.rArrow.height(env.theme)
) / 2;
clickable.insertBefore(svg.make('rect', {
'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);
const lift = Math.max(height, arrowSpread);
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,
env.theme.actionMargin
);
@ -367,16 +439,77 @@ define([
};
}
render(stage, env) {
render(stage, env, from = null, yBegin = null) {
if(from === null) {
from = env.agentInfos.get(stage.agentIDs[0]);
yBegin = env.primaryY;
}
if(stage.agentIDs[0] === stage.agentIDs[1]) {
return this.renderSelfConnect(stage, env);
return this.renderSelfConnect(stage, env, from, yBegin);
} else {
return this.renderSimpleConnect(stage, env);
return this.renderSimpleConnect(stage, env, from, yBegin);
}
}
}
BaseComponent.register('connect', new Connect());
class ConnectDelayBegin extends Connect {
makeState(state) {
state.delayedConnections = new Map();
}
return Connect;
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-delay-begin', new ConnectDelayBegin());
BaseComponent.register('connect-delay-end', new ConnectDelayEnd());
return {
Connect,
ConnectDelayBegin,
ConnectDelayEnd,
};
});

View File

@ -9,6 +9,11 @@ defineDescribe('Connect', [
it('registers itself with the component store', () => {
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,
'height': fullHeight,
'fill': 'transparent',
'class': 'vis',
}), clickable.firstChild);
return env.primaryY + fullHeight + env.theme.actionMargin;

View File

@ -92,6 +92,7 @@ define(['./BaseComponent', 'svg/SVGUtilities'], (BaseComponent, svg) => {
'width': x1 - x0,
'height': fullH,
'fill': 'transparent',
'class': 'vis',
}), clickable.firstChild);
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(
attrs.fill === 'none' ? 'polyline' : 'polygon',
Object.assign({
'points': (
(x + dx) + ' ' + (y - dy) + ' ' +
(x + wx - hx) + ' ' + (y + wy - hy) + ' ' +
x + ' ' + y + ' ' +
(x + dx) + ' ' + (y + dy)
(x + wx + hx) + ' ' + (y + wy + hy)
),
}, attrs)
);
@ -144,37 +148,50 @@ define([
}
};
BaseTheme.renderFlatConnector = (pattern, attrs, {x1, dx1, x2, dx2, y}) => {
BaseTheme.renderFlatConnector = (
pattern,
attrs,
{x1, y1, x2, y2}
) => {
return {
shape: svg.make('path', Object.assign({
d: new SVGShapes.PatternedLine(pattern)
.move(x1 + dx1, y)
.line(x2 + dx2, y)
.move(x1, y1)
.line(x2, y2)
.cap()
.asPath(),
}, attrs)),
p1: {x: x1, y},
p2: {x: x2, y},
p1: {x: x1, y: y1},
p2: {x: x2, y: y2},
};
};
BaseTheme.renderRevConnector = (
pattern,
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 {
shape: svg.make('path', Object.assign({
d: new SVGShapes.PatternedLine(pattern)
.move(xL + dx1, y1)
.line(xR, y1)
.arc(xR, (y1 + y2) / 2, Math.PI)
.line(xL + dx2, y2)
d: line
.line(x2, y2)
.cap()
.asPath(),
}, attrs)),
p1: {x: xL, y: y1},
p2: {x: xL, y: y2},
p1: {x: x1, y: y1},
p2: {x: x2, y: y2},
};
};

View File

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

View File

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

View File

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

View File

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

View File

@ -73,7 +73,7 @@ define(() => {
(cx - this.x) * (cx - this.x) +
(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 nextY = cy - Math.cos(theta1 + theta) * radius;
@ -93,8 +93,8 @@ define(() => {
this.points.push(
this.x + ' ' + this.y +
'A' + radius + ' ' + radius + ' 0 ' +
((Math.abs(theta) >= Math.PI) ? '1 ' : '0 ') +
((theta < 0) ? '0 ' : '1 ') +
'1 ' +
nextX + ' ' + nextY
);
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', () => {
const ln = new PatternedLine()
.move(10, 20)

View File

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