Basic functionality (agents and labelled arrows)
This commit is contained in:
commit
6eb8de8160
|
@ -0,0 +1,165 @@
|
||||||
|
GNU LESSER GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
|
||||||
|
This version of the GNU Lesser General Public License incorporates
|
||||||
|
the terms and conditions of version 3 of the GNU General Public
|
||||||
|
License, supplemented by the additional permissions listed below.
|
||||||
|
|
||||||
|
0. Additional Definitions.
|
||||||
|
|
||||||
|
As used herein, "this License" refers to version 3 of the GNU Lesser
|
||||||
|
General Public License, and the "GNU GPL" refers to version 3 of the GNU
|
||||||
|
General Public License.
|
||||||
|
|
||||||
|
"The Library" refers to a covered work governed by this License,
|
||||||
|
other than an Application or a Combined Work as defined below.
|
||||||
|
|
||||||
|
An "Application" is any work that makes use of an interface provided
|
||||||
|
by the Library, but which is not otherwise based on the Library.
|
||||||
|
Defining a subclass of a class defined by the Library is deemed a mode
|
||||||
|
of using an interface provided by the Library.
|
||||||
|
|
||||||
|
A "Combined Work" is a work produced by combining or linking an
|
||||||
|
Application with the Library. The particular version of the Library
|
||||||
|
with which the Combined Work was made is also called the "Linked
|
||||||
|
Version".
|
||||||
|
|
||||||
|
The "Minimal Corresponding Source" for a Combined Work means the
|
||||||
|
Corresponding Source for the Combined Work, excluding any source code
|
||||||
|
for portions of the Combined Work that, considered in isolation, are
|
||||||
|
based on the Application, and not on the Linked Version.
|
||||||
|
|
||||||
|
The "Corresponding Application Code" for a Combined Work means the
|
||||||
|
object code and/or source code for the Application, including any data
|
||||||
|
and utility programs needed for reproducing the Combined Work from the
|
||||||
|
Application, but excluding the System Libraries of the Combined Work.
|
||||||
|
|
||||||
|
1. Exception to Section 3 of the GNU GPL.
|
||||||
|
|
||||||
|
You may convey a covered work under sections 3 and 4 of this License
|
||||||
|
without being bound by section 3 of the GNU GPL.
|
||||||
|
|
||||||
|
2. Conveying Modified Versions.
|
||||||
|
|
||||||
|
If you modify a copy of the Library, and, in your modifications, a
|
||||||
|
facility refers to a function or data to be supplied by an Application
|
||||||
|
that uses the facility (other than as an argument passed when the
|
||||||
|
facility is invoked), then you may convey a copy of the modified
|
||||||
|
version:
|
||||||
|
|
||||||
|
a) under this License, provided that you make a good faith effort to
|
||||||
|
ensure that, in the event an Application does not supply the
|
||||||
|
function or data, the facility still operates, and performs
|
||||||
|
whatever part of its purpose remains meaningful, or
|
||||||
|
|
||||||
|
b) under the GNU GPL, with none of the additional permissions of
|
||||||
|
this License applicable to that copy.
|
||||||
|
|
||||||
|
3. Object Code Incorporating Material from Library Header Files.
|
||||||
|
|
||||||
|
The object code form of an Application may incorporate material from
|
||||||
|
a header file that is part of the Library. You may convey such object
|
||||||
|
code under terms of your choice, provided that, if the incorporated
|
||||||
|
material is not limited to numerical parameters, data structure
|
||||||
|
layouts and accessors, or small macros, inline functions and templates
|
||||||
|
(ten or fewer lines in length), you do both of the following:
|
||||||
|
|
||||||
|
a) Give prominent notice with each copy of the object code that the
|
||||||
|
Library is used in it and that the Library and its use are
|
||||||
|
covered by this License.
|
||||||
|
|
||||||
|
b) Accompany the object code with a copy of the GNU GPL and this license
|
||||||
|
document.
|
||||||
|
|
||||||
|
4. Combined Works.
|
||||||
|
|
||||||
|
You may convey a Combined Work under terms of your choice that,
|
||||||
|
taken together, effectively do not restrict modification of the
|
||||||
|
portions of the Library contained in the Combined Work and reverse
|
||||||
|
engineering for debugging such modifications, if you also do each of
|
||||||
|
the following:
|
||||||
|
|
||||||
|
a) Give prominent notice with each copy of the Combined Work that
|
||||||
|
the Library is used in it and that the Library and its use are
|
||||||
|
covered by this License.
|
||||||
|
|
||||||
|
b) Accompany the Combined Work with a copy of the GNU GPL and this license
|
||||||
|
document.
|
||||||
|
|
||||||
|
c) For a Combined Work that displays copyright notices during
|
||||||
|
execution, include the copyright notice for the Library among
|
||||||
|
these notices, as well as a reference directing the user to the
|
||||||
|
copies of the GNU GPL and this license document.
|
||||||
|
|
||||||
|
d) Do one of the following:
|
||||||
|
|
||||||
|
0) Convey the Minimal Corresponding Source under the terms of this
|
||||||
|
License, and the Corresponding Application Code in a form
|
||||||
|
suitable for, and under terms that permit, the user to
|
||||||
|
recombine or relink the Application with a modified version of
|
||||||
|
the Linked Version to produce a modified Combined Work, in the
|
||||||
|
manner specified by section 6 of the GNU GPL for conveying
|
||||||
|
Corresponding Source.
|
||||||
|
|
||||||
|
1) Use a suitable shared library mechanism for linking with the
|
||||||
|
Library. A suitable mechanism is one that (a) uses at run time
|
||||||
|
a copy of the Library already present on the user's computer
|
||||||
|
system, and (b) will operate properly with a modified version
|
||||||
|
of the Library that is interface-compatible with the Linked
|
||||||
|
Version.
|
||||||
|
|
||||||
|
e) Provide Installation Information, but only if you would otherwise
|
||||||
|
be required to provide such information under section 6 of the
|
||||||
|
GNU GPL, and only to the extent that such information is
|
||||||
|
necessary to install and execute a modified version of the
|
||||||
|
Combined Work produced by recombining or relinking the
|
||||||
|
Application with a modified version of the Linked Version. (If
|
||||||
|
you use option 4d0, the Installation Information must accompany
|
||||||
|
the Minimal Corresponding Source and Corresponding Application
|
||||||
|
Code. If you use option 4d1, you must provide the Installation
|
||||||
|
Information in the manner specified by section 6 of the GNU GPL
|
||||||
|
for conveying Corresponding Source.)
|
||||||
|
|
||||||
|
5. Combined Libraries.
|
||||||
|
|
||||||
|
You may place library facilities that are a work based on the
|
||||||
|
Library side by side in a single library together with other library
|
||||||
|
facilities that are not Applications and are not covered by this
|
||||||
|
License, and convey such a combined library under terms of your
|
||||||
|
choice, if you do both of the following:
|
||||||
|
|
||||||
|
a) Accompany the combined library with a copy of the same work based
|
||||||
|
on the Library, uncombined with any other library facilities,
|
||||||
|
conveyed under the terms of this License.
|
||||||
|
|
||||||
|
b) Give prominent notice with the combined library that part of it
|
||||||
|
is a work based on the Library, and explaining where to find the
|
||||||
|
accompanying uncombined form of the same work.
|
||||||
|
|
||||||
|
6. Revised Versions of the GNU Lesser General Public License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions
|
||||||
|
of the GNU Lesser General Public License from time to time. Such new
|
||||||
|
versions will be similar in spirit to the present version, but may
|
||||||
|
differ in detail to address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Library as you received it specifies that a certain numbered version
|
||||||
|
of the GNU Lesser General Public License "or any later version"
|
||||||
|
applies to it, you have the option of following the terms and
|
||||||
|
conditions either of that published version or of any later version
|
||||||
|
published by the Free Software Foundation. If the Library as you
|
||||||
|
received it does not specify a version number of the GNU Lesser
|
||||||
|
General Public License, you may choose any version of the GNU Lesser
|
||||||
|
General Public License ever published by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Library as you received it specifies that a proxy can decide
|
||||||
|
whether future versions of the GNU Lesser General Public License shall
|
||||||
|
apply, that proxy's public statement of acceptance of any version is
|
||||||
|
permanent authorization for you to choose that version for the
|
||||||
|
Library.
|
|
@ -0,0 +1,188 @@
|
||||||
|
# Sequence Diagram
|
||||||
|
|
||||||
|
A tool for creating sequence diagrams from a Domain-Specific Language.
|
||||||
|
|
||||||
|
This project includes a web page for editing the diagrams, but the core
|
||||||
|
logic is available as separate components which can be included in
|
||||||
|
other projects.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Simple Usage
|
||||||
|
|
||||||
|
```
|
||||||
|
title Labyrinth
|
||||||
|
|
||||||
|
Bowie -> Gremlin: You remind me of the babe
|
||||||
|
Gremlin -> Bowie: What babe?
|
||||||
|
Bowie -> Gremlin: The babe with the power
|
||||||
|
Gremlin -> Bowie: What power?
|
||||||
|
note right of Bowie, Gremlin: Most people get muddled here!
|
||||||
|
Bowie -> Gremlin: 'The power of voodoo'
|
||||||
|
Gremlin -> Bowie: "Who-do?"
|
||||||
|
Bowie -> Gremlin: You do!
|
||||||
|
Gremlin -> Bowie: Do what?
|
||||||
|
Bowie -> Gremlin: Remind me of the babe!
|
||||||
|
|
||||||
|
Bowie -> Audience: Sings
|
||||||
|
```
|
||||||
|
|
||||||
|
### Connection Types
|
||||||
|
|
||||||
|
```
|
||||||
|
title Connection Types
|
||||||
|
|
||||||
|
Foo -> Bar: Simple arrow
|
||||||
|
Foo --> Bar: Dotted arrow
|
||||||
|
Foo <- Bar: Reversed arrow
|
||||||
|
Foo <-- Bar: Reversed dotted arrow
|
||||||
|
Foo <-> Bar: Double arrow
|
||||||
|
Foo <--> Bar: Double dotted arrow
|
||||||
|
|
||||||
|
# An arrow with no label:
|
||||||
|
Foo -> Bar
|
||||||
|
|
||||||
|
# Arrows leaving on the left and right of the diagram
|
||||||
|
[ -> Foo: From the left
|
||||||
|
[ <- Foo: To the left
|
||||||
|
Foo -> ]: To the right
|
||||||
|
Foo <- ]: From the right
|
||||||
|
[ -> ]: Left to right!
|
||||||
|
# (etc.)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Notes & State
|
||||||
|
|
||||||
|
```
|
||||||
|
title Note placements
|
||||||
|
|
||||||
|
note over Foo: Foo says something
|
||||||
|
note left of Foo: Stuff
|
||||||
|
note right of Bar: More stuff
|
||||||
|
note over Foo, Bar: Foo and Bar
|
||||||
|
note between Foo, Bar: Link
|
||||||
|
|
||||||
|
state over Foo: Foo is ponderous
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logic
|
||||||
|
|
||||||
|
```
|
||||||
|
title At the bank
|
||||||
|
|
||||||
|
Person -> ATM: Request money
|
||||||
|
ATM -> Bank: Check funds
|
||||||
|
if fraud detected
|
||||||
|
Bank -> Police: "Get 'em!"
|
||||||
|
Police -> Person: "You're nicked"
|
||||||
|
else if sufficient funds
|
||||||
|
ATM -> Bank: Withdraw funds
|
||||||
|
repeat until all requested money handed over
|
||||||
|
ATM -> Person: Dispense note
|
||||||
|
end
|
||||||
|
else
|
||||||
|
ATM -> Person: Error
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Short-Lived Agents
|
||||||
|
|
||||||
|
```
|
||||||
|
title "Baz doesn't live long"
|
||||||
|
|
||||||
|
Foo -> Bar
|
||||||
|
begin Baz
|
||||||
|
Bar -> Baz
|
||||||
|
Baz -> Foo
|
||||||
|
end Baz
|
||||||
|
Foo -> Bar
|
||||||
|
|
||||||
|
# Foo and Bar end with black bars
|
||||||
|
terminators bar
|
||||||
|
# (options are: box, bar, cross, none)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Alternative Agent Ordering
|
||||||
|
|
||||||
|
```
|
||||||
|
define Baz, Foo
|
||||||
|
Foo -> Bar
|
||||||
|
Bar -> Baz
|
||||||
|
```
|
||||||
|
|
||||||
|
## DSL Basics
|
||||||
|
|
||||||
|
Comments begin with a `#` and end at the next newline:
|
||||||
|
|
||||||
|
```
|
||||||
|
# This is a comment
|
||||||
|
```
|
||||||
|
|
||||||
|
Meta data can be provided with particular keywords:
|
||||||
|
|
||||||
|
```
|
||||||
|
title 'My title here'
|
||||||
|
```
|
||||||
|
|
||||||
|
Quoting strings is usually optional, for example these are the same:
|
||||||
|
|
||||||
|
```
|
||||||
|
title 'My title here'
|
||||||
|
title "My title here"
|
||||||
|
title My title here
|
||||||
|
title "My title" here
|
||||||
|
title "My" 'title' "here"
|
||||||
|
```
|
||||||
|
|
||||||
|
Each non-metadata line represents a step in the sequence, in order.
|
||||||
|
|
||||||
|
```
|
||||||
|
# Draw an arrow from agent "Foo Bar" to agent "Zig Zag" with a label:
|
||||||
|
# (implicitly creates the agents if they do not already exist)
|
||||||
|
|
||||||
|
Foo Bar -> Zig Zag: Do a thing
|
||||||
|
|
||||||
|
# With quotes, this is the same as:
|
||||||
|
|
||||||
|
'Foo Bar' -> 'Zig Zag': 'Do a thing'
|
||||||
|
```
|
||||||
|
|
||||||
|
Blocks surround steps, and can nest:
|
||||||
|
|
||||||
|
```
|
||||||
|
if something
|
||||||
|
Foo -> Bar
|
||||||
|
else if something else
|
||||||
|
Foo -> Baz
|
||||||
|
if more stuff
|
||||||
|
Baz -> Zig
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
(indentation is ignored)
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions are welcome!
|
||||||
|
|
||||||
|
If you find a bug or desire a new feature, feel free to report it in
|
||||||
|
the GitHub issue tracker, or write the code yourself and make a pull
|
||||||
|
request.
|
||||||
|
|
||||||
|
Pull requests are more likely to be accepted if the code you changed
|
||||||
|
is tested (write new tests for new features and bug fixes, and update
|
||||||
|
existing tests where necessary). You can make sure the tests and linter
|
||||||
|
are passing by opening test.htm
|
||||||
|
|
||||||
|
Note: the linter can't run from the local filesystem, so you'll need to
|
||||||
|
run a local HTTP server to ensure linting is successful. One option if
|
||||||
|
you have NPM installed is:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Setup
|
||||||
|
npm install http-server -g;
|
||||||
|
|
||||||
|
# Then
|
||||||
|
http-server;
|
||||||
|
```
|
Binary file not shown.
After Width: | Height: | Size: 662 B |
|
@ -0,0 +1,49 @@
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta http-equiv="content-security-policy" content="
|
||||||
|
base-uri 'self';
|
||||||
|
default-src 'none';
|
||||||
|
script-src
|
||||||
|
'self'
|
||||||
|
'sha256-0SGl1PJNDyJwcV5T+weg2zpEMrh7xvlwO4oXgvZCeZk='
|
||||||
|
'sha256-eue5ceZRwKVQ1OXOZSyU7MXCTZMlqsPi/TOIqh1Vlzo='
|
||||||
|
;
|
||||||
|
style-src 'self' https://cdnjs.cloudflare.com;
|
||||||
|
img-src 'self' blob:;
|
||||||
|
form-action 'none';
|
||||||
|
">
|
||||||
|
|
||||||
|
<title>Sequence Diagram</title>
|
||||||
|
<link rel="icon" href="favicon.png">
|
||||||
|
|
||||||
|
<link rel="stylesheet"
|
||||||
|
href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.31.0/codemirror.min.css"
|
||||||
|
integrity="sha256-Zg9EoB1hB8n8EVhx/D07lT5dD3ZZqjJbxlDmHx8jsMc="
|
||||||
|
crossorigin="anonymous"
|
||||||
|
>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="styles/main.css">
|
||||||
|
|
||||||
|
<script src="scripts/requireConfig.js"></script>
|
||||||
|
|
||||||
|
<script
|
||||||
|
data-main="scripts/main"
|
||||||
|
src="https://cdnjs.cloudflare.com/ajax/libs/require.js/2.3.5/require.min.js"
|
||||||
|
integrity="sha256-0SGl1PJNDyJwcV5T+weg2zpEMrh7xvlwO4oXgvZCeZk="
|
||||||
|
crossorigin="anonymous"
|
||||||
|
></script>
|
||||||
|
|
||||||
|
<meta
|
||||||
|
name="cdn-codemirror"
|
||||||
|
content="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.31.0/codemirror.min.js"
|
||||||
|
data-integrity="sha256-eue5ceZRwKVQ1OXOZSyU7MXCTZMlqsPi/TOIqh1Vlzo="
|
||||||
|
>
|
||||||
|
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<noscript>This tool requires Javascript!</noscript>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -0,0 +1,216 @@
|
||||||
|
define(['codemirror'], (CodeMirror) => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const DELAY_AGENTCHANGE = 500;
|
||||||
|
const DELAY_STAGECHANGE = 250;
|
||||||
|
const PNG_RESOLUTION = 4;
|
||||||
|
|
||||||
|
function makeText(text = '') {
|
||||||
|
return document.createTextNode(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeNode(type, attrs = {}) {
|
||||||
|
const o = document.createElement(type);
|
||||||
|
for(let k in attrs) {
|
||||||
|
if(attrs.hasOwnProperty(k)) {
|
||||||
|
o.setAttribute(k, attrs[k]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
function on(element, events, fn) {
|
||||||
|
events.forEach((event) => element.addEventListener(event, fn));
|
||||||
|
}
|
||||||
|
|
||||||
|
return class Interface {
|
||||||
|
constructor({
|
||||||
|
parser,
|
||||||
|
generator,
|
||||||
|
renderer,
|
||||||
|
defaultCode = '',
|
||||||
|
}) {
|
||||||
|
window.devicePixelRatio = 1;
|
||||||
|
this.canvas = makeNode('canvas');
|
||||||
|
this.context = this.canvas.getContext('2d');
|
||||||
|
|
||||||
|
this.parser = parser;
|
||||||
|
this.generator = generator;
|
||||||
|
this.renderer = renderer;
|
||||||
|
this.defaultCode = defaultCode;
|
||||||
|
|
||||||
|
this.debounced = null;
|
||||||
|
this.latestSeq = null;
|
||||||
|
this.renderedSeq = null;
|
||||||
|
this.canvasDirty = true;
|
||||||
|
this.svgDirty = true;
|
||||||
|
this.latestPNG = null;
|
||||||
|
this.latestSVG = null;
|
||||||
|
this.updatingPNG = null;
|
||||||
|
|
||||||
|
this._downloadSVGClick = this._downloadSVGClick.bind(this);
|
||||||
|
this._downloadPNGClick = this._downloadPNGClick.bind(this);
|
||||||
|
this._downloadPNGFocus = this._downloadPNGFocus.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
build(container) {
|
||||||
|
this.codePane = makeNode('div', {'class': 'pane-code'});
|
||||||
|
this.viewPane = makeNode('div', {'class': 'pane-view'});
|
||||||
|
|
||||||
|
this.downloadPNG = makeNode('a', {
|
||||||
|
'href': '#',
|
||||||
|
'download': 'SequenceDiagram.png',
|
||||||
|
});
|
||||||
|
this.downloadPNG.appendChild(makeText('Download PNG'));
|
||||||
|
on(this.downloadPNG, [
|
||||||
|
'focus',
|
||||||
|
'mouseover',
|
||||||
|
'mousedown',
|
||||||
|
], this._downloadPNGFocus);
|
||||||
|
on(this.downloadPNG, ['click'], this._downloadPNGClick);
|
||||||
|
|
||||||
|
this.downloadSVG = makeNode('a', {
|
||||||
|
'href': '#',
|
||||||
|
'download': 'SequenceDiagram.svg',
|
||||||
|
});
|
||||||
|
this.downloadSVG.appendChild(makeText('SVG'));
|
||||||
|
on(this.downloadSVG, ['click'], this._downloadSVGClick);
|
||||||
|
|
||||||
|
this.options = makeNode('div', {'class': 'options'});
|
||||||
|
this.options.appendChild(this.downloadPNG);
|
||||||
|
this.options.appendChild(this.downloadSVG);
|
||||||
|
this.viewPane.appendChild(this.options);
|
||||||
|
|
||||||
|
container.appendChild(this.codePane);
|
||||||
|
container.appendChild(this.viewPane);
|
||||||
|
|
||||||
|
this.code = new CodeMirror(this.codePane, {
|
||||||
|
value: this.defaultCode,
|
||||||
|
mode: '',
|
||||||
|
});
|
||||||
|
this.viewPane.appendChild(this.renderer.svg());
|
||||||
|
|
||||||
|
this.code.on('change', () => this.update(false));
|
||||||
|
this.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
redraw(sequence) {
|
||||||
|
clearTimeout(this.debounced);
|
||||||
|
this.debounced = null;
|
||||||
|
this.canvasDirty = true;
|
||||||
|
this.svgDirty = true;
|
||||||
|
this.renderedSeq = sequence;
|
||||||
|
this.renderer.render(sequence);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(immediate = true) {
|
||||||
|
const src = this.code.getDoc().getValue();
|
||||||
|
let sequence = null;
|
||||||
|
try {
|
||||||
|
const parsed = this.parser.parse(src);
|
||||||
|
sequence = this.generator.generate(parsed);
|
||||||
|
} catch(e) {
|
||||||
|
// TODO
|
||||||
|
// console.log(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let delay = 0;
|
||||||
|
if(!immediate && this.renderedSeq) {
|
||||||
|
const old = this.renderedSeq;
|
||||||
|
if(sequence.agents.length !== old.agents.length) {
|
||||||
|
delay = DELAY_AGENTCHANGE;
|
||||||
|
} else if(sequence.stages.length !== old.stages.length) {
|
||||||
|
delay = DELAY_STAGECHANGE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(delay <= 0) {
|
||||||
|
this.redraw(sequence);
|
||||||
|
} else {
|
||||||
|
clearTimeout(this.debounced);
|
||||||
|
this.latestSeq = sequence;
|
||||||
|
this.debounced = setTimeout(() => this.redraw(sequence), delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
forceRender() {
|
||||||
|
if(this.debounced) {
|
||||||
|
clearTimeout(this.debounced);
|
||||||
|
this.redraw(this.latestSeq);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getSVGData() {
|
||||||
|
this.forceRender();
|
||||||
|
if(!this.svgDirty) {
|
||||||
|
return this.latestSVG;
|
||||||
|
}
|
||||||
|
this.svgDirty = false;
|
||||||
|
const src = this.renderer.svg().outerHTML;
|
||||||
|
const blob = new Blob([src], {type: 'image/svg+xml'});
|
||||||
|
if(this.latestSVG) {
|
||||||
|
URL.revokeObjectURL(this.latestSVG);
|
||||||
|
}
|
||||||
|
this.latestSVG = URL.createObjectURL(blob);
|
||||||
|
return this.latestSVG;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPNGData(callback) {
|
||||||
|
this.forceRender();
|
||||||
|
if(!this.canvasDirty) {
|
||||||
|
// TODO: this could cause issues if getPNGData is called
|
||||||
|
// while another update is ongoing. Needs a more robust fix
|
||||||
|
callback(this.latestPNG);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.canvasDirty = false;
|
||||||
|
const width = this.renderer.width * PNG_RESOLUTION;
|
||||||
|
const height = this.renderer.height * PNG_RESOLUTION;
|
||||||
|
this.canvas.width = width;
|
||||||
|
this.canvas.height = height;
|
||||||
|
const img = new Image(width, height);
|
||||||
|
img.addEventListener('load', () => {
|
||||||
|
this.context.drawImage(img, 0, 0);
|
||||||
|
this.canvas.toBlob((blob) => {
|
||||||
|
if(this.latestPNG) {
|
||||||
|
URL.revokeObjectURL(this.latestPNG);
|
||||||
|
}
|
||||||
|
this.latestPNG = URL.createObjectURL(blob);
|
||||||
|
callback(this.latestPNG);
|
||||||
|
}, 'image/png');
|
||||||
|
}, {once: true});
|
||||||
|
img.src = this.getSVGData();
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePNGLink() {
|
||||||
|
const nonce = this.updatingPNG = {};
|
||||||
|
this.getPNGData((data) => {
|
||||||
|
if(this.updatingPNG === nonce) {
|
||||||
|
this.downloadPNG.setAttribute('href', data);
|
||||||
|
this.updatingPNG = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_downloadPNGFocus() {
|
||||||
|
this.forceRender();
|
||||||
|
if(this.canvasDirty) {
|
||||||
|
this.updatePNGLink();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_downloadPNGClick(e) {
|
||||||
|
if(this.updatingPNG) {
|
||||||
|
e.preventDefault();
|
||||||
|
} else if(this.canvasDirty) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.updatePNGLink();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_downloadSVGClick() {
|
||||||
|
this.downloadSVG.setAttribute('href', this.getSVGData());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
|
@ -0,0 +1,54 @@
|
||||||
|
defineDescribe('Interface', ['./Interface'], (Interface) => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
let parser = null;
|
||||||
|
let generator = null;
|
||||||
|
let renderer = null;
|
||||||
|
let container = null;
|
||||||
|
let ui = null;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
parser = jasmine.createSpyObj('parser', ['parse']);
|
||||||
|
parser.parse.and.returnValue({
|
||||||
|
meta: {},
|
||||||
|
stages: [],
|
||||||
|
});
|
||||||
|
generator = jasmine.createSpyObj('generator', ['generate']);
|
||||||
|
generator.generate.and.returnValue({
|
||||||
|
meta: {},
|
||||||
|
agents: [],
|
||||||
|
stages: [],
|
||||||
|
});
|
||||||
|
renderer = jasmine.createSpyObj('renderer', ['render', 'svg']);
|
||||||
|
renderer.svg.and.returnValue(document.createElement('svg'));
|
||||||
|
container = jasmine.createSpyObj('container', ['appendChild']);
|
||||||
|
ui = new Interface({
|
||||||
|
parser,
|
||||||
|
generator,
|
||||||
|
renderer,
|
||||||
|
defaultCode: 'my default code',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('build', () => {
|
||||||
|
it('adds elements to the given container', () => {
|
||||||
|
ui.build(container);
|
||||||
|
expect(container.appendChild).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a code mirror instance with the given code', () => {
|
||||||
|
ui.build(container);
|
||||||
|
const constructorArgs = ui.code.constructor;
|
||||||
|
expect(constructorArgs.options.value).toEqual('my default code');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('download SVG', () => {
|
||||||
|
it('triggers a download of the current image in SVG format', () => {
|
||||||
|
ui.build(container);
|
||||||
|
expect(ui.downloadSVG.getAttribute('href')).toEqual('#');
|
||||||
|
ui.downloadSVG.dispatchEvent(new Event('click'));
|
||||||
|
expect(ui.downloadSVG.getAttribute('href')).not.toEqual('#');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,42 @@
|
||||||
|
define({
|
||||||
|
// Environment
|
||||||
|
esversion: 6,
|
||||||
|
browser: true,
|
||||||
|
typed: true,
|
||||||
|
|
||||||
|
// Error verbosity
|
||||||
|
bitwise: true,
|
||||||
|
curly: true,
|
||||||
|
eqeqeq: true,
|
||||||
|
forin: true,
|
||||||
|
freeze: true,
|
||||||
|
futurehostile: true,
|
||||||
|
latedef: true,
|
||||||
|
maxcomplexity: 6,
|
||||||
|
maxdepth: 3,
|
||||||
|
maxparams: 4,
|
||||||
|
maxstatements: 20,
|
||||||
|
noarg: true,
|
||||||
|
nocomma: true,
|
||||||
|
nonbsp: true,
|
||||||
|
nonew: true,
|
||||||
|
shadow: 'outer',
|
||||||
|
singleGroups: false,
|
||||||
|
strict: true,
|
||||||
|
trailingcomma: true,
|
||||||
|
undef: true,
|
||||||
|
unused: true,
|
||||||
|
varstmt: true,
|
||||||
|
|
||||||
|
// Deprecated code-style flags:
|
||||||
|
camelcase: true,
|
||||||
|
immed: true,
|
||||||
|
indent: 4,
|
||||||
|
maxlen: 80,
|
||||||
|
newcap: true,
|
||||||
|
noempty: true,
|
||||||
|
quotmark: 'single',
|
||||||
|
|
||||||
|
// Output options
|
||||||
|
maxerr: 1000,
|
||||||
|
});
|
|
@ -0,0 +1,43 @@
|
||||||
|
((() => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
requirejs.config(window.getRequirejsCDN());
|
||||||
|
|
||||||
|
requirejs([
|
||||||
|
'interface/Interface',
|
||||||
|
'sequence/Parser',
|
||||||
|
'sequence/Generator',
|
||||||
|
'sequence/Renderer',
|
||||||
|
], (
|
||||||
|
Interface,
|
||||||
|
Parser,
|
||||||
|
Generator,
|
||||||
|
Renderer
|
||||||
|
) => {
|
||||||
|
const defaultCode = (
|
||||||
|
'title Labyrinth\n' +
|
||||||
|
'\n' +
|
||||||
|
'Bowie -> Gremlin: You remind me of the babe\n' +
|
||||||
|
'Gremlin -> Bowie: What babe?\n' +
|
||||||
|
'Bowie -> Gremlin: The babe with the power\n' +
|
||||||
|
'Gremlin -> Bowie: What power?\n' +
|
||||||
|
'note right of Bowie, Gremlin: Most people get muddled here!\n' +
|
||||||
|
'Bowie -> Gremlin: \'The power of voodoo\'\n' +
|
||||||
|
'Gremlin -> Bowie: "Who-do?"\n' +
|
||||||
|
'Bowie -> Gremlin: You do!\n' +
|
||||||
|
'Gremlin -> Bowie: Do what?\n' +
|
||||||
|
'Bowie -> Gremlin: Remind me of the babe!\n' +
|
||||||
|
'\n' +
|
||||||
|
'Bowie -> Audience: Sings\n' +
|
||||||
|
'\n' +
|
||||||
|
'terminators box\n'
|
||||||
|
);
|
||||||
|
const ui = new Interface({
|
||||||
|
defaultCode,
|
||||||
|
parser: new Parser(),
|
||||||
|
generator: new Generator(),
|
||||||
|
renderer: new Renderer(),
|
||||||
|
});
|
||||||
|
ui.build(document.body);
|
||||||
|
});
|
||||||
|
})());
|
|
@ -0,0 +1,36 @@
|
||||||
|
((() => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
window.getRequirejsCDN = () => {
|
||||||
|
const paths = {};
|
||||||
|
const hashes = {};
|
||||||
|
const metaTags = document.getElementsByTagName('meta');
|
||||||
|
for(let i = 0; i < metaTags.length; ++ i) {
|
||||||
|
const metaTag = metaTags[i];
|
||||||
|
const name = metaTag.getAttribute('name');
|
||||||
|
if(name && name.startsWith('cdn-')) {
|
||||||
|
const module = name.substr('cdn-'.length);
|
||||||
|
let src = metaTag.getAttribute('content');
|
||||||
|
if(src.endsWith('.js')) {
|
||||||
|
src = src.substr(0, src.length - '.js'.length);
|
||||||
|
}
|
||||||
|
paths[module] = src;
|
||||||
|
const integrity = metaTag.getAttribute('data-integrity');
|
||||||
|
if(integrity) {
|
||||||
|
hashes[module] = integrity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
paths,
|
||||||
|
hashes,
|
||||||
|
onNodeCreated: (node, config, module) => {
|
||||||
|
if(config.hashes[module]) {
|
||||||
|
// Thanks, https://stackoverflow.com/a/37065379/1180785
|
||||||
|
node.setAttribute('integrity', config.hashes[module]);
|
||||||
|
node.setAttribute('crossorigin', 'anonymous');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
})());
|
|
@ -0,0 +1,194 @@
|
||||||
|
define(() => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
function mergeSets(target, b) {
|
||||||
|
if(!b) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for(let i = 0; i < b.length; ++ i) {
|
||||||
|
if(target.indexOf(b[i]) === -1) {
|
||||||
|
target.push(b[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastElement(list) {
|
||||||
|
return list[list.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return class Generator {
|
||||||
|
findAgents(stages) {
|
||||||
|
const agents = ['['];
|
||||||
|
stages.forEach((stage) => {
|
||||||
|
if(stage.agents) {
|
||||||
|
mergeSets(agents, stage.agents);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if(agents.indexOf(']') !== -1) {
|
||||||
|
agents.splice(agents.indexOf(']'), 1);
|
||||||
|
}
|
||||||
|
agents.push(']');
|
||||||
|
|
||||||
|
return agents;
|
||||||
|
}
|
||||||
|
|
||||||
|
generate({meta = {}, stages}) {
|
||||||
|
const agents = this.findAgents(stages);
|
||||||
|
|
||||||
|
const agentStates = new Map();
|
||||||
|
agents.forEach((agent) => {
|
||||||
|
agentStates.set(agent, {visible: false, locked: false});
|
||||||
|
});
|
||||||
|
agentStates.get('[').locked = true;
|
||||||
|
agentStates.get(']').locked = true;
|
||||||
|
|
||||||
|
const rootStages = [];
|
||||||
|
let currentSection = {
|
||||||
|
mode: 'global',
|
||||||
|
label: '',
|
||||||
|
stages: rootStages,
|
||||||
|
};
|
||||||
|
let currentNest = {
|
||||||
|
type: 'block',
|
||||||
|
agents: [],
|
||||||
|
root: true,
|
||||||
|
sections: [currentSection],
|
||||||
|
};
|
||||||
|
const nesting = [currentNest];
|
||||||
|
|
||||||
|
function beginNested(stage) {
|
||||||
|
currentSection = {
|
||||||
|
mode: stage.mode,
|
||||||
|
label: stage.label,
|
||||||
|
stages: [],
|
||||||
|
};
|
||||||
|
currentNest = {
|
||||||
|
type: 'block',
|
||||||
|
agents: [],
|
||||||
|
sections: [currentSection],
|
||||||
|
};
|
||||||
|
nesting.push(currentNest);
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitNested(stage) {
|
||||||
|
if(currentNest.sections[0].mode !== 'if') {
|
||||||
|
throw new Error('Invalid block nesting');
|
||||||
|
}
|
||||||
|
currentSection = {
|
||||||
|
mode: stage.mode,
|
||||||
|
label: stage.label,
|
||||||
|
stages: [],
|
||||||
|
};
|
||||||
|
currentNest.sections.push(currentSection);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addStage(stage) {
|
||||||
|
currentSection.stages.push(stage);
|
||||||
|
mergeSets(currentNest.agents, stage.agents);
|
||||||
|
}
|
||||||
|
|
||||||
|
function endNested() {
|
||||||
|
if(currentNest.root) {
|
||||||
|
throw new Error('Invalid block nesting');
|
||||||
|
}
|
||||||
|
const subNest = nesting.pop();
|
||||||
|
currentNest = lastElement(nesting);
|
||||||
|
currentSection = lastElement(currentNest.sections);
|
||||||
|
if(subNest.agents.length > 0) {
|
||||||
|
addStage(subNest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addAgentMod(stageAgents, markVisible, mode) {
|
||||||
|
if(stageAgents.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stageAgents.forEach((agent) => {
|
||||||
|
agentStates.get(agent).visible = markVisible;
|
||||||
|
});
|
||||||
|
const type = (markVisible ? 'agent begin' : 'agent end');
|
||||||
|
const existing = lastElement(currentSection.stages) || {};
|
||||||
|
if(existing.type === type && existing.mode === mode) {
|
||||||
|
mergeSets(existing.agents, stageAgents);
|
||||||
|
mergeSets(currentNest.agents, stageAgents);
|
||||||
|
} else {
|
||||||
|
addStage({
|
||||||
|
type,
|
||||||
|
agents: stageAgents,
|
||||||
|
mode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterVis(stageAgents, visible, implicit = false) {
|
||||||
|
return stageAgents.filter((agent) => {
|
||||||
|
const state = agentStates.get(agent);
|
||||||
|
if(!state.locked) {
|
||||||
|
return state.visible === visible;
|
||||||
|
} else if(!implicit) {
|
||||||
|
throw new Error('Cannot modify agent ' + agent);
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
stages.forEach((stage) => {
|
||||||
|
/* jshint -W074 */ // It's only a switch statement
|
||||||
|
switch(stage.type) {
|
||||||
|
case 'agent define':
|
||||||
|
break;
|
||||||
|
case 'agent begin':
|
||||||
|
addAgentMod(
|
||||||
|
filterVis(stage.agents, false),
|
||||||
|
true,
|
||||||
|
stage.mode
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'agent end':
|
||||||
|
addAgentMod(
|
||||||
|
filterVis(stage.agents, true),
|
||||||
|
false,
|
||||||
|
stage.mode
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'block begin':
|
||||||
|
beginNested(stage);
|
||||||
|
break;
|
||||||
|
case 'block split':
|
||||||
|
splitNested(stage);
|
||||||
|
break;
|
||||||
|
case 'block end':
|
||||||
|
endNested(stage);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
addAgentMod(
|
||||||
|
filterVis(stage.agents, false, true),
|
||||||
|
true,
|
||||||
|
'box'
|
||||||
|
);
|
||||||
|
addStage(stage);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if(nesting.length !== 1) {
|
||||||
|
throw new Error('Invalid block nesting');
|
||||||
|
}
|
||||||
|
|
||||||
|
addAgentMod(
|
||||||
|
filterVis(agents, true, true),
|
||||||
|
false,
|
||||||
|
meta.terminators || 'none'
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
meta,
|
||||||
|
agents,
|
||||||
|
stages: rootStages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
|
@ -0,0 +1,374 @@
|
||||||
|
defineDescribe('Sequence Generator', ['./Generator'], (Generator) => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/* jshint -W071 */ // Allow lots of tests
|
||||||
|
|
||||||
|
const generator = new Generator();
|
||||||
|
|
||||||
|
const AGENT_DEFINE = 'agent define';
|
||||||
|
const AGENT_BEGIN = 'agent begin';
|
||||||
|
const AGENT_END = 'agent end';
|
||||||
|
|
||||||
|
const BLOCK_BEGIN = 'block begin';
|
||||||
|
const BLOCK_SPLIT = 'block split';
|
||||||
|
const BLOCK_END = 'block end';
|
||||||
|
|
||||||
|
describe('.generate', () => {
|
||||||
|
it('propagates metadata', () => {
|
||||||
|
const input = {
|
||||||
|
meta: {foo: 'bar'},
|
||||||
|
stages: [],
|
||||||
|
};
|
||||||
|
const sequence = generator.generate(input);
|
||||||
|
expect(sequence.meta).toEqual({foo: 'bar'});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty sequence for blank input', () => {
|
||||||
|
const sequence = generator.generate({stages: []});
|
||||||
|
expect(sequence.stages).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes implicit hidden left/right agents', () => {
|
||||||
|
const sequence = generator.generate({stages: []});
|
||||||
|
expect(sequence.agents).toEqual(['[', ']']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns aggregated agents', () => {
|
||||||
|
const sequence = generator.generate({stages: [
|
||||||
|
{type: '->', agents: ['A', 'B']},
|
||||||
|
{type: '<-', agents: ['C', 'D']},
|
||||||
|
{type: AGENT_BEGIN, agents: ['E'], mode: 'box'},
|
||||||
|
]});
|
||||||
|
expect(sequence.agents).toEqual(
|
||||||
|
['[', 'A', 'B', 'C', 'D', 'E', ']']
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('always puts the implicit right agent on the right', () => {
|
||||||
|
const sequence = generator.generate({stages: [
|
||||||
|
{type: '->', agents: [']', 'B']},
|
||||||
|
]});
|
||||||
|
expect(sequence.agents).toEqual(['[', 'B', ']']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates implicit begin stages for agents when used', () => {
|
||||||
|
const sequence = generator.generate({stages: [
|
||||||
|
{type: '->', agents: ['A', 'B']},
|
||||||
|
{type: '->', agents: ['B', 'C']},
|
||||||
|
]});
|
||||||
|
expect(sequence.stages).toEqual([
|
||||||
|
{type: AGENT_BEGIN, agents: ['A', 'B'], mode: 'box'},
|
||||||
|
{type: '->', agents: ['A', 'B']},
|
||||||
|
{type: AGENT_BEGIN, agents: ['C'], mode: 'box'},
|
||||||
|
{type: '->', agents: ['B', 'C']},
|
||||||
|
{type: AGENT_END, agents: ['A', 'B', 'C'], mode: 'none'},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates implicit end stages for all remaining agents', () => {
|
||||||
|
const sequence = generator.generate({
|
||||||
|
meta: {
|
||||||
|
terminators: 'foo',
|
||||||
|
},
|
||||||
|
stages: [
|
||||||
|
{type: '->', agents: ['A', 'B']},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(sequence.stages).toEqual([
|
||||||
|
{type: AGENT_BEGIN, agents: ['A', 'B'], mode: 'box'},
|
||||||
|
{type: '->', agents: ['A', 'B']},
|
||||||
|
{type: AGENT_END, agents: ['A', 'B'], mode: 'foo'},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not create duplicate begin stages', () => {
|
||||||
|
const sequence = generator.generate({stages: [
|
||||||
|
{type: AGENT_BEGIN, agents: ['A', 'B', 'C'], mode: 'box'},
|
||||||
|
{type: '->', agents: ['A', 'B']},
|
||||||
|
{type: '->', agents: ['B', 'C']},
|
||||||
|
]});
|
||||||
|
expect(sequence.stages).toEqual([
|
||||||
|
{type: AGENT_BEGIN, agents: ['A', 'B', 'C'], mode: 'box'},
|
||||||
|
{type: '->', agents: ['A', 'B']},
|
||||||
|
{type: '->', agents: ['B', 'C']},
|
||||||
|
{type: AGENT_END, agents: ['A', 'B', 'C'], mode: 'none'},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redisplays agents if they have been hidden', () => {
|
||||||
|
const sequence = generator.generate({stages: [
|
||||||
|
{type: AGENT_BEGIN, agents: ['A', 'B'], mode: 'box'},
|
||||||
|
{type: '->', agents: ['A', 'B']},
|
||||||
|
{type: AGENT_END, agents: ['B'], mode: 'cross'},
|
||||||
|
{type: '->', agents: ['A', 'B']},
|
||||||
|
]});
|
||||||
|
expect(sequence.stages).toEqual([
|
||||||
|
{type: AGENT_BEGIN, agents: ['A', 'B'], mode: 'box'},
|
||||||
|
{type: '->', agents: ['A', 'B']},
|
||||||
|
{type: AGENT_END, agents: ['B'], mode: 'cross'},
|
||||||
|
{type: AGENT_BEGIN, agents: ['B'], mode: 'box'},
|
||||||
|
{type: '->', agents: ['A', 'B']},
|
||||||
|
{type: AGENT_END, agents: ['A', 'B'], mode: 'none'},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses adjacent begin statements', () => {
|
||||||
|
const sequence = generator.generate({stages: [
|
||||||
|
{type: '->', agents: ['A', 'B']},
|
||||||
|
{type: AGENT_BEGIN, agents: ['D'], mode: 'box'},
|
||||||
|
{type: '->', agents: ['B', 'C']},
|
||||||
|
{type: '->', agents: ['C', 'D']},
|
||||||
|
]});
|
||||||
|
expect(sequence.stages).toEqual([
|
||||||
|
{type: AGENT_BEGIN, agents: ['A', 'B'], mode: 'box'},
|
||||||
|
{type: '->', agents: ['A', 'B']},
|
||||||
|
{type: AGENT_BEGIN, agents: ['D', 'C'], mode: 'box'},
|
||||||
|
{type: '->', agents: ['B', 'C']},
|
||||||
|
{type: '->', agents: ['C', 'D']},
|
||||||
|
{type: AGENT_END, agents: ['A', 'B', 'D', 'C'], mode: 'none'},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes superfluous begin statements', () => {
|
||||||
|
const sequence = generator.generate({stages: [
|
||||||
|
{type: '->', agents: ['A', 'B']},
|
||||||
|
{type: AGENT_BEGIN, agents: ['A', 'C', 'D'], mode: 'box'},
|
||||||
|
{type: AGENT_BEGIN, agents: ['C', 'E'], mode: 'box'},
|
||||||
|
]});
|
||||||
|
expect(sequence.stages).toEqual([
|
||||||
|
{type: AGENT_BEGIN, agents: ['A', 'B'], mode: 'box'},
|
||||||
|
{type: '->', agents: ['A', 'B']},
|
||||||
|
{type: AGENT_BEGIN, agents: ['C', 'D', 'E'], mode: 'box'},
|
||||||
|
{type: AGENT_END, agents: [
|
||||||
|
'A', 'B', 'C', 'D', 'E',
|
||||||
|
], mode: 'none'},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes superfluous end statements', () => {
|
||||||
|
const sequence = generator.generate({stages: [
|
||||||
|
{type: AGENT_DEFINE, agents: ['E']},
|
||||||
|
{type: AGENT_BEGIN, agents: ['C', 'D'], mode: 'box'},
|
||||||
|
{type: '->', agents: ['A', 'B']},
|
||||||
|
{type: AGENT_END, agents: ['A', 'B', 'C'], mode: 'cross'},
|
||||||
|
{type: AGENT_END, agents: ['A', 'D', 'E'], mode: 'cross'},
|
||||||
|
]});
|
||||||
|
expect(sequence.stages).toEqual([
|
||||||
|
{type: AGENT_BEGIN, agents: ['C', 'D', 'A', 'B'], mode: 'box'},
|
||||||
|
{type: '->', agents: ['A', 'B']},
|
||||||
|
{type: AGENT_END, agents: ['A', 'B', 'C', 'D'], mode: 'cross'},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not merge different modes of end', () => {
|
||||||
|
const sequence = generator.generate({stages: [
|
||||||
|
{type: AGENT_DEFINE, agents: ['E']},
|
||||||
|
{type: AGENT_BEGIN, agents: ['C', 'D'], mode: 'box'},
|
||||||
|
{type: '->', agents: ['A', 'B']},
|
||||||
|
{type: AGENT_END, agents: ['A', 'B', 'C'], mode: 'cross'},
|
||||||
|
]});
|
||||||
|
expect(sequence.stages).toEqual([
|
||||||
|
{type: AGENT_BEGIN, agents: ['C', 'D', 'A', 'B'], mode: 'box'},
|
||||||
|
{type: '->', agents: ['A', 'B']},
|
||||||
|
{type: AGENT_END, agents: ['A', 'B', 'C'], mode: 'cross'},
|
||||||
|
{type: AGENT_END, agents: ['D'], mode: 'none'},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records all involved agents in block begin statements', () => {
|
||||||
|
const sequence = generator.generate({stages: [
|
||||||
|
{type: BLOCK_BEGIN, mode: 'if', label: 'abc'},
|
||||||
|
{type: '->', agents: ['A', 'B']},
|
||||||
|
{type: BLOCK_SPLIT, mode: 'else', label: 'xyz'},
|
||||||
|
{type: '->', agents: ['A', 'C']},
|
||||||
|
{type: BLOCK_END},
|
||||||
|
]});
|
||||||
|
|
||||||
|
expect(sequence.stages).toEqual([
|
||||||
|
{type: 'block', agents: ['A', 'B', 'C'], sections: [
|
||||||
|
{mode: 'if', label: 'abc', stages: [
|
||||||
|
{type: AGENT_BEGIN, agents: ['A', 'B'], mode: 'box'},
|
||||||
|
{type: '->', agents: ['A', 'B']},
|
||||||
|
]},
|
||||||
|
{mode: 'else', label: 'xyz', stages: [
|
||||||
|
{type: AGENT_BEGIN, agents: ['C'], mode: 'box'},
|
||||||
|
{type: '->', agents: ['A', 'C']},
|
||||||
|
]},
|
||||||
|
]},
|
||||||
|
{type: AGENT_END, agents: ['A', 'B', 'C'], mode: 'none'},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records all involved agents in nested blocks', () => {
|
||||||
|
const sequence = generator.generate({stages: [
|
||||||
|
{type: BLOCK_BEGIN, mode: 'if', label: 'abc'},
|
||||||
|
{type: '->', agents: ['A', 'B']},
|
||||||
|
{type: BLOCK_SPLIT, mode: 'else', label: 'xyz'},
|
||||||
|
{type: BLOCK_BEGIN, mode: 'if', label: 'def'},
|
||||||
|
{type: '->', agents: ['A', 'C']},
|
||||||
|
{type: BLOCK_END},
|
||||||
|
{type: BLOCK_END},
|
||||||
|
]});
|
||||||
|
|
||||||
|
expect(sequence.stages).toEqual([
|
||||||
|
{type: 'block', agents: ['A', 'B', 'C'], sections: [
|
||||||
|
{mode: 'if', label: 'abc', stages: [
|
||||||
|
{type: AGENT_BEGIN, agents: ['A', 'B'], mode: 'box'},
|
||||||
|
{type: '->', agents: ['A', 'B']},
|
||||||
|
]},
|
||||||
|
{mode: 'else', label: 'xyz', stages: [
|
||||||
|
{type: 'block', agents: ['C', 'A'], sections: [
|
||||||
|
{mode: 'if', label: 'def', stages: [
|
||||||
|
{type: AGENT_BEGIN, agents: ['C'], mode: 'box'},
|
||||||
|
{type: '->', agents: ['A', 'C']},
|
||||||
|
]},
|
||||||
|
]},
|
||||||
|
]},
|
||||||
|
]},
|
||||||
|
{type: AGENT_END, agents: ['A', 'B', 'C'], mode: 'none'},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows empty block parts after split', () => {
|
||||||
|
const sequence = generator.generate({stages: [
|
||||||
|
{type: BLOCK_BEGIN, mode: 'if', label: 'abc'},
|
||||||
|
{type: '->', agents: ['A', 'B']},
|
||||||
|
{type: BLOCK_SPLIT, mode: 'else', label: 'xyz'},
|
||||||
|
{type: BLOCK_END},
|
||||||
|
]});
|
||||||
|
|
||||||
|
expect(sequence.stages).toEqual([
|
||||||
|
{type: 'block', agents: ['A', 'B'], sections: [
|
||||||
|
{mode: 'if', label: 'abc', stages: [
|
||||||
|
{type: AGENT_BEGIN, agents: ['A', 'B'], mode: 'box'},
|
||||||
|
{type: '->', agents: ['A', 'B']},
|
||||||
|
]},
|
||||||
|
{mode: 'else', label: 'xyz', stages: []},
|
||||||
|
]},
|
||||||
|
{type: AGENT_END, agents: ['A', 'B'], mode: 'none'},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows empty block parts before split', () => {
|
||||||
|
const sequence = generator.generate({stages: [
|
||||||
|
{type: BLOCK_BEGIN, mode: 'if', label: 'abc'},
|
||||||
|
{type: BLOCK_SPLIT, mode: 'else', label: 'xyz'},
|
||||||
|
{type: '->', agents: ['A', 'B']},
|
||||||
|
{type: BLOCK_END},
|
||||||
|
]});
|
||||||
|
|
||||||
|
expect(sequence.stages).toEqual([
|
||||||
|
{type: 'block', agents: ['A', 'B'], sections: [
|
||||||
|
{mode: 'if', label: 'abc', stages: []},
|
||||||
|
{mode: 'else', label: 'xyz', stages: [
|
||||||
|
{type: AGENT_BEGIN, agents: ['A', 'B'], mode: 'box'},
|
||||||
|
{type: '->', agents: ['A', 'B']},
|
||||||
|
]},
|
||||||
|
]},
|
||||||
|
{type: AGENT_END, agents: ['A', 'B'], mode: 'none'},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes entirely empty blocks', () => {
|
||||||
|
const sequence = generator.generate({stages: [
|
||||||
|
{type: BLOCK_BEGIN, mode: 'if', label: 'abc'},
|
||||||
|
{type: BLOCK_SPLIT, mode: 'else', label: 'xyz'},
|
||||||
|
{type: BLOCK_BEGIN, mode: 'if', label: 'abc'},
|
||||||
|
{type: BLOCK_END},
|
||||||
|
{type: BLOCK_END},
|
||||||
|
]});
|
||||||
|
|
||||||
|
expect(sequence.stages).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes entirely empty nested blocks', () => {
|
||||||
|
const sequence = generator.generate({stages: [
|
||||||
|
{type: BLOCK_BEGIN, mode: 'if', label: 'abc'},
|
||||||
|
{type: '->', agents: ['A', 'B']},
|
||||||
|
{type: BLOCK_SPLIT, mode: 'else', label: 'xyz'},
|
||||||
|
{type: BLOCK_BEGIN, mode: 'if', label: 'abc'},
|
||||||
|
{type: BLOCK_END},
|
||||||
|
{type: BLOCK_END},
|
||||||
|
]});
|
||||||
|
|
||||||
|
|
||||||
|
expect(sequence.stages).toEqual([
|
||||||
|
{type: 'block', agents: ['A', 'B'], sections: [
|
||||||
|
{mode: 'if', label: 'abc', stages: [
|
||||||
|
{type: AGENT_BEGIN, agents: ['A', 'B'], mode: 'box'},
|
||||||
|
{type: '->', agents: ['A', 'B']},
|
||||||
|
]},
|
||||||
|
{mode: 'else', label: 'xyz', stages: []},
|
||||||
|
]},
|
||||||
|
{type: AGENT_END, agents: ['A', 'B'], mode: 'none'},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unterminated blocks', () => {
|
||||||
|
expect(() => generator.generate({stages: [
|
||||||
|
{type: BLOCK_BEGIN, mode: 'if', label: 'abc'},
|
||||||
|
{type: '->', agents: ['A', 'B']},
|
||||||
|
]})).toThrow();
|
||||||
|
|
||||||
|
expect(() => generator.generate({stages: [
|
||||||
|
{type: BLOCK_BEGIN, mode: 'if', label: 'abc'},
|
||||||
|
{type: BLOCK_BEGIN, mode: 'if', label: 'def'},
|
||||||
|
{type: '->', agents: ['A', 'B']},
|
||||||
|
{type: BLOCK_END},
|
||||||
|
]})).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects extra block terminations', () => {
|
||||||
|
expect(() => generator.generate({stages: [
|
||||||
|
{type: BLOCK_END},
|
||||||
|
]})).toThrow();
|
||||||
|
|
||||||
|
expect(() => generator.generate({stages: [
|
||||||
|
{type: BLOCK_BEGIN, mode: 'if', label: 'abc'},
|
||||||
|
{type: '->', agents: ['A', 'B']},
|
||||||
|
{type: BLOCK_END},
|
||||||
|
{type: BLOCK_END},
|
||||||
|
]})).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects block splitting without a block', () => {
|
||||||
|
expect(() => generator.generate({stages: [
|
||||||
|
{type: BLOCK_SPLIT, mode: 'else', label: 'xyz'},
|
||||||
|
]})).toThrow();
|
||||||
|
|
||||||
|
expect(() => generator.generate({stages: [
|
||||||
|
{type: BLOCK_BEGIN, mode: 'if', label: 'abc'},
|
||||||
|
{type: '->', agents: ['A', 'B']},
|
||||||
|
{type: BLOCK_END},
|
||||||
|
{type: BLOCK_SPLIT, mode: 'else', label: 'xyz'},
|
||||||
|
]})).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects block splitting in non-splittable blocks', () => {
|
||||||
|
expect(() => generator.generate({stages: [
|
||||||
|
{type: BLOCK_BEGIN, mode: 'repeat', label: 'abc'},
|
||||||
|
{type: BLOCK_SPLIT, mode: 'else', label: 'xyz'},
|
||||||
|
{type: '->', agents: ['A', 'B']},
|
||||||
|
{type: BLOCK_END},
|
||||||
|
]})).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects attempts to change implicit agents', () => {
|
||||||
|
expect(() => generator.generate({stages: [
|
||||||
|
{type: AGENT_BEGIN, agents: ['['], mode: 'box'},
|
||||||
|
]})).toThrow();
|
||||||
|
|
||||||
|
expect(() => generator.generate({stages: [
|
||||||
|
{type: AGENT_BEGIN, agents: [']'], mode: 'box'},
|
||||||
|
]})).toThrow();
|
||||||
|
|
||||||
|
expect(() => generator.generate({stages: [
|
||||||
|
{type: AGENT_END, agents: ['['], mode: 'cross'},
|
||||||
|
]})).toThrow();
|
||||||
|
|
||||||
|
expect(() => generator.generate({stages: [
|
||||||
|
{type: AGENT_END, agents: [']'], mode: 'cross'},
|
||||||
|
]})).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,346 @@
|
||||||
|
define(() => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
function execAt(str, reg, i) {
|
||||||
|
reg.lastIndex = i;
|
||||||
|
return reg.exec(str);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unescape(match) {
|
||||||
|
const c = match[1];
|
||||||
|
if(c === 'n') {
|
||||||
|
return '\n';
|
||||||
|
}
|
||||||
|
return match[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOKENS = [
|
||||||
|
{start: /#/y, end: /(?=\n)|$/y, omit: true},
|
||||||
|
{start: /"/y, end: /"/y, escape: /\\(.)/y, escapeWith: unescape},
|
||||||
|
{start: /'/y, end: /'/y, escape: /\\(.)/y, escapeWith: unescape},
|
||||||
|
{start: /(?=[^ \t\r\n:+\-<>,])/y, end: /(?=[ \t\r\n:+\-<>,])|$/y},
|
||||||
|
{start: /(?=[+\-<>])/y, end: /(?=[^+\-<>])|$/y},
|
||||||
|
{start: /,/y, prefix: ','},
|
||||||
|
{start: /:/y, prefix: ':'},
|
||||||
|
{start: /\n/y, prefix: '\n'},
|
||||||
|
];
|
||||||
|
|
||||||
|
const BLOCK_TYPES = {
|
||||||
|
'if': {type: 'block begin', mode: 'if', skip: []},
|
||||||
|
'else': {type: 'block split', mode: 'else', skip: ['if']},
|
||||||
|
'elif': {type: 'block split', mode: 'else', skip: []},
|
||||||
|
'repeat': {type: 'block begin', mode: 'repeat', skip: []},
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONNECTION_TYPES = [
|
||||||
|
'->',
|
||||||
|
'<-',
|
||||||
|
'<->',
|
||||||
|
'-->',
|
||||||
|
'<--',
|
||||||
|
'<-->',
|
||||||
|
];
|
||||||
|
|
||||||
|
const TERMINATOR_TYPES = [
|
||||||
|
'none',
|
||||||
|
'box',
|
||||||
|
'cross',
|
||||||
|
'bar',
|
||||||
|
];
|
||||||
|
|
||||||
|
const NOTE_TYPES = {
|
||||||
|
'note': {
|
||||||
|
mode: 'note',
|
||||||
|
multiAgent: true,
|
||||||
|
types: {
|
||||||
|
'over': {type: 'note over', skip: []},
|
||||||
|
'left': {type: 'note left', skip: ['of']},
|
||||||
|
'right': {type: 'note right', skip: ['of']},
|
||||||
|
'between': {type: 'note between', skip: []},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'state': {
|
||||||
|
mode: 'state',
|
||||||
|
multiAgent: false,
|
||||||
|
types: {
|
||||||
|
'over': {type: 'note over', skip: []},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const AGENT_MANIPULATION_TYPES = {
|
||||||
|
'define': {type: 'agent define'},
|
||||||
|
'begin': {type: 'agent begin', mode: 'box'},
|
||||||
|
'end': {type: 'agent end', mode: 'cross'},
|
||||||
|
};
|
||||||
|
|
||||||
|
function tokFindBegin(src, i) {
|
||||||
|
for(let j = 0; j < TOKENS.length; ++ j) {
|
||||||
|
const block = TOKENS[j];
|
||||||
|
const match = execAt(src, block.start, i);
|
||||||
|
if(match) {
|
||||||
|
return {
|
||||||
|
newBlock: block,
|
||||||
|
end: !block.end,
|
||||||
|
append: (block.prefix || ''),
|
||||||
|
skip: match[0].length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
newBlock: null,
|
||||||
|
end: false,
|
||||||
|
append: '',
|
||||||
|
skip: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokContinuePart(src, i, block) {
|
||||||
|
if(block.escape) {
|
||||||
|
const match = execAt(src, block.escape, i);
|
||||||
|
if(match) {
|
||||||
|
return {
|
||||||
|
newBlock: null,
|
||||||
|
end: false,
|
||||||
|
append: block.escapeWith(match),
|
||||||
|
skip: match[0].length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const match = execAt(src, block.end, i);
|
||||||
|
if(match) {
|
||||||
|
return {
|
||||||
|
newBlock: null,
|
||||||
|
end: true,
|
||||||
|
append: '',
|
||||||
|
skip: match[0].length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
newBlock: null,
|
||||||
|
end: false,
|
||||||
|
append: src[i],
|
||||||
|
skip: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokAdvance(src, i, block) {
|
||||||
|
if(block) {
|
||||||
|
return tokContinuePart(src, i, block);
|
||||||
|
} else {
|
||||||
|
return tokFindBegin(src, i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipOver(line, start, skip, error = null) {
|
||||||
|
if(skip.some((token, i) => (line[start + i] !== token))) {
|
||||||
|
if(error) {
|
||||||
|
throw new Error(error + ': ' + line.join(' '));
|
||||||
|
} else {
|
||||||
|
return start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return start + skip.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCommaList(tokens) {
|
||||||
|
const list = [];
|
||||||
|
let current = '';
|
||||||
|
tokens.forEach((token) => {
|
||||||
|
if(token === ',') {
|
||||||
|
if(current) {
|
||||||
|
list.push(current);
|
||||||
|
current = '';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
current += (current ? ' ' : '') + token;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if(current) {
|
||||||
|
list.push(current);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBlockCommand(line) {
|
||||||
|
if(line[0] === 'end' && line.length === 1) {
|
||||||
|
return {type: 'block end'};
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = BLOCK_TYPES[line[0]];
|
||||||
|
if(!type) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let skip = 1;
|
||||||
|
if(line.length > skip) {
|
||||||
|
skip = skipOver(line, skip, type.skip, 'Invalid block command');
|
||||||
|
}
|
||||||
|
skip = skipOver(line, skip, [':']);
|
||||||
|
return {
|
||||||
|
type: type.type,
|
||||||
|
mode: type.mode,
|
||||||
|
label: line.slice(skip).join(' '),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAgentCommand(line) {
|
||||||
|
const type = AGENT_MANIPULATION_TYPES[line[0]];
|
||||||
|
if(!type) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if(line.length <= 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Object.assign({
|
||||||
|
agents: parseCommaList(line.slice(1)),
|
||||||
|
}, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNote(line) {
|
||||||
|
const mode = NOTE_TYPES[line[0]];
|
||||||
|
const labelSplit = line.indexOf(':');
|
||||||
|
if(!mode || labelSplit === -1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const type = mode.types[line[1]];
|
||||||
|
if(!type) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let skip = 2;
|
||||||
|
skip = skipOver(line, skip, type.skip);
|
||||||
|
const agents = parseCommaList(line.slice(skip, labelSplit));
|
||||||
|
if(agents.length < 1 || (agents.length > 1 && !mode.multiAgent)) {
|
||||||
|
throw new Error('Invalid ' + line[0] + ': ' + line.join(' '));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: type.type,
|
||||||
|
agents,
|
||||||
|
mode: mode.mode,
|
||||||
|
label: line.slice(labelSplit + 1).join(' '),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseConnection(line) {
|
||||||
|
let labelSplit = line.indexOf(':');
|
||||||
|
if(labelSplit === -1) {
|
||||||
|
labelSplit = line.length;
|
||||||
|
}
|
||||||
|
let typeSplit = -1;
|
||||||
|
for(let j = 0; j < CONNECTION_TYPES.length; ++ j) {
|
||||||
|
const p = line.indexOf(CONNECTION_TYPES[j]);
|
||||||
|
if(p !== -1 && p < labelSplit) {
|
||||||
|
typeSplit = p;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(typeSplit <= 0 || typeSplit === labelSplit - 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: line[typeSplit],
|
||||||
|
agents: [
|
||||||
|
line.slice(0, typeSplit).join(' '),
|
||||||
|
line.slice(typeSplit + 1, labelSplit).join(' '),
|
||||||
|
],
|
||||||
|
label: line.slice(labelSplit + 1).join(' '),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMeta(line, meta) {
|
||||||
|
if(line[0] === 'title') {
|
||||||
|
meta.title = line.slice(1).join(' ');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if(line[0] === 'terminators') {
|
||||||
|
if(TERMINATOR_TYPES.indexOf(line[1]) === -1) {
|
||||||
|
throw new Error('Unrecognised termination: ' + line.join(' '));
|
||||||
|
}
|
||||||
|
meta.terminators = line[1];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLine(line, {meta, stages}) {
|
||||||
|
if(parseMeta(line, meta)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const stage = (
|
||||||
|
parseBlockCommand(line) ||
|
||||||
|
parseAgentCommand(line) ||
|
||||||
|
parseNote(line) ||
|
||||||
|
parseConnection(line)
|
||||||
|
);
|
||||||
|
if(!stage) {
|
||||||
|
throw new Error('Unrecognised command: ' + line.join(' '));
|
||||||
|
}
|
||||||
|
stages.push(stage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return class Parser {
|
||||||
|
tokenise(src) {
|
||||||
|
const tokens = [];
|
||||||
|
let block = null;
|
||||||
|
let current = '';
|
||||||
|
for(let i = 0; i <= src.length;) {
|
||||||
|
const {newBlock, end, append, skip} = tokAdvance(src, i, block);
|
||||||
|
if(newBlock) {
|
||||||
|
block = newBlock;
|
||||||
|
current = '';
|
||||||
|
}
|
||||||
|
current += append;
|
||||||
|
i += skip;
|
||||||
|
if(end) {
|
||||||
|
if(!block.omit) {
|
||||||
|
tokens.push(current);
|
||||||
|
}
|
||||||
|
block = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(block) {
|
||||||
|
throw new Error('Unterminated block');
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
splitLines(tokens) {
|
||||||
|
const lines = [];
|
||||||
|
let line = [];
|
||||||
|
tokens.forEach((token) => {
|
||||||
|
if(token === '\n') {
|
||||||
|
if(line.length > 0) {
|
||||||
|
lines.push(line);
|
||||||
|
line = [];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
line.push(token);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if(line.length > 0) {
|
||||||
|
lines.push(line);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
parseLines(lines) {
|
||||||
|
const result = {
|
||||||
|
meta: {
|
||||||
|
title: '',
|
||||||
|
terminators: 'none',
|
||||||
|
},
|
||||||
|
stages: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
lines.forEach((line) => parseLine(line, result));
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
parse(src) {
|
||||||
|
const tokens = this.tokenise(src);
|
||||||
|
const lines = this.splitLines(tokens);
|
||||||
|
return this.parseLines(lines);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
|
@ -0,0 +1,345 @@
|
||||||
|
defineDescribe('Sequence Parser', ['./Parser'], (Parser) => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/* jshint -W071 */ // Allow lots of tests
|
||||||
|
|
||||||
|
const parser = new Parser();
|
||||||
|
|
||||||
|
describe('.tokenise', () => {
|
||||||
|
it('converts the source into atomic tokens', () => {
|
||||||
|
const input = 'foo bar -> baz';
|
||||||
|
const tokens = parser.tokenise(input);
|
||||||
|
expect(tokens).toEqual(['foo', 'bar', '->', 'baz']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('splits tokens at flexible boundaries', () => {
|
||||||
|
const input = 'foo bar->baz';
|
||||||
|
const tokens = parser.tokenise(input);
|
||||||
|
expect(tokens).toEqual(['foo', 'bar', '->', 'baz']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses newlines as tokens', () => {
|
||||||
|
const input = 'foo bar\nbaz';
|
||||||
|
const tokens = parser.tokenise(input);
|
||||||
|
expect(tokens).toEqual(['foo', 'bar', '\n', 'baz']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes leading and trailing whitespace', () => {
|
||||||
|
const input = ' foo \t bar\t\n baz';
|
||||||
|
const tokens = parser.tokenise(input);
|
||||||
|
expect(tokens).toEqual(['foo', 'bar', '\n', 'baz']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses quoted strings as single tokens', () => {
|
||||||
|
const input = 'foo "zig zag" \'abc def\'';
|
||||||
|
const tokens = parser.tokenise(input);
|
||||||
|
expect(tokens).toEqual(['foo', 'zig zag', 'abc def']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores comments', () => {
|
||||||
|
const input = 'foo # bar baz\nzig';
|
||||||
|
const tokens = parser.tokenise(input);
|
||||||
|
expect(tokens).toEqual(['foo', '\n', 'zig']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores quotes within comments', () => {
|
||||||
|
const input = 'foo # bar "\'baz\nzig';
|
||||||
|
const tokens = parser.tokenise(input);
|
||||||
|
expect(tokens).toEqual(['foo', '\n', 'zig']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('interprets special characters within quoted strings', () => {
|
||||||
|
const input = 'foo "zig\\" zag\\n"';
|
||||||
|
const tokens = parser.tokenise(input);
|
||||||
|
expect(tokens).toEqual(['foo', 'zig" zag\n']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maintains whitespace and newlines within quoted strings', () => {
|
||||||
|
const input = 'foo " zig\n zag "';
|
||||||
|
const tokens = parser.tokenise(input);
|
||||||
|
expect(tokens).toEqual(['foo', ' zig\n zag ']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unterminated quoted values', () => {
|
||||||
|
expect(() => parser.tokenise('"nope')).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('.splitLines', () => {
|
||||||
|
it('combines tokens', () => {
|
||||||
|
const lines = parser.splitLines(['abc', 'd']);
|
||||||
|
expect(lines).toEqual([
|
||||||
|
['abc', 'd'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('splits at newlines', () => {
|
||||||
|
const lines = parser.splitLines(['abc', 'd', '\n', 'e']);
|
||||||
|
expect(lines).toEqual([
|
||||||
|
['abc', 'd'],
|
||||||
|
['e'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores multiple newlines', () => {
|
||||||
|
const lines = parser.splitLines(['abc', 'd', '\n', '\n', 'e']);
|
||||||
|
expect(lines).toEqual([
|
||||||
|
['abc', 'd'],
|
||||||
|
['e'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores trailing newlines', () => {
|
||||||
|
const lines = parser.splitLines(['abc', 'd', '\n', 'e', '\n']);
|
||||||
|
expect(lines).toEqual([
|
||||||
|
['abc', 'd'],
|
||||||
|
['e'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty input', () => {
|
||||||
|
const lines = parser.splitLines([]);
|
||||||
|
expect(lines).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('.parse', () => {
|
||||||
|
it('returns an empty sequence for blank input', () => {
|
||||||
|
const parsed = parser.parse('');
|
||||||
|
expect(parsed).toEqual({
|
||||||
|
meta: {
|
||||||
|
title: '',
|
||||||
|
terminators: 'none',
|
||||||
|
},
|
||||||
|
stages: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads title metadata', () => {
|
||||||
|
const parsed = parser.parse('title foo');
|
||||||
|
expect(parsed.meta.title).toEqual('foo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads terminators metadata', () => {
|
||||||
|
const parsed = parser.parse('terminators bar');
|
||||||
|
expect(parsed.meta.terminators).toEqual('bar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads multiple tokens as one when reading values', () => {
|
||||||
|
const parsed = parser.parse('title foo bar');
|
||||||
|
expect(parsed.meta.title).toEqual('foo bar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts entries into abstract form', () => {
|
||||||
|
const parsed = parser.parse('A -> B');
|
||||||
|
expect(parsed.stages).toEqual([
|
||||||
|
{type: '->', agents: ['A', 'B'], label: ''},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('combines multiple tokens into single entries', () => {
|
||||||
|
const parsed = parser.parse('A B -> C D');
|
||||||
|
expect(parsed.stages).toEqual([
|
||||||
|
{type: '->', agents: ['A B', 'C D'], label: ''},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses optional labels', () => {
|
||||||
|
const parsed = parser.parse('A B -> C D: foo bar');
|
||||||
|
expect(parsed.stages).toEqual([
|
||||||
|
{type: '->', agents: ['A B', 'C D'], label: 'foo bar'},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts multiple entries', () => {
|
||||||
|
const parsed = parser.parse('A -> B\nB -> A');
|
||||||
|
expect(parsed.stages).toEqual([
|
||||||
|
{type: '->', agents: ['A', 'B'], label: ''},
|
||||||
|
{type: '->', agents: ['B', 'A'], label: ''},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores blank lines', () => {
|
||||||
|
const parsed = parser.parse('A -> B\n\nB -> A\n');
|
||||||
|
expect(parsed.stages).toEqual([
|
||||||
|
{type: '->', agents: ['A', 'B'], label: ''},
|
||||||
|
{type: '->', agents: ['B', 'A'], label: ''},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recognises all types of connection', () => {
|
||||||
|
const parsed = parser.parse(
|
||||||
|
'A -> B\n' +
|
||||||
|
'A <- B\n' +
|
||||||
|
'A <-> B\n' +
|
||||||
|
'A --> B\n' +
|
||||||
|
'A <-- B\n' +
|
||||||
|
'A <--> B\n'
|
||||||
|
);
|
||||||
|
expect(parsed.stages).toEqual([
|
||||||
|
{type: '->', agents: ['A', 'B'], label: ''},
|
||||||
|
{type: '<-', agents: ['A', 'B'], label: ''},
|
||||||
|
{type: '<->', agents: ['A', 'B'], label: ''},
|
||||||
|
{type: '-->', agents: ['A', 'B'], label: ''},
|
||||||
|
{type: '<--', agents: ['A', 'B'], label: ''},
|
||||||
|
{type: '<-->', agents: ['A', 'B'], label: ''},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores arrows within the label', () => {
|
||||||
|
const parsed = parser.parse(
|
||||||
|
'A <- B: B -> A\n' +
|
||||||
|
'A -> B: B <- A\n'
|
||||||
|
);
|
||||||
|
expect(parsed.stages).toEqual([
|
||||||
|
{type: '<-', agents: ['A', 'B'], label: 'B -> A'},
|
||||||
|
{type: '->', agents: ['A', 'B'], label: 'B <- A'},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts notes', () => {
|
||||||
|
const parsed = parser.parse('note over A: hello there');
|
||||||
|
expect(parsed.stages).toEqual([{
|
||||||
|
type: 'note over',
|
||||||
|
agents: ['A'],
|
||||||
|
mode: 'note',
|
||||||
|
label: 'hello there',
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts different note types', () => {
|
||||||
|
const parsed = parser.parse(
|
||||||
|
'note left A: hello there\n' +
|
||||||
|
'note left of A: hello there\n' +
|
||||||
|
'note right A: hello there\n' +
|
||||||
|
'note right of A: hello there\n' +
|
||||||
|
'note between A, B: hi\n'
|
||||||
|
);
|
||||||
|
expect(parsed.stages).toEqual([
|
||||||
|
{
|
||||||
|
type: 'note left',
|
||||||
|
agents: ['A'],
|
||||||
|
mode: 'note',
|
||||||
|
label: 'hello there',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'note left',
|
||||||
|
agents: ['A'],
|
||||||
|
mode: 'note',
|
||||||
|
label: 'hello there',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'note right',
|
||||||
|
agents: ['A'],
|
||||||
|
mode: 'note',
|
||||||
|
label: 'hello there',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'note right',
|
||||||
|
agents: ['A'],
|
||||||
|
mode: 'note',
|
||||||
|
label: 'hello there',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'note between',
|
||||||
|
agents: ['A', 'B'],
|
||||||
|
mode: 'note',
|
||||||
|
label: 'hi',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows multiple agents for notes', () => {
|
||||||
|
const parsed = parser.parse('note over A B, C D: hi');
|
||||||
|
expect(parsed.stages).toEqual([{
|
||||||
|
type: 'note over',
|
||||||
|
agents: ['A B', 'C D'],
|
||||||
|
mode: 'note',
|
||||||
|
label: 'hi',
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts state', () => {
|
||||||
|
const parsed = parser.parse('state over A: doing stuff');
|
||||||
|
expect(parsed.stages).toEqual([{
|
||||||
|
type: 'note over',
|
||||||
|
agents: ['A'],
|
||||||
|
mode: 'state',
|
||||||
|
label: 'doing stuff',
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects multiple agents for state', () => {
|
||||||
|
expect(() => parser.parse('state over A, B: hi')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts agent commands', () => {
|
||||||
|
const parsed = parser.parse(
|
||||||
|
'define A, B\n' +
|
||||||
|
'begin A, B\n' +
|
||||||
|
'end A, B\n'
|
||||||
|
);
|
||||||
|
expect(parsed.stages).toEqual([
|
||||||
|
{type: 'agent define', agents: ['A', 'B']},
|
||||||
|
{type: 'agent begin', agents: ['A', 'B'], mode: 'box'},
|
||||||
|
{type: 'agent end', agents: ['A', 'B'], mode: 'cross'},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts conditional blocks', () => {
|
||||||
|
const parsed = parser.parse(
|
||||||
|
'if something happens\n' +
|
||||||
|
' A -> B\n' +
|
||||||
|
'else if something else\n' +
|
||||||
|
' A -> C\n' +
|
||||||
|
' C -> B\n' +
|
||||||
|
'else\n' +
|
||||||
|
' A -> D\n' +
|
||||||
|
'end\n'
|
||||||
|
);
|
||||||
|
expect(parsed.stages).toEqual([
|
||||||
|
{type: 'block begin', mode: 'if', label: 'something happens'},
|
||||||
|
{type: '->', agents: ['A', 'B'], label: ''},
|
||||||
|
{type: 'block split', mode: 'else', label: 'something else'},
|
||||||
|
{type: '->', agents: ['A', 'C'], label: ''},
|
||||||
|
{type: '->', agents: ['C', 'B'], label: ''},
|
||||||
|
{type: 'block split', mode: 'else', label: ''},
|
||||||
|
{type: '->', agents: ['A', 'D'], label: ''},
|
||||||
|
{type: 'block end'},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts loop blocks', () => {
|
||||||
|
const parsed = parser.parse('repeat until something');
|
||||||
|
expect(parsed.stages).toEqual([
|
||||||
|
{type: 'block begin', mode: 'repeat', label: 'until something'},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid inputs', () => {
|
||||||
|
expect(() => parser.parse('huh')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects partial links', () => {
|
||||||
|
expect(() => parser.parse('-> A')).toThrow();
|
||||||
|
expect(() => parser.parse('A ->')).toThrow();
|
||||||
|
expect(() => parser.parse('A -> : hello')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid terminators', () => {
|
||||||
|
expect(() => parser.parse('terminators foo')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects malformed notes', () => {
|
||||||
|
expect(() => parser.parse('note over A hello')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects malformed block commands', () => {
|
||||||
|
expect(() => parser.parse('else nope foo')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid notes', () => {
|
||||||
|
expect(() => parser.parse('note huh A: hello')).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,616 @@
|
||||||
|
define(() => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/* jshint -W071 */ // TODO: break up rendering logic
|
||||||
|
|
||||||
|
function empty(node) {
|
||||||
|
while(node.childNodes.length > 0) {
|
||||||
|
node.removeChild(node.lastChild);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeSets(target, b) {
|
||||||
|
if(!b) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for(let i = 0; i < b.length; ++ i) {
|
||||||
|
if(target.indexOf(b[i]) === -1) {
|
||||||
|
target.push(b[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAll(target, b) {
|
||||||
|
if(!b) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for(let i = 0; i < b.length; ++ i) {
|
||||||
|
const p = target.indexOf(b[i]);
|
||||||
|
if(p !== -1) {
|
||||||
|
target.splice(p, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const NS = 'http://www.w3.org/2000/svg';
|
||||||
|
|
||||||
|
const LINE_HEIGHT = 1.3;
|
||||||
|
const TITLE_MARGIN = 10;
|
||||||
|
const OUTER_MARGIN = 5;
|
||||||
|
const BOX_PADDING = 10;
|
||||||
|
const AGENT_MARGIN = 10;
|
||||||
|
const AGENT_CROSS_SIZE = 20;
|
||||||
|
const AGENT_NONE_HEIGHT = 10;
|
||||||
|
const ACTION_MARGIN = 5;
|
||||||
|
const ARROW_HEIGHT = 8;
|
||||||
|
const ARROW_POINT = 4;
|
||||||
|
const ARROW_LABEL_PADDING = 6;
|
||||||
|
const ARROW_LABEL_MASK_PADDING = 3;
|
||||||
|
const ARROW_LABEL_MARGIN_TOP = 2;
|
||||||
|
const ARROW_LABEL_MARGIN_BOTTOM = 1;
|
||||||
|
|
||||||
|
const ATTRS = {
|
||||||
|
TITLE: {
|
||||||
|
'font-family': 'sans-serif',
|
||||||
|
'font-size': 20,
|
||||||
|
'class': 'title',
|
||||||
|
},
|
||||||
|
|
||||||
|
AGENT_LINE: {
|
||||||
|
'fill': 'none',
|
||||||
|
'stroke': '#000000',
|
||||||
|
'stroke-width': 1,
|
||||||
|
},
|
||||||
|
AGENT_BOX: {
|
||||||
|
'fill': '#FFFFFF',
|
||||||
|
'stroke': '#000000',
|
||||||
|
'stroke-width': 1,
|
||||||
|
'height': 24,
|
||||||
|
},
|
||||||
|
AGENT_BOX_LABEL: {
|
||||||
|
'font-family': 'sans-serif',
|
||||||
|
'font-size': 12,
|
||||||
|
},
|
||||||
|
AGENT_CROSS: {
|
||||||
|
'fill': 'none',
|
||||||
|
'stroke': '#000000',
|
||||||
|
'stroke-width': 1,
|
||||||
|
},
|
||||||
|
AGENT_BAR: {
|
||||||
|
'fill': '#000000',
|
||||||
|
'height': 5,
|
||||||
|
},
|
||||||
|
|
||||||
|
ARROW_LINE_SOLID: {
|
||||||
|
'fill': 'none',
|
||||||
|
'stroke': '#000000',
|
||||||
|
'stroke-width': 1,
|
||||||
|
},
|
||||||
|
ARROW_LINE_DASH: {
|
||||||
|
'fill': 'none',
|
||||||
|
'stroke': '#000000',
|
||||||
|
'stroke-width': 1,
|
||||||
|
'stroke-dasharray': '2, 2',
|
||||||
|
},
|
||||||
|
ARROW_LABEL: {
|
||||||
|
'font-family': 'sans-serif',
|
||||||
|
'font-size': 8,
|
||||||
|
},
|
||||||
|
ARROW_LABEL_MASK: {
|
||||||
|
'fill': '#FFFFFF',
|
||||||
|
},
|
||||||
|
ARROW_HEAD: {
|
||||||
|
'fill': '#000000',
|
||||||
|
'stroke': '#000000',
|
||||||
|
'stroke-width': 1,
|
||||||
|
'stroke-linejoin': 'miter',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeText(text = '') {
|
||||||
|
return document.createTextNode(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSVGNode(type, attrs = {}) {
|
||||||
|
const o = document.createElementNS(NS, type);
|
||||||
|
for(let k in attrs) {
|
||||||
|
if(attrs.hasOwnProperty(k)) {
|
||||||
|
o.setAttribute(k, attrs[k]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
function traverse(stages, fn) {
|
||||||
|
stages.forEach((stage) => {
|
||||||
|
fn(stage);
|
||||||
|
if(stage.type === 'block') {
|
||||||
|
stage.sections.forEach((section) => {
|
||||||
|
traverse(section.stages, fn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return class Renderer {
|
||||||
|
constructor() {
|
||||||
|
this.base = makeSVGNode('svg', {
|
||||||
|
'xmlns': NS,
|
||||||
|
'version': '1.1',
|
||||||
|
'width': '100%',
|
||||||
|
'height': '100%',
|
||||||
|
});
|
||||||
|
|
||||||
|
this.title = makeSVGNode('text', Object.assign({
|
||||||
|
'y': ATTRS.TITLE['font-size'] + OUTER_MARGIN,
|
||||||
|
}, ATTRS.TITLE));
|
||||||
|
this.titleText = makeText();
|
||||||
|
this.title.appendChild(this.titleText);
|
||||||
|
this.base.appendChild(this.title);
|
||||||
|
|
||||||
|
this.diagram = makeSVGNode('g');
|
||||||
|
this.agentLines = makeSVGNode('g');
|
||||||
|
this.agentDecor = makeSVGNode('g');
|
||||||
|
this.actions = makeSVGNode('g');
|
||||||
|
this.diagram.appendChild(this.agentLines);
|
||||||
|
this.diagram.appendChild(this.agentDecor);
|
||||||
|
this.diagram.appendChild(this.actions);
|
||||||
|
this.base.appendChild(this.diagram);
|
||||||
|
|
||||||
|
this.renderAgentCap = {
|
||||||
|
'box': this.renderAgentCapBox.bind(this),
|
||||||
|
'cross': this.renderAgentCapCross.bind(this),
|
||||||
|
'bar': this.renderAgentCapBar.bind(this),
|
||||||
|
'none': this.renderAgentCapNone.bind(this),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.renderAction = {
|
||||||
|
'agent begin': this.renderAgentBegin.bind(this),
|
||||||
|
'agent end': this.renderAgentEnd.bind(this),
|
||||||
|
'->': this.renderArrow.bind(this, {
|
||||||
|
lineAttrs: ATTRS.ARROW_LINE_SOLID, left: false, right: true,
|
||||||
|
}),
|
||||||
|
'<-': this.renderArrow.bind(this, {
|
||||||
|
lineAttrs: ATTRS.ARROW_LINE_SOLID, left: true, right: false,
|
||||||
|
}),
|
||||||
|
'<->': this.renderArrow.bind(this, {
|
||||||
|
lineAttrs: ATTRS.ARROW_LINE_SOLID, left: true, right: true,
|
||||||
|
}),
|
||||||
|
'-->': this.renderArrow.bind(this, {
|
||||||
|
lineAttrs: ATTRS.ARROW_LINE_DASH, left: false, right: true,
|
||||||
|
}),
|
||||||
|
'<--': this.renderArrow.bind(this, {
|
||||||
|
lineAttrs: ATTRS.ARROW_LINE_DASH, left: true, right: false,
|
||||||
|
}),
|
||||||
|
'<-->': this.renderArrow.bind(this, {
|
||||||
|
lineAttrs: ATTRS.ARROW_LINE_DASH, left: true, right: true,
|
||||||
|
}),
|
||||||
|
'block': this.renderBlock.bind(this),
|
||||||
|
'note over': this.renderNoteOver.bind(this),
|
||||||
|
'note left': this.renderNoteLeft.bind(this),
|
||||||
|
'note right': this.renderNoteRight.bind(this),
|
||||||
|
'note between': this.renderNoteBetween.bind(this),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.separationAction = {
|
||||||
|
'agent begin': this.separationAgentCap.bind(this),
|
||||||
|
'agent end': this.separationAgentCap.bind(this),
|
||||||
|
'->': this.separationArrow.bind(this),
|
||||||
|
'<-': this.separationArrow.bind(this),
|
||||||
|
'<->': this.separationArrow.bind(this),
|
||||||
|
'-->': this.separationArrow.bind(this),
|
||||||
|
'<--': this.separationArrow.bind(this),
|
||||||
|
'<-->': this.separationArrow.bind(this),
|
||||||
|
'block': this.separationBlock.bind(this),
|
||||||
|
'note over': this.separationNoteOver.bind(this),
|
||||||
|
'note left': this.separationNoteLeft.bind(this),
|
||||||
|
'note right': this.separationNoteRight.bind(this),
|
||||||
|
'note between': this.separationNoteBetween.bind(this),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.width = 0;
|
||||||
|
this.height = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
addSeparation(agentInfo, agent, dist) {
|
||||||
|
let d = agentInfo.separations.get(agent) || 0;
|
||||||
|
agentInfo.separations.set(agent, Math.max(d, dist));
|
||||||
|
}
|
||||||
|
|
||||||
|
separationAgentCap(agentInfos, stage) {
|
||||||
|
switch(stage.mode) {
|
||||||
|
case 'box':
|
||||||
|
case 'bar':
|
||||||
|
stage.agents.forEach((agent1) => {
|
||||||
|
const info1 = agentInfos.get(agent1);
|
||||||
|
const sep1 = (
|
||||||
|
info1.labelWidth / 2 +
|
||||||
|
AGENT_MARGIN
|
||||||
|
);
|
||||||
|
stage.agents.forEach((agent2) => {
|
||||||
|
if(agent1 === agent2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const info2 = agentInfos.get(agent2);
|
||||||
|
this.addSeparation(info1, agent2,
|
||||||
|
sep1 + info2.labelWidth / 2
|
||||||
|
);
|
||||||
|
});
|
||||||
|
this.visibleAgents.forEach((agent2) => {
|
||||||
|
if(stage.agents.indexOf(agent2) === -1) {
|
||||||
|
const info2 = agentInfos.get(agent2);
|
||||||
|
this.addSeparation(info1, agent2, sep1);
|
||||||
|
this.addSeparation(info2, agent1, sep1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'cross':
|
||||||
|
stage.agents.forEach((agent1) => {
|
||||||
|
const info1 = agentInfos.get(agent1);
|
||||||
|
const sep1 = (
|
||||||
|
AGENT_CROSS_SIZE / 2 +
|
||||||
|
AGENT_MARGIN
|
||||||
|
);
|
||||||
|
stage.agents.forEach((agent2) => {
|
||||||
|
if(agent1 === agent2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.addSeparation(info1, agent2,
|
||||||
|
sep1 + AGENT_CROSS_SIZE / 2
|
||||||
|
);
|
||||||
|
});
|
||||||
|
this.visibleAgents.forEach((agent2) => {
|
||||||
|
if(stage.agents.indexOf(agent2) === -1) {
|
||||||
|
const info2 = agentInfos.get(agent2);
|
||||||
|
this.addSeparation(info1, agent2, sep1);
|
||||||
|
this.addSeparation(info2, agent1, sep1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if(stage.type === 'agent begin') {
|
||||||
|
mergeSets(this.visibleAgents, stage.agents);
|
||||||
|
} else if(stage.type === 'agent end') {
|
||||||
|
removeAll(this.visibleAgents, stage.agents);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
separationArrow(agentInfos, stage) {
|
||||||
|
const w = (
|
||||||
|
this.testTextWidth(this.testArrowWidth, stage.label) +
|
||||||
|
ARROW_POINT * 2 +
|
||||||
|
ARROW_LABEL_PADDING * 2 +
|
||||||
|
ATTRS.AGENT_LINE['stroke-width']
|
||||||
|
);
|
||||||
|
const agent1 = stage.agents[0];
|
||||||
|
const agent2 = stage.agents[1];
|
||||||
|
this.addSeparation(agentInfos.get(agent1), agent2, w);
|
||||||
|
this.addSeparation(agentInfos.get(agent2), agent1, w);
|
||||||
|
}
|
||||||
|
|
||||||
|
separationBlock(/*agentInfos, stage*/) {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
separationNoteOver(/*agentInfos, stage*/) {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
separationNoteLeft(/*agentInfos, stage*/) {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
separationNoteRight(/*agentInfos, stage*/) {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
separationNoteBetween(/*agentInfos, stage*/) {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
checkSeparation(agentInfos, stage) {
|
||||||
|
this.separationAction[stage.type](agentInfos, stage);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderAgentCapBox({x, labelWidth, label}) {
|
||||||
|
this.agentDecor.appendChild(makeSVGNode('rect', Object.assign({
|
||||||
|
'x': x - labelWidth / 2,
|
||||||
|
'y': this.currentY,
|
||||||
|
'width': labelWidth,
|
||||||
|
}, ATTRS.AGENT_BOX)));
|
||||||
|
|
||||||
|
const name = makeSVGNode('text', Object.assign({
|
||||||
|
'x': x - labelWidth / 2 + BOX_PADDING,
|
||||||
|
'y': this.currentY + (
|
||||||
|
ATTRS.AGENT_BOX.height +
|
||||||
|
ATTRS.AGENT_BOX_LABEL['font-size'] * (2 - LINE_HEIGHT)
|
||||||
|
) / 2,
|
||||||
|
}, ATTRS.AGENT_BOX_LABEL));
|
||||||
|
name.appendChild(makeText(label));
|
||||||
|
this.agentDecor.appendChild(name);
|
||||||
|
|
||||||
|
return {
|
||||||
|
lineTop: 0,
|
||||||
|
lineBottom: ATTRS.AGENT_BOX.height,
|
||||||
|
height: ATTRS.AGENT_BOX.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
renderAgentCapCross({x}) {
|
||||||
|
const y = this.currentY;
|
||||||
|
const d = AGENT_CROSS_SIZE / 2;
|
||||||
|
|
||||||
|
this.agentDecor.appendChild(makeSVGNode('path', Object.assign({
|
||||||
|
'd': (
|
||||||
|
'M ' + (x - d) + ' ' + y +
|
||||||
|
' L ' + (x + d) + ' ' + (y + d * 2) +
|
||||||
|
' M ' + (x + d) + ' ' + y +
|
||||||
|
' L ' + (x - d) + ' ' + (y + d * 2)
|
||||||
|
),
|
||||||
|
}, ATTRS.AGENT_CROSS)));
|
||||||
|
|
||||||
|
return {
|
||||||
|
lineTop: d,
|
||||||
|
lineBottom: d,
|
||||||
|
height: d * 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
renderAgentCapBar({x, labelWidth}) {
|
||||||
|
this.agentDecor.appendChild(makeSVGNode('rect', Object.assign({
|
||||||
|
'x': x - labelWidth / 2,
|
||||||
|
'y': this.currentY,
|
||||||
|
'width': labelWidth,
|
||||||
|
}, ATTRS.AGENT_BAR)));
|
||||||
|
|
||||||
|
return {
|
||||||
|
lineTop: 0,
|
||||||
|
lineBottom: ATTRS.AGENT_BAR.height,
|
||||||
|
height: ATTRS.AGENT_BAR.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
renderAgentCapNone() {
|
||||||
|
return {
|
||||||
|
lineTop: AGENT_NONE_HEIGHT,
|
||||||
|
lineBottom: 0,
|
||||||
|
height: AGENT_NONE_HEIGHT,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
renderAgentBegin(agentInfos, stage) {
|
||||||
|
let shifts = {height: 0};
|
||||||
|
stage.agents.forEach((agent) => {
|
||||||
|
const agentInfo = agentInfos.get(agent);
|
||||||
|
shifts = this.renderAgentCap[stage.mode](agentInfo);
|
||||||
|
agentInfo.latestYStart = this.currentY + shifts.lineBottom;
|
||||||
|
});
|
||||||
|
this.currentY += shifts.height + ACTION_MARGIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderAgentEnd(agentInfos, stage) {
|
||||||
|
let shifts = {height: 0};
|
||||||
|
stage.agents.forEach((agent) => {
|
||||||
|
const agentInfo = agentInfos.get(agent);
|
||||||
|
const x = agentInfo.x;
|
||||||
|
shifts = this.renderAgentCap[stage.mode](agentInfo);
|
||||||
|
this.agentLines.appendChild(makeSVGNode('path', Object.assign({
|
||||||
|
'd': (
|
||||||
|
'M ' + x + ' ' + agentInfo.latestYStart +
|
||||||
|
' L ' + x + ' ' + (this.currentY + shifts.lineTop)
|
||||||
|
),
|
||||||
|
}, ATTRS.AGENT_LINE)));
|
||||||
|
agentInfo.latestYStart = null;
|
||||||
|
});
|
||||||
|
this.currentY += shifts.height + ACTION_MARGIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderArrow({lineAttrs, left, right}, agentInfos, stage) {
|
||||||
|
/* jshint -W074, -W071 */ // TODO: tidy this up
|
||||||
|
const from = agentInfos.get(stage.agents[0]);
|
||||||
|
const to = agentInfos.get(stage.agents[1]);
|
||||||
|
|
||||||
|
const dy = ARROW_HEIGHT / 2;
|
||||||
|
const dx = ARROW_POINT;
|
||||||
|
const dir = (from.x < to.x) ? 1 : -1;
|
||||||
|
const short = ATTRS.AGENT_LINE['stroke-width'];
|
||||||
|
let y = this.currentY;
|
||||||
|
|
||||||
|
if(stage.label) {
|
||||||
|
const mask = makeSVGNode('rect', ATTRS.ARROW_LABEL_MASK);
|
||||||
|
const label = makeSVGNode('text', ATTRS.ARROW_LABEL);
|
||||||
|
label.appendChild(makeText(stage.label));
|
||||||
|
const sz = ATTRS.ARROW_LABEL['font-size'];
|
||||||
|
this.actions.appendChild(mask);
|
||||||
|
this.actions.appendChild(label);
|
||||||
|
y += Math.max(
|
||||||
|
dy,
|
||||||
|
ARROW_LABEL_MARGIN_TOP +
|
||||||
|
sz * LINE_HEIGHT +
|
||||||
|
ARROW_LABEL_MARGIN_BOTTOM
|
||||||
|
);
|
||||||
|
const w = label.getComputedTextLength();
|
||||||
|
const x = (from.x + to.x - w) / 2;
|
||||||
|
const yBase = (
|
||||||
|
y -
|
||||||
|
sz * (LINE_HEIGHT - 1) -
|
||||||
|
ARROW_LABEL_MARGIN_BOTTOM
|
||||||
|
);
|
||||||
|
label.setAttribute('x', x);
|
||||||
|
label.setAttribute('y', yBase);
|
||||||
|
mask.setAttribute('x', x - ARROW_LABEL_MASK_PADDING);
|
||||||
|
mask.setAttribute('y', yBase - sz);
|
||||||
|
mask.setAttribute('width', w + ARROW_LABEL_MASK_PADDING * 2);
|
||||||
|
mask.setAttribute('height', sz * LINE_HEIGHT);
|
||||||
|
} else {
|
||||||
|
y += dy;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.actions.appendChild(makeSVGNode('path', Object.assign({
|
||||||
|
'd': (
|
||||||
|
'M ' + (from.x + (left ? short : 0) * dir) + ' ' + y +
|
||||||
|
' L ' + (to.x - (right ? short : 0) * dir) + ' ' + y
|
||||||
|
),
|
||||||
|
}, lineAttrs)));
|
||||||
|
|
||||||
|
if(left) {
|
||||||
|
this.actions.appendChild(makeSVGNode('path', Object.assign({
|
||||||
|
'd': (
|
||||||
|
'M ' + (from.x + (dx + short) * dir) + ' ' + (y - dy) +
|
||||||
|
' L ' + (from.x + short * dir) + ' ' + y +
|
||||||
|
' L ' + (from.x + (dx + short) * dir) + ' ' + (y + dy) +
|
||||||
|
(ATTRS.ARROW_HEAD.fill === 'none' ? '' : ' Z')
|
||||||
|
),
|
||||||
|
}, ATTRS.ARROW_HEAD)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if(right) {
|
||||||
|
this.actions.appendChild(makeSVGNode('path', Object.assign({
|
||||||
|
'd': (
|
||||||
|
'M ' + (to.x - (dx + short) * dir) + ' ' + (y - dy) +
|
||||||
|
' L ' + (to.x - short * dir) + ' ' + y +
|
||||||
|
' L ' + (to.x - (dx + short) * dir) + ' ' + (y + dy) +
|
||||||
|
(ATTRS.ARROW_HEAD.fill === 'none' ? '' : ' Z')
|
||||||
|
),
|
||||||
|
}, ATTRS.ARROW_HEAD)));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentY = y + dy + ACTION_MARGIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderBlock(/*agentInfos, stage*/) {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
renderNoteOver(/*agentInfos, stage*/) {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
renderNoteLeft(/*agentInfos, stage*/) {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
renderNoteRight(/*agentInfos, stage*/) {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
renderNoteBetween(/*agentInfos, stage*/) {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
addAction(agentInfos, stage) {
|
||||||
|
this.renderAction[stage.type](agentInfos, stage);
|
||||||
|
}
|
||||||
|
|
||||||
|
makeTextTester(attrs) {
|
||||||
|
const text = makeText();
|
||||||
|
const node = makeSVGNode('text', attrs);
|
||||||
|
node.appendChild(text);
|
||||||
|
this.agentDecor.appendChild(node);
|
||||||
|
return {text, node};
|
||||||
|
}
|
||||||
|
|
||||||
|
testTextWidth(tester, text) {
|
||||||
|
tester.text.nodeValue = text;
|
||||||
|
return tester.node.getComputedTextLength();
|
||||||
|
}
|
||||||
|
|
||||||
|
removeTextTester(tester) {
|
||||||
|
this.agentDecor.removeChild(tester.node);
|
||||||
|
}
|
||||||
|
|
||||||
|
buildAgentInfos(agents, stages) {
|
||||||
|
const testNameWidth = this.makeTextTester(ATTRS.AGENT_BOX_LABEL);
|
||||||
|
|
||||||
|
const agentInfos = new Map();
|
||||||
|
agents.forEach((agent) => {
|
||||||
|
agentInfos.set(agent, {
|
||||||
|
label: agent,
|
||||||
|
labelWidth: (
|
||||||
|
this.testTextWidth(testNameWidth, agent) +
|
||||||
|
BOX_PADDING * 2
|
||||||
|
),
|
||||||
|
x: null,
|
||||||
|
latestYStart: null,
|
||||||
|
separations: new Map(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
agentInfos.get('[').labelWidth = 0;
|
||||||
|
agentInfos.get(']').labelWidth = 0;
|
||||||
|
|
||||||
|
this.removeTextTester(testNameWidth);
|
||||||
|
|
||||||
|
this.testArrowWidth = this.makeTextTester(ATTRS.ARROW_LABEL);
|
||||||
|
this.visibleAgents = ['[', ']'];
|
||||||
|
traverse(stages, this.checkSeparation.bind(this, agentInfos));
|
||||||
|
this.removeTextTester(this.testArrowWidth);
|
||||||
|
|
||||||
|
let currentX = 0;
|
||||||
|
agents.forEach((agent) => {
|
||||||
|
const agentInfo = agentInfos.get(agent);
|
||||||
|
agentInfo.separations.forEach((dist, otherAgent) => {
|
||||||
|
const otherAgentInfo = agentInfos.get(otherAgent);
|
||||||
|
if(otherAgentInfo.x !== null) {
|
||||||
|
currentX = Math.max(currentX, otherAgentInfo.x + dist);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
agentInfo.x = currentX;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {agentInfos, minX: 0, maxX: currentX};
|
||||||
|
}
|
||||||
|
|
||||||
|
updateBounds(stagesHeight) {
|
||||||
|
const titleWidth = this.title.getComputedTextLength();
|
||||||
|
const stagesWidth = (this.maxX - this.minX);
|
||||||
|
|
||||||
|
const width = Math.ceil(
|
||||||
|
Math.max(stagesWidth, titleWidth) +
|
||||||
|
OUTER_MARGIN * 2
|
||||||
|
);
|
||||||
|
const height = Math.ceil(
|
||||||
|
ATTRS.TITLE['font-size'] * LINE_HEIGHT +
|
||||||
|
TITLE_MARGIN +
|
||||||
|
stagesHeight +
|
||||||
|
OUTER_MARGIN * 2
|
||||||
|
);
|
||||||
|
|
||||||
|
this.diagram.setAttribute('transform',
|
||||||
|
'translate(' + ((width - stagesWidth) / 2 - this.minX) + ',' + (
|
||||||
|
OUTER_MARGIN +
|
||||||
|
ATTRS.TITLE['font-size'] * LINE_HEIGHT +
|
||||||
|
TITLE_MARGIN
|
||||||
|
) + ')'
|
||||||
|
);
|
||||||
|
|
||||||
|
this.title.setAttribute('x', (width - titleWidth) / 2);
|
||||||
|
this.base.setAttribute('viewBox', '0 0 ' + width + ' ' + height);
|
||||||
|
this.width = width;
|
||||||
|
this.height = height;
|
||||||
|
}
|
||||||
|
|
||||||
|
render({meta, agents, stages}) {
|
||||||
|
empty(this.agentLines);
|
||||||
|
empty(this.agentDecor);
|
||||||
|
empty(this.actions);
|
||||||
|
|
||||||
|
this.titleText.nodeValue = meta.title || '';
|
||||||
|
|
||||||
|
const info = this.buildAgentInfos(agents, stages);
|
||||||
|
|
||||||
|
this.minX = info.minX;
|
||||||
|
this.maxX = info.maxX;
|
||||||
|
this.currentY = 0;
|
||||||
|
|
||||||
|
traverse(stages, this.addAction.bind(this, info.agentInfos));
|
||||||
|
|
||||||
|
this.updateBounds(Math.max(this.currentY - ACTION_MARGIN, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
svg() {
|
||||||
|
return this.base;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
|
@ -0,0 +1,29 @@
|
||||||
|
defineDescribe('Sequence Renderer', ['./Renderer'], (Renderer) => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
let renderer = null;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
renderer = new Renderer();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('.svg', () => {
|
||||||
|
it('returns an SVG node containing the rendered diagram', () => {
|
||||||
|
const svg = renderer.svg();
|
||||||
|
expect(svg.tagName).toEqual('svg');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('.render', () => {
|
||||||
|
it('populates the SVG with content', () => {
|
||||||
|
renderer.render({
|
||||||
|
meta: {title: 'Title'},
|
||||||
|
agents: ['[', 'Col 1', 'Col 2', ']'],
|
||||||
|
stages: [],
|
||||||
|
});
|
||||||
|
const element = renderer.svg();
|
||||||
|
const title = element.getElementsByClassName('title')[0];
|
||||||
|
expect(title.innerHTML).toEqual('Title');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,6 @@
|
||||||
|
define([
|
||||||
|
'interface/Interface_spec',
|
||||||
|
'sequence/Parser_spec',
|
||||||
|
'sequence/Generator_spec',
|
||||||
|
'sequence/Renderer_spec',
|
||||||
|
]);
|
|
@ -0,0 +1,14 @@
|
||||||
|
define([], () => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
return function(container, options) {
|
||||||
|
const spy = jasmine.createSpyObj('CodeMirror', ['on']);
|
||||||
|
spy.constructor = {
|
||||||
|
container,
|
||||||
|
options,
|
||||||
|
};
|
||||||
|
spy.doc = jasmine.createSpyObj('CodeMirror document', ['getValue']);
|
||||||
|
spy.getDoc = () => spy.doc;
|
||||||
|
return spy;
|
||||||
|
};
|
||||||
|
});
|
|
@ -0,0 +1,99 @@
|
||||||
|
define(['jshintConfig', 'specs'], (jshintConfig) => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/* global JSHINT */
|
||||||
|
|
||||||
|
// Thanks, https://opensoul.org/2011/02/20/jslint-and-jasmine/
|
||||||
|
|
||||||
|
const extraFiles = [
|
||||||
|
'scripts/main.js',
|
||||||
|
'scripts/requireConfig.js',
|
||||||
|
];
|
||||||
|
|
||||||
|
const PREDEF = [
|
||||||
|
'self',
|
||||||
|
'define',
|
||||||
|
'requirejs',
|
||||||
|
];
|
||||||
|
|
||||||
|
const PREDEF_TEST = [
|
||||||
|
'jasmine',
|
||||||
|
'beforeEach',
|
||||||
|
'afterEach',
|
||||||
|
'spyOn',
|
||||||
|
'describe',
|
||||||
|
'defineDescribe',
|
||||||
|
'it',
|
||||||
|
'expect',
|
||||||
|
'fail',
|
||||||
|
].concat(PREDEF);
|
||||||
|
|
||||||
|
function formatError(error) {
|
||||||
|
const evidence = (error.evidence || '').replace(/\t/g, ' ');
|
||||||
|
if(error.code === 'W140') {
|
||||||
|
// Don't warn about lack of trailing comma for inline objects/lists
|
||||||
|
const c = evidence.charAt(error.character - 1);
|
||||||
|
if(c === ']' || c === '}') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
error.code +
|
||||||
|
' @' + error.line + ':' + error.character +
|
||||||
|
': ' + error.reason +
|
||||||
|
'\n' + evidence
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lintTestName = 'conforms to the linter';
|
||||||
|
|
||||||
|
// Until browsers support cache: 'no-cache'
|
||||||
|
const noCacheSuffix = '?' + Math.random();
|
||||||
|
|
||||||
|
function check(path, src) {
|
||||||
|
describe(path, () => it(lintTestName, () => {
|
||||||
|
const test = (
|
||||||
|
path.endsWith('_spec.js') ||
|
||||||
|
path.endsWith('jshintRunner.js') ||
|
||||||
|
path.endsWith('specRunner.js') ||
|
||||||
|
path.includes('/stubs/')
|
||||||
|
);
|
||||||
|
const predef = test ? PREDEF_TEST : PREDEF;
|
||||||
|
JSHINT(src, Object.assign({predef}, jshintConfig));
|
||||||
|
(JSHINT.errors
|
||||||
|
.map(formatError)
|
||||||
|
.filter((error) => (error !== null))
|
||||||
|
.forEach(fail));
|
||||||
|
JSHINT.errors.length = 0;
|
||||||
|
expect(true).toBe(true); // prevent no-expectation warning
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimPath(path) {
|
||||||
|
if(path.indexOf('?') !== -1) {
|
||||||
|
return path.substr(0, path.indexOf('?'));
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSource(path) {
|
||||||
|
return fetch(path + noCacheSuffix, {
|
||||||
|
mode: 'no-cors',
|
||||||
|
cache: 'no-cache',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const scriptElements = document.getElementsByTagName('script');
|
||||||
|
return Promise.all(Array.prototype.map.call(scriptElements,
|
||||||
|
(scriptElement) => scriptElement.getAttribute('src'))
|
||||||
|
.concat(extraFiles)
|
||||||
|
.filter((path) => !path.includes('://'))
|
||||||
|
.map(trimPath)
|
||||||
|
.map((path) => (getSource(path)
|
||||||
|
.then((response) => response.text())
|
||||||
|
.then((src) => check(path, src))
|
||||||
|
))
|
||||||
|
).catch(() => describe('source code', () => it(lintTestName, () => {
|
||||||
|
fail('Failed to run linter against source code');
|
||||||
|
})));
|
||||||
|
});
|
|
@ -0,0 +1,31 @@
|
||||||
|
((() => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Jasmine test configuration.
|
||||||
|
// See specs.js for the list of spec files
|
||||||
|
|
||||||
|
requirejs.config(Object.assign({
|
||||||
|
baseUrl: 'scripts/',
|
||||||
|
urlArgs: String(Math.random()), // Prevent cache
|
||||||
|
}, window.getRequirejsCDN()));
|
||||||
|
|
||||||
|
requirejs(['jasmineBoot'], () => {
|
||||||
|
// Slightly hacky way of making jasmine work with asynchronously loaded
|
||||||
|
// tests while keeping features of jasmine-boot
|
||||||
|
const runner = window.onload;
|
||||||
|
window.onload = undefined;
|
||||||
|
|
||||||
|
// Convenience function wraps:
|
||||||
|
// define(['d1', 'd2'], (d1, d2) => describe('My Name', () => {...}));
|
||||||
|
// as:
|
||||||
|
// defineDescribe('My Name', ['d1', 'd2'], (d1, d2) => {...});
|
||||||
|
window.defineDescribe = (name, deps, fn) => {
|
||||||
|
define(deps, function() {
|
||||||
|
const args = arguments;
|
||||||
|
describe(name, () => fn.apply(null, args));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
requirejs(['tester/jshintRunner'], (promise) => promise.then(runner));
|
||||||
|
});
|
||||||
|
})());
|
|
@ -0,0 +1,59 @@
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane-code {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 30%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane-code .CodeMirror {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: #EEEEEE;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane-view {
|
||||||
|
position: absolute;
|
||||||
|
left: 30%;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.options {
|
||||||
|
display: inline-block;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
background: #FFFFFF;
|
||||||
|
border-top-left-radius: 5px;
|
||||||
|
border-top: 1px solid #EEEEEE;
|
||||||
|
border-left: 1px solid #EEEEEE;
|
||||||
|
font-family: sans-serif;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.options a {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 5px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.options a:not(:last-child) {
|
||||||
|
border-right: 1px solid #EEEEEE;
|
||||||
|
}
|
||||||
|
|
||||||
|
.options a:link, .options a:visited {
|
||||||
|
color: #666699;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.options a:active, .options a:hover {
|
||||||
|
background: #EEEEEE;
|
||||||
|
color: #6666CC;
|
||||||
|
}
|
|
@ -0,0 +1,67 @@
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta http-equiv="content-security-policy" content="
|
||||||
|
base-uri 'self';
|
||||||
|
default-src 'none';
|
||||||
|
script-src
|
||||||
|
'self'
|
||||||
|
'sha256-0SGl1PJNDyJwcV5T+weg2zpEMrh7xvlwO4oXgvZCeZk='
|
||||||
|
'sha256-nbDuV0lauU6Rzhc3T39vSmQ64+K0R8Kp556x6W5Xxg4='
|
||||||
|
'sha256-3t+j0EiiLhROsCHCLF+g/h4gPsyH5agBoZeAcOrydRM='
|
||||||
|
'sha256-Re9XxIL3x1flvE6WD58jWPdDzKYQLXwxS2HAVfmM6Z8='
|
||||||
|
'sha256-SYY59VkHlf1wmU3zhsnII/kb51ODoHy6ub8qaq0eAcY='
|
||||||
|
;
|
||||||
|
connect-src 'self';
|
||||||
|
style-src 'self' https://cdnjs.cloudflare.com;
|
||||||
|
img-src 'self' data: blob:;
|
||||||
|
form-action 'none';
|
||||||
|
">
|
||||||
|
|
||||||
|
<title>Tests</title>
|
||||||
|
<link rel="icon" href="favicon.png">
|
||||||
|
|
||||||
|
<link rel="stylesheet"
|
||||||
|
href="https://cdnjs.cloudflare.com/ajax/libs/jasmine/2.8.0/jasmine.min.css"
|
||||||
|
integrity="sha256-VrOKUZ4Ge9kapNTEARs4xFN+//KC1DdjdOSRMqiHw8s="
|
||||||
|
crossorigin="anonymous"
|
||||||
|
>
|
||||||
|
<script
|
||||||
|
src="https://cdnjs.cloudflare.com/ajax/libs/jshint/2.9.5/jshint.min.js"
|
||||||
|
integrity="sha256-SYY59VkHlf1wmU3zhsnII/kb51ODoHy6ub8qaq0eAcY="
|
||||||
|
crossorigin="anonymous"
|
||||||
|
></script>
|
||||||
|
<script
|
||||||
|
src="https://cdnjs.cloudflare.com/ajax/libs/jasmine/2.8.0/jasmine.min.js"
|
||||||
|
integrity="sha256-nbDuV0lauU6Rzhc3T39vSmQ64+K0R8Kp556x6W5Xxg4="
|
||||||
|
crossorigin="anonymous"
|
||||||
|
></script>
|
||||||
|
<script
|
||||||
|
src="https://cdnjs.cloudflare.com/ajax/libs/jasmine/2.8.0/jasmine-html.min.js"
|
||||||
|
integrity="sha256-3t+j0EiiLhROsCHCLF+g/h4gPsyH5agBoZeAcOrydRM="
|
||||||
|
crossorigin="anonymous"
|
||||||
|
></script>
|
||||||
|
|
||||||
|
<script src="scripts/requireConfig.js"></script>
|
||||||
|
|
||||||
|
<script
|
||||||
|
data-main="scripts/tester/specRunner"
|
||||||
|
src="https://cdnjs.cloudflare.com/ajax/libs/require.js/2.3.5/require.min.js"
|
||||||
|
integrity="sha256-0SGl1PJNDyJwcV5T+weg2zpEMrh7xvlwO4oXgvZCeZk="
|
||||||
|
crossorigin="anonymous"
|
||||||
|
></script>
|
||||||
|
|
||||||
|
<meta
|
||||||
|
name="cdn-jasmineBoot"
|
||||||
|
content="https://cdnjs.cloudflare.com/ajax/libs/jasmine/2.8.0/boot.min.js"
|
||||||
|
data-integrity="sha256-Re9XxIL3x1flvE6WD58jWPdDzKYQLXwxS2HAVfmM6Z8="
|
||||||
|
>
|
||||||
|
|
||||||
|
<meta name="cdn-codemirror" content="stubs/codemirror">
|
||||||
|
|
||||||
|
<!-- test files defined in scripts/specs.js -->
|
||||||
|
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
</body>
|
||||||
|
</html>
|
Loading…
Reference in New Issue