Add support for asynchronous communication [#41]
This commit is contained in:
parent
8344ab2a44
commit
bbb9350e15
22
README.md
22
README.md
|
@ -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
19
library.htm
19
library.htm
|
@ -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 |
|
@ -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}',
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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: [],
|
||||
|
|
|
@ -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 ']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,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}) {
|
||||
const leftGAgent = GAgent.make(name + '[', {anchorRight: true});
|
||||
const rightGAgent = GAgent.make(name + ']');
|
||||
const gAgents = [leftGAgent, rightGAgent];
|
||||
const stages = [];
|
||||
this.currentSection = {
|
||||
header: {
|
||||
this.currentSection = this._makeSection({
|
||||
type: 'block begin',
|
||||
blockType,
|
||||
tag: this.textFormatter(tag),
|
||||
|
@ -450,9 +470,7 @@ define(['core/ArrayUtilities'], (array) => {
|
|||
left: leftGAgent.id,
|
||||
right: rightGAgent.id,
|
||||
ln,
|
||||
},
|
||||
stages,
|
||||
};
|
||||
}, stages);
|
||||
this.currentNest = {
|
||||
blockType,
|
||||
gAgents,
|
||||
|
@ -496,8 +514,8 @@ define(['core/ArrayUtilities'], (array) => {
|
|||
this.currentNest.blockType + ')'
|
||||
);
|
||||
}
|
||||
this.currentSection = {
|
||||
header: {
|
||||
this._checkSectionEnd();
|
||||
this.currentSection = this._makeSection({
|
||||
type: 'block split',
|
||||
blockType,
|
||||
tag: this.textFormatter(tag),
|
||||
|
@ -505,9 +523,7 @@ define(['core/ArrayUtilities'], (array) => {
|
|||
left: this.currentNest.leftGAgent.id,
|
||||
right: this.currentNest.rightGAgent.id,
|
||||
ln,
|
||||
},
|
||||
stages: [],
|
||||
};
|
||||
}, []);
|
||||
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),
|
||||
|
|
|
@ -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']),
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
||||
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, typePos, readAgentOpts),
|
||||
readAgent(line, typePos + 1, labelSep, readAgentOpts),
|
||||
readAgent(line, 0, connectPos, readOpts),
|
||||
readAgent(line, connectPos + 1, labelSep, readOpts),
|
||||
],
|
||||
label: joinLabel(line, labelSep + 1),
|
||||
options,
|
||||
options: connectionToken.value,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
(line) => { // marker
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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"'
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -77,6 +77,7 @@ define([
|
|||
'width': agentInfoR.x - agentInfoL.x,
|
||||
'height': labelHeight,
|
||||
'fill': 'transparent',
|
||||
'class': 'vis',
|
||||
}), clickable.firstChild);
|
||||
|
||||
if(!first) {
|
||||
|
|
|
@ -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) {
|
||||
if(stage.agentIDs[0] === stage.agentIDs[1]) {
|
||||
return this.renderSelfConnect(stage, env);
|
||||
} else {
|
||||
return this.renderSimpleConnect(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, 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-delay-begin', new ConnectDelayBegin());
|
||||
BaseComponent.register('connect-delay-end', new ConnectDelayEnd());
|
||||
|
||||
return Connect;
|
||||
return {
|
||||
Connect,
|
||||
ConnectDelayBegin,
|
||||
ConnectDelayEnd,
|
||||
};
|
||||
});
|
||||
|
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -92,6 +92,7 @@ define(['./BaseComponent', 'svg/SVGUtilities'], (BaseComponent, svg) => {
|
|||
'width': x1 - x0,
|
||||
'height': fullH,
|
||||
'fill': 'transparent',
|
||||
'class': 'vis',
|
||||
}), clickable.firstChild);
|
||||
|
||||
return (
|
||||
|
|
|
@ -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},
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue