blob: d029475095ac79b90d2205b41e8a9c40a2024188 [file] [log] [blame]
"use strict";
/*
Railroad Diagrams
by Tab Atkins Jr. (and others)
http://xanthir.com
http://twitter.com/tabatkins
http://github.com/tabatkins/railroad-diagrams
This document and all associated files in the github project are licensed under CC0: http://creativecommons.org/publicdomain/zero/1.0/
This means you can reuse, remix, or otherwise appropriate this project for your own use WITHOUT RESTRICTION.
(The actual legal meaning can be found at the above link.)
Don't ask me for permission to use any part of this project, JUST USE IT.
I would appreciate attribution, but that is not required by the license.
*/
// Export function versions of all the constructors.
// Each class will add itself to this object.
const funcs = {};
export default funcs;
export const Options = {
DEBUG: false, // if true, writes some debug information into attributes
VS: 8, // minimum vertical separation between things. For a 3px stroke, must be at least 4
AR: 10, // radius of arcs
DIAGRAM_CLASS: 'railroad-diagram', // class to put on the root <svg>
STROKE_ODD_PIXEL_LENGTH: true, // is the stroke width an odd (1px, 3px, etc) pixel length?
INTERNAL_ALIGNMENT: 'center', // how to align items when they have extra space. left/right/center
CHAR_WIDTH: 8.5, // width of each monospace character. play until you find the right value for your font
COMMENT_CHAR_WIDTH: 7, // comments are in smaller text by default
};
export const defaultCSS = `
svg {
background-color: hsl(30,20%,95%);
}
path {
stroke-width: 3;
stroke: black;
fill: rgba(0,0,0,0);
}
text {
font: bold 14px monospace;
text-anchor: middle;
white-space: pre;
}
text.diagram-text {
font-size: 12px;
}
text.diagram-arrow {
font-size: 16px;
}
text.label {
text-anchor: start;
}
text.comment {
font: italic 12px monospace;
}
g.non-terminal text {
/*font-style: italic;*/
}
rect {
stroke-width: 3;
stroke: black;
fill: hsl(120,100%,90%);
}
rect.group-box {
stroke: gray;
stroke-dasharray: 10 5;
fill: none;
}
path.diagram-text {
stroke-width: 3;
stroke: black;
fill: white;
cursor: help;
}
g.diagram-text:hover path.diagram-text {
fill: #eee;
}`;
export class FakeSVG {
constructor(tagName, attrs, text) {
if(text) this.children = text;
else this.children = [];
this.tagName = tagName;
this.attrs = unnull(attrs, {});
}
format(x, y, width) {
// Virtual
}
addTo(parent) {
if(parent instanceof FakeSVG) {
parent.children.push(this);
return this;
} else {
var svg = this.toSVG();
parent.appendChild(svg);
return svg;
}
}
toSVG() {
var el = SVG(this.tagName, this.attrs);
if(typeof this.children == 'string') {
el.textContent = this.children;
} else {
this.children.forEach(function(e) {
el.appendChild(e.toSVG());
});
}
return el;
}
toString() {
var str = '<' + this.tagName;
var group = this.tagName == "g" || this.tagName == "svg";
for(var attr in this.attrs) {
str += ' ' + attr + '="' + (this.attrs[attr]+'').replace(/&/g, '&amp;').replace(/"/g, '&quot;') + '"';
}
str += '>';
if(group) str += "\n";
if(typeof this.children == 'string') {
str += escapeString(this.children);
} else {
this.children.forEach(function(e) {
str += e;
});
}
str += '</' + this.tagName + '>\n';
return str;
}
walk(cb) {
cb(this);
}
}
export class Path extends FakeSVG {
constructor(x,y) {
super('path');
this.attrs.d = "M"+x+' '+y;
}
m(x,y) {
this.attrs.d += 'm'+x+' '+y;
return this;
}
h(val) {
this.attrs.d += 'h'+val;
return this;
}
right(val) { return this.h(Math.max(0, val)); }
left(val) { return this.h(-Math.max(0, val)); }
v(val) {
this.attrs.d += 'v'+val;
return this;
}
down(val) { return this.v(Math.max(0, val)); }
up(val) { return this.v(-Math.max(0, val)); }
arc(sweep){
// 1/4 of a circle
var x = Options.AR;
var y = Options.AR;
if(sweep[0] == 'e' || sweep[1] == 'w') {
x *= -1;
}
if(sweep[0] == 's' || sweep[1] == 'n') {
y *= -1;
}
var cw;
if(sweep == 'ne' || sweep == 'es' || sweep == 'sw' || sweep == 'wn') {
cw = 1;
} else {
cw = 0;
}
this.attrs.d += "a"+Options.AR+" "+Options.AR+" 0 0 "+cw+' '+x+' '+y;
return this;
}
arc_8(start, dir) {
// 1/8 of a circle
const arc = Options.AR;
const s2 = 1/Math.sqrt(2) * arc;
const s2inv = (arc - s2);
let path = "a " + arc + " " + arc + " 0 0 " + (dir=='cw' ? "1" : "0") + " ";
const sd = start+dir;
const offset =
sd == 'ncw' ? [s2, s2inv] :
sd == 'necw' ? [s2inv, s2] :
sd == 'ecw' ? [-s2inv, s2] :
sd == 'secw' ? [-s2, s2inv] :
sd == 'scw' ? [-s2, -s2inv] :
sd == 'swcw' ? [-s2inv, -s2] :
sd == 'wcw' ? [s2inv, -s2] :
sd == 'nwcw' ? [s2, -s2inv] :
sd == 'nccw' ? [-s2, s2inv] :
sd == 'nwccw' ? [-s2inv, s2] :
sd == 'wccw' ? [s2inv, s2] :
sd == 'swccw' ? [s2, s2inv] :
sd == 'sccw' ? [s2, -s2inv] :
sd == 'seccw' ? [s2inv, -s2] :
sd == 'eccw' ? [-s2inv, -s2] :
sd == 'neccw' ? [-s2, -s2inv] : null
;
path += offset.join(" ");
this.attrs.d += path;
return this;
}
l(x, y) {
this.attrs.d += 'l'+x+' '+y;
return this;
}
format() {
// All paths in this library start/end horizontally.
// The extra .5 ensures a minor overlap, so there's no seams in bad rasterizers.
this.attrs.d += 'h.5';
return this;
}
}
export class DiagramMultiContainer extends FakeSVG {
constructor(tagName, items, attrs, text) {
super(tagName, attrs, text);
this.items = items.map(wrapString);
}
walk(cb) {
cb(this);
this.items.forEach(x=>x.walk(cb));
}
}
export class Diagram extends DiagramMultiContainer {
constructor(...items) {
super('svg', items, {class: Options.DIAGRAM_CLASS});
if(!(this.items[0] instanceof Start)) {
this.items.unshift(new Start());
}
if(!(this.items[this.items.length-1] instanceof End)) {
this.items.push(new End());
}
this.up = this.down = this.height = this.width = 0;
for(const item of this.items) {
this.width += item.width + (item.needsSpace?20:0);
this.up = Math.max(this.up, item.up - this.height);
this.height += item.height;
this.down = Math.max(this.down - item.height, item.down);
}
this.formatted = false;
}
format(paddingt, paddingr, paddingb, paddingl) {
paddingt = unnull(paddingt, 20);
paddingr = unnull(paddingr, paddingt, 20);
paddingb = unnull(paddingb, paddingt, 20);
paddingl = unnull(paddingl, paddingr, 20);
var x = paddingl;
var y = paddingt;
y += this.up;
var g = new FakeSVG('g', Options.STROKE_ODD_PIXEL_LENGTH ? {transform:'translate(.5 .5)'} : {});
for(var i = 0; i < this.items.length; i++) {
var item = this.items[i];
if(item.needsSpace) {
new Path(x,y).h(10).addTo(g);
x += 10;
}
item.format(x, y, item.width).addTo(g);
x += item.width;
y += item.height;
if(item.needsSpace) {
new Path(x,y).h(10).addTo(g);
x += 10;
}
}
this.attrs.width = this.width + paddingl + paddingr;
this.attrs.height = this.up + this.height + this.down + paddingt + paddingb;
this.attrs.viewBox = "0 0 " + this.attrs.width + " " + this.attrs.height;
g.addTo(this);
this.formatted = true;
return this;
}
addTo(parent) {
if(!parent) {
var scriptTag = document.getElementsByTagName('script');
scriptTag = scriptTag[scriptTag.length - 1];
parent = scriptTag.parentNode;
}
return super.addTo.call(this, parent);
}
toSVG() {
if(!this.formatted) {
this.format();
}
return super.toSVG.call(this);
}
toString() {
if(!this.formatted) {
this.format();
}
return super.toString.call(this);
}
toStandalone(style) {
if(!this.formatted) {
this.format();
}
const s = new FakeSVG('style', {}, style || defaultCSS);
this.children.push(s);
this.attrs.xmlns = "http://www.w3.org/2000/svg";
this.attrs['xmlns:xlink'] = "http://www.w3.org/1999/xlink";
const result = super.toString.call(this);
this.children.pop();
delete this.attrs.xmlns;
return result;
}
}
funcs.Diagram = (...args)=>new Diagram(...args);
export class ComplexDiagram extends FakeSVG {
constructor(...items) {
var diagram = new Diagram(...items);
diagram.items[0] = new Start({type:"complex"});
diagram.items[diagram.items.length-1] = new End({type:"complex"});
return diagram;
}
}
funcs.ComplexDiagram = (...args)=>new ComplexDiagram(...args);
export class Sequence extends DiagramMultiContainer {
constructor(...items) {
super('g', items);
var numberOfItems = this.items.length;
this.needsSpace = true;
this.up = this.down = this.height = this.width = 0;
for(var i = 0; i < this.items.length; i++) {
var item = this.items[i];
this.width += item.width + (item.needsSpace?20:0);
this.up = Math.max(this.up, item.up - this.height);
this.height += item.height;
this.down = Math.max(this.down - item.height, item.down);
}
if(this.items[0].needsSpace) this.width -= 10;
if(this.items[this.items.length-1].needsSpace) this.width -= 10;
if(Options.DEBUG) {
this.attrs['data-updown'] = this.up + " " + this.height + " " + this.down;
this.attrs['data-type'] = "sequence";
}
}
format(x,y,width) {
// Hook up the two sides if this is narrower than its stated width.
var gaps = determineGaps(width, this.width);
new Path(x,y).h(gaps[0]).addTo(this);
new Path(x+gaps[0]+this.width,y+this.height).h(gaps[1]).addTo(this);
x += gaps[0];
for(var i = 0; i < this.items.length; i++) {
var item = this.items[i];
if(item.needsSpace && i > 0) {
new Path(x,y).h(10).addTo(this);
x += 10;
}
item.format(x, y, item.width).addTo(this);
x += item.width;
y += item.height;
if(item.needsSpace && i < this.items.length-1) {
new Path(x,y).h(10).addTo(this);
x += 10;
}
}
return this;
}
}
funcs.Sequence = (...args)=>new Sequence(...args);
export class Stack extends DiagramMultiContainer {
constructor(...items) {
super('g', items);
if( items.length === 0 ) {
throw new RangeError("Stack() must have at least one child.");
}
this.width = Math.max.apply(null, this.items.map(function(e) { return e.width + (e.needsSpace?20:0); }));
//if(this.items[0].needsSpace) this.width -= 10;
//if(this.items[this.items.length-1].needsSpace) this.width -= 10;
if(this.items.length > 1){
this.width += Options.AR*2;
}
this.needsSpace = true;
this.up = this.items[0].up;
this.down = this.items[this.items.length-1].down;
this.height = 0;
var last = this.items.length - 1;
for(var i = 0; i < this.items.length; i++) {
var item = this.items[i];
this.height += item.height;
if(i > 0) {
this.height += Math.max(Options.AR*2, item.up + Options.VS);
}
if(i < last) {
this.height += Math.max(Options.AR*2, item.down + Options.VS);
}
}
if(Options.DEBUG) {
this.attrs['data-updown'] = this.up + " " + this.height + " " + this.down;
this.attrs['data-type'] = "stack";
}
}
format(x,y,width) {
var gaps = determineGaps(width, this.width);
new Path(x,y).h(gaps[0]).addTo(this);
x += gaps[0];
var xInitial = x;
if(this.items.length > 1) {
new Path(x, y).h(Options.AR).addTo(this);
x += Options.AR;
}
for(var i = 0; i < this.items.length; i++) {
var item = this.items[i];
var innerWidth = this.width - (this.items.length>1 ? Options.AR*2 : 0);
item.format(x, y, innerWidth).addTo(this);
x += innerWidth;
y += item.height;
if(i !== this.items.length-1) {
new Path(x, y)
.arc('ne').down(Math.max(0, item.down + Options.VS - Options.AR*2))
.arc('es').left(innerWidth)
.arc('nw').down(Math.max(0, this.items[i+1].up + Options.VS - Options.AR*2))
.arc('ws').addTo(this);
y += Math.max(item.down + Options.VS, Options.AR*2) + Math.max(this.items[i+1].up + Options.VS, Options.AR*2);
//y += Math.max(Options.AR*4, item.down + Options.VS*2 + this.items[i+1].up)
x = xInitial+Options.AR;
}
}
if(this.items.length > 1) {
new Path(x,y).h(Options.AR).addTo(this);
x += Options.AR;
}
new Path(x,y).h(gaps[1]).addTo(this);
return this;
}
}
funcs.Stack = (...args)=>new Stack(...args);
export class OptionalSequence extends DiagramMultiContainer {
constructor(...items) {
super('g', items);
if( items.length === 0 ) {
throw new RangeError("OptionalSequence() must have at least one child.");
}
if( items.length === 1 ) {
return new Sequence(items);
}
var arc = Options.AR;
this.needsSpace = false;
this.width = 0;
this.up = 0;
this.height = sum(this.items, function(x){return x.height});
this.down = this.items[0].down;
var heightSoFar = 0;
for(var i = 0; i < this.items.length; i++) {
var item = this.items[i];
this.up = Math.max(this.up, Math.max(arc*2, item.up + Options.VS) - heightSoFar);
heightSoFar += item.height;
if(i > 0) {
this.down = Math.max(this.height + this.down, heightSoFar + Math.max(arc*2, item.down + Options.VS)) - this.height;
}
var itemWidth = (item.needsSpace?10:0) + item.width;
if(i === 0) {
this.width += arc + Math.max(itemWidth, arc);
} else {
this.width += arc*2 + Math.max(itemWidth, arc) + arc;
}
}
if(Options.DEBUG) {
this.attrs['data-updown'] = this.up + " " + this.height + " " + this.down;
this.attrs['data-type'] = "optseq";
}
}
format(x, y, width) {
var arc = Options.AR;
var gaps = determineGaps(width, this.width);
new Path(x, y).right(gaps[0]).addTo(this);
new Path(x + gaps[0] + this.width, y + this.height).right(gaps[1]).addTo(this);
x += gaps[0];
var upperLineY = y - this.up;
var last = this.items.length - 1;
for(var i = 0; i < this.items.length; i++) {
var item = this.items[i];
var itemSpace = (item.needsSpace?10:0);
var itemWidth = item.width + itemSpace;
if(i === 0) {
// Upper skip
new Path(x,y)
.arc('se')
.up(y - upperLineY - arc*2)
.arc('wn')
.right(itemWidth - arc)
.arc('ne')
.down(y + item.height - upperLineY - arc*2)
.arc('ws')
.addTo(this);
// Straight line
new Path(x, y)
.right(itemSpace + arc)
.addTo(this);
item.format(x + itemSpace + arc, y, item.width).addTo(this);
x += itemWidth + arc;
y += item.height;
// x ends on the far side of the first element,
// where the next element's skip needs to begin
} else if(i < last) {
// Upper skip
new Path(x, upperLineY)
.right(arc*2 + Math.max(itemWidth, arc) + arc)
.arc('ne')
.down(y - upperLineY + item.height - arc*2)
.arc('ws')
.addTo(this);
// Straight line
new Path(x,y)
.right(arc*2)
.addTo(this);
item.format(x + arc*2, y, item.width).addTo(this);
new Path(x + item.width + arc*2, y + item.height)
.right(itemSpace + arc)
.addTo(this);
// Lower skip
new Path(x,y)
.arc('ne')
.down(item.height + Math.max(item.down + Options.VS, arc*2) - arc*2)
.arc('ws')
.right(itemWidth - arc)
.arc('se')
.up(item.down + Options.VS - arc*2)
.arc('wn')
.addTo(this);
x += arc*2 + Math.max(itemWidth, arc) + arc;
y += item.height;
} else {
// Straight line
new Path(x, y)
.right(arc*2)
.addTo(this);
item.format(x + arc*2, y, item.width).addTo(this);
new Path(x + arc*2 + item.width, y + item.height)
.right(itemSpace + arc)
.addTo(this);
// Lower skip
new Path(x,y)
.arc('ne')
.down(item.height + Math.max(item.down + Options.VS, arc*2) - arc*2)
.arc('ws')
.right(itemWidth - arc)
.arc('se')
.up(item.down + Options.VS - arc*2)
.arc('wn')
.addTo(this);
}
}
return this;
}
}
funcs.OptionalSequence = (...args)=>new OptionalSequence(...args);
export class AlternatingSequence extends DiagramMultiContainer {
constructor(...items) {
super('g', items);
if( items.length === 1 ) {
return new Sequence(items);
}
if( items.length !== 2 ) {
throw new RangeError("AlternatingSequence() must have one or two children.");
}
this.needsSpace = false;
const arc = Options.AR;
const vert = Options.VS;
const max = Math.max;
const first = this.items[0];
const second = this.items[1];
const arcX = 1 / Math.sqrt(2) * arc * 2;
const arcY = (1 - 1 / Math.sqrt(2)) * arc * 2;
const crossY = Math.max(arc, Options.VS);
const crossX = (crossY - arcY) + arcX;
const firstOut = max(arc + arc, crossY/2 + arc + arc, crossY/2 + vert + first.down);
this.up = firstOut + first.height + first.up;
const secondIn = max(arc + arc, crossY/2 + arc + arc, crossY/2 + vert + second.up);
this.down = secondIn + second.height + second.down;
this.height = 0;
const firstWidth = 2*(first.needsSpace?10:0) + first.width;
const secondWidth = 2*(second.needsSpace?10:0) + second.width;
this.width = 2*arc + max(firstWidth, crossX, secondWidth) + 2*arc;
if(Options.DEBUG) {
this.attrs['data-updown'] = this.up + " " + this.height + " " + this.down;
this.attrs['data-type'] = "altseq";
}
}
format(x, y, width) {
const arc = Options.AR;
const gaps = determineGaps(width, this.width);
new Path(x,y).right(gaps[0]).addTo(this);
x += gaps[0];
new Path(x+this.width, y).right(gaps[1]).addTo(this);
// bounding box
//new Path(x+gaps[0], y).up(this.up).right(this.width).down(this.up+this.down).left(this.width).up(this.down).addTo(this);
const first = this.items[0];
const second = this.items[1];
// top
const firstIn = this.up - first.up;
const firstOut = this.up - first.up - first.height;
new Path(x,y).arc('se').up(firstIn-2*arc).arc('wn').addTo(this);
first.format(x + 2*arc, y - firstIn, this.width - 4*arc).addTo(this);
new Path(x + this.width - 2*arc, y - firstOut).arc('ne').down(firstOut - 2*arc).arc('ws').addTo(this);
// bottom
const secondIn = this.down - second.down - second.height;
const secondOut = this.down - second.down;
new Path(x,y).arc('ne').down(secondIn - 2*arc).arc('ws').addTo(this);
second.format(x + 2*arc, y + secondIn, this.width - 4*arc).addTo(this);
new Path(x + this.width - 2*arc, y + secondOut).arc('se').up(secondOut - 2*arc).arc('wn').addTo(this);
// crossover
const arcX = 1 / Math.sqrt(2) * arc * 2;
const arcY = (1 - 1 / Math.sqrt(2)) * arc * 2;
const crossY = Math.max(arc, Options.VS);
const crossX = (crossY - arcY) + arcX;
const crossBar = (this.width - 4*arc - crossX)/2;
new Path(x+arc, y - crossY/2 - arc).arc('ws').right(crossBar)
.arc_8('n', 'cw').l(crossX - arcX, crossY - arcY).arc_8('sw', 'ccw')
.right(crossBar).arc('ne').addTo(this);
new Path(x+arc, y + crossY/2 + arc).arc('wn').right(crossBar)
.arc_8('s', 'ccw').l(crossX - arcX, -(crossY - arcY)).arc_8('nw', 'cw')
.right(crossBar).arc('se').addTo(this);
return this;
}
}
funcs.AlternatingSequence = (...args)=>new AlternatingSequence(...args);
export class Choice extends DiagramMultiContainer {
constructor(normal, ...items) {
super('g', items);
if( typeof normal !== "number" || normal !== Math.floor(normal) ) {
throw new TypeError("The first argument of Choice() must be an integer.");
} else if(normal < 0 || normal >= items.length) {
throw new RangeError("The first argument of Choice() must be an index for one of the items.");
} else {
this.normal = normal;
}
var first = 0;
var last = items.length - 1;
this.width = Math.max.apply(null, this.items.map(function(el){return el.width})) + Options.AR*4;
this.height = this.items[normal].height;
this.up = this.items[first].up;
var arcs;
for(var i = first; i < normal; i++) {
if(i == normal-1) arcs = Options.AR*2;
else arcs = Options.AR;
this.up += Math.max(arcs, this.items[i].height + this.items[i].down + Options.VS + this.items[i+1].up);
}
this.down = this.items[last].down;
for(i = normal+1; i <= last; i++) {
if(i == normal+1) arcs = Options.AR*2;
else arcs = Options.AR;
this.down += Math.max(arcs, this.items[i-1].height + this.items[i-1].down + Options.VS + this.items[i].up);
}
this.down -= this.items[normal].height; // already counted in Choice.height
if(Options.DEBUG) {
this.attrs['data-updown'] = this.up + " " + this.height + " " + this.down;
this.attrs['data-type'] = "choice";
}
}
format(x,y,width) {
// Hook up the two sides if this is narrower than its stated width.
var gaps = determineGaps(width, this.width);
new Path(x,y).h(gaps[0]).addTo(this);
new Path(x+gaps[0]+this.width,y+this.height).h(gaps[1]).addTo(this);
x += gaps[0];
var last = this.items.length -1;
var innerWidth = this.width - Options.AR*4;
// Do the elements that curve above
var distanceFromY;
for(var i = this.normal - 1; i >= 0; i--) {
let item = this.items[i];
if( i == this.normal - 1 ) {
distanceFromY = Math.max(Options.AR*2, this.items[this.normal].up + Options.VS + item.down + item.height);
}
new Path(x,y)
.arc('se')
.up(distanceFromY - Options.AR*2)
.arc('wn').addTo(this);
item.format(x+Options.AR*2,y - distanceFromY,innerWidth).addTo(this);
new Path(x+Options.AR*2+innerWidth, y-distanceFromY+item.height)
.arc('ne')
.down(distanceFromY - item.height + this.height - Options.AR*2)
.arc('ws').addTo(this);
distanceFromY += Math.max(Options.AR, item.up + Options.VS + (i === 0 ? 0 : this.items[i-1].down+this.items[i-1].height));
}
// Do the straight-line path.
new Path(x,y).right(Options.AR*2).addTo(this);
this.items[this.normal].format(x+Options.AR*2, y, innerWidth).addTo(this);
new Path(x+Options.AR*2+innerWidth, y+this.height).right(Options.AR*2).addTo(this);
// Do the elements that curve below
for(i = this.normal+1; i <= last; i++) {
let item = this.items[i];
if( i == this.normal + 1 ) {
distanceFromY = Math.max(Options.AR*2, this.height + this.items[this.normal].down + Options.VS + item.up);
}
new Path(x,y)
.arc('ne')
.down(distanceFromY - Options.AR*2)
.arc('ws').addTo(this);
item.format(x+Options.AR*2, y+distanceFromY, innerWidth).addTo(this);
new Path(x+Options.AR*2+innerWidth, y+distanceFromY+item.height)
.arc('se')
.up(distanceFromY - Options.AR*2 + item.height - this.height)
.arc('wn').addTo(this);
distanceFromY += Math.max(Options.AR, item.height + item.down + Options.VS + (i == last ? 0 : this.items[i+1].up));
}
return this;
}
}
funcs.Choice = (...args)=>new Choice(...args);
export class HorizontalChoice extends DiagramMultiContainer {
constructor(...items) {
super('g', items);
if( items.length === 0 ) {
throw new RangeError("HorizontalChoice() must have at least one child.");
}
if( items.length === 1) {
return new Sequence(items);
}
const allButLast = this.items.slice(0, -1);
const middles = this.items.slice(1, -1);
const first = this.items[0];
const last = this.items[this.items.length - 1];
this.needsSpace = false;
this.width = Options.AR; // starting track
this.width += Options.AR*2 * (this.items.length-1); // inbetween tracks
this.width += sum(this.items, x=>x.width + (x.needsSpace?20:0)); // items
this.width += (last.height > 0 ? Options.AR : 0); // needs space to curve up
this.width += Options.AR; //ending track
// Always exits at entrance height
this.height = 0;
// All but the last have a track running above them
this._upperTrack = Math.max(
Options.AR*2,
Options.VS,
max(allButLast, x=>x.up) + Options.VS
);
this.up = Math.max(this._upperTrack, last.up);
// All but the first have a track running below them
// Last either straight-lines or curves up, so has different calculation
this._lowerTrack = Math.max(
Options.VS,
max(middles, x=>x.height+Math.max(x.down+Options.VS, Options.AR*2)),
last.height + last.down + Options.VS
);
if(first.height < this._lowerTrack) {
// Make sure there's at least 2*AR room between first exit and lower track
this._lowerTrack = Math.max(this._lowerTrack, first.height + Options.AR*2);
}
this.down = Math.max(this._lowerTrack, first.height + first.down);
if(Options.DEBUG) {
this.attrs['data-updown'] = this.up + " " + this.height + " " + this.down;
this.attrs['data-type'] = "horizontalchoice";
}
}
format(x,y,width) {
// Hook up the two sides if this is narrower than its stated width.
var gaps = determineGaps(width, this.width);
new Path(x,y).h(gaps[0]).addTo(this);
new Path(x+gaps[0]+this.width,y+this.height).h(gaps[1]).addTo(this);
x += gaps[0];
const first = this.items[0];
const last = this.items[this.items.length-1];
const allButFirst = this.items.slice(1);
const allButLast = this.items.slice(0, -1);
// upper track
var upperSpan = (sum(allButLast, x=>x.width+(x.needsSpace?20:0))
+ (this.items.length - 2) * Options.AR*2
- Options.AR
);
new Path(x,y)
.arc('se')
.v(-(this._upperTrack - Options.AR*2))
.arc('wn')
.h(upperSpan)
.addTo(this);
// lower track
var lowerSpan = (sum(allButFirst, x=>x.width+(x.needsSpace?20:0))
+ (this.items.length - 2) * Options.AR*2
+ (last.height > 0 ? Options.AR : 0)
- Options.AR
);
var lowerStart = x + Options.AR + first.width+(first.needsSpace?20:0) + Options.AR*2;
new Path(lowerStart, y+this._lowerTrack)
.h(lowerSpan)
.arc('se')
.v(-(this._lowerTrack - Options.AR*2))
.arc('wn')
.addTo(this);
// Items
for(const [i, item] of enumerate(this.items)) {
// input track
if(i === 0) {
new Path(x,y)
.h(Options.AR)
.addTo(this);
x += Options.AR;
} else {
new Path(x, y - this._upperTrack)
.arc('ne')
.v(this._upperTrack - Options.AR*2)
.arc('ws')
.addTo(this);
x += Options.AR*2;
}
// item
var itemWidth = item.width + (item.needsSpace?20:0);
item.format(x, y, itemWidth).addTo(this);
x += itemWidth;
// output track
if(i === this.items.length-1) {
if(item.height === 0) {
new Path(x,y)
.h(Options.AR)
.addTo(this);
} else {
new Path(x,y+item.height)
.arc('se')
.addTo(this);
}
} else if(i === 0 && item.height > this._lowerTrack) {
// Needs to arc up to meet the lower track, not down.
if(item.height - this._lowerTrack >= Options.AR*2) {
new Path(x, y+item.height)
.arc('se')
.v(this._lowerTrack - item.height + Options.AR*2)
.arc('wn')
.addTo(this);
} else {
// Not enough space to fit two arcs
// so just bail and draw a straight line for now.
new Path(x, y+item.height)
.l(Options.AR*2, this._lowerTrack - item.height)
.addTo(this);
}
} else {
new Path(x, y+item.height)
.arc('ne')
.v(this._lowerTrack - item.height - Options.AR*2)
.arc('ws')
.addTo(this);
}
}
return this;
}
}
funcs.HorizontalChoice = (...args)=>new HorizontalChoice(...args);
export class MultipleChoice extends DiagramMultiContainer {
constructor(normal, type, ...items) {
super('g', items);
if( typeof normal !== "number" || normal !== Math.floor(normal) ) {
throw new TypeError("The first argument of MultipleChoice() must be an integer.");
} else if(normal < 0 || normal >= items.length) {
throw new RangeError("The first argument of MultipleChoice() must be an index for one of the items.");
} else {
this.normal = normal;
}
if( type != "any" && type != "all" ) {
throw new SyntaxError("The second argument of MultipleChoice must be 'any' or 'all'.");
} else {
this.type = type;
}
this.needsSpace = true;
this.innerWidth = max(this.items, function(x){return x.width});
this.width = 30 + Options.AR + this.innerWidth + Options.AR + 20;
this.up = this.items[0].up;
this.down = this.items[this.items.length-1].down;
this.height = this.items[normal].height;
for(var i = 0; i < this.items.length; i++) {
let item = this.items[i];
let minimum;
if(i == normal - 1 || i == normal + 1) minimum = 10 + Options.AR;
else minimum = Options.AR;
if(i < normal) {
this.up += Math.max(minimum, item.height + item.down + Options.VS + this.items[i+1].up);
} else if(i > normal) {
this.down += Math.max(minimum, item.up + Options.VS + this.items[i-1].down + this.items[i-1].height);
}
}
this.down -= this.items[normal].height; // already counted in this.height
if(Options.DEBUG) {
this.attrs['data-updown'] = this.up + " " + this.height + " " + this.down;
this.attrs['data-type'] = "multiplechoice";
}
}
format(x, y, width) {
var gaps = determineGaps(width, this.width);
new Path(x, y).right(gaps[0]).addTo(this);
new Path(x + gaps[0] + this.width, y + this.height).right(gaps[1]).addTo(this);
x += gaps[0];
var normal = this.items[this.normal];
// Do the elements that curve above
var distanceFromY;
for(var i = this.normal - 1; i >= 0; i--) {
var item = this.items[i];
if( i == this.normal - 1 ) {
distanceFromY = Math.max(10 + Options.AR, normal.up + Options.VS + item.down + item.height);
}
new Path(x + 30,y)
.up(distanceFromY - Options.AR)
.arc('wn').addTo(this);
item.format(x + 30 + Options.AR, y - distanceFromY, this.innerWidth).addTo(this);
new Path(x + 30 + Options.AR + this.innerWidth, y - distanceFromY + item.height)
.arc('ne')
.down(distanceFromY - item.height + this.height - Options.AR - 10)
.addTo(this);
if(i !== 0) {
distanceFromY += Math.max(Options.AR, item.up + Options.VS + this.items[i-1].down + this.items[i-1].height);
}
}
new Path(x + 30, y).right(Options.AR).addTo(this);
normal.format(x + 30 + Options.AR, y, this.innerWidth).addTo(this);
new Path(x + 30 + Options.AR + this.innerWidth, y + this.height).right(Options.AR).addTo(this);
for(i = this.normal+1; i < this.items.length; i++) {
let item = this.items[i];
if(i == this.normal + 1) {
distanceFromY = Math.max(10+Options.AR, normal.height + normal.down + Options.VS + item.up);
}
new Path(x + 30, y)
.down(distanceFromY - Options.AR)
.arc('ws')
.addTo(this);
item.format(x + 30 + Options.AR, y + distanceFromY, this.innerWidth).addTo(this);
new Path(x + 30 + Options.AR + this.innerWidth, y + distanceFromY + item.height)
.arc('se')
.up(distanceFromY - Options.AR + item.height - normal.height)
.addTo(this);
if(i != this.items.length - 1) {
distanceFromY += Math.max(Options.AR, item.height + item.down + Options.VS + this.items[i+1].up);
}
}
var text = new FakeSVG('g', {"class": "diagram-text"}).addTo(this);
new FakeSVG('title', {}, (this.type=="any"?"take one or more branches, once each, in any order":"take all branches, once each, in any order")).addTo(text);
new FakeSVG('path', {
"d": "M "+(x+30)+" "+(y-10)+" h -26 a 4 4 0 0 0 -4 4 v 12 a 4 4 0 0 0 4 4 h 26 z",
"class": "diagram-text"
}).addTo(text);
new FakeSVG('text', {
"x": x + 15,
"y": y + 4,
"class": "diagram-text"
}, (this.type=="any"?"1+":"all")).addTo(text);
new FakeSVG('path', {
"d": "M "+(x+this.width-20)+" "+(y-10)+" h 16 a 4 4 0 0 1 4 4 v 12 a 4 4 0 0 1 -4 4 h -16 z",
"class": "diagram-text"
}).addTo(text);
new FakeSVG('path', {
"d": "M "+(x+this.width-13)+" "+(y-2)+" a 4 4 0 1 0 6 -1 m 2.75 -1 h -4 v 4 m 0 -3 h 2",
"style": "stroke-width: 1.75"
}).addTo(text);
return this;
}
}
funcs.MultipleChoice = (...args)=>new MultipleChoice(...args);
export class Optional extends FakeSVG {
constructor(item, skip) {
if( skip === undefined )
return new Choice(1, new Skip(), item);
else if ( skip === "skip" )
return new Choice(0, new Skip(), item);
else
throw "Unknown value for Optional()'s 'skip' argument.";
}
}
funcs.Optional = (...args)=>new Optional(...args);
export class OneOrMore extends FakeSVG {
constructor(item, rep) {
super('g');
rep = rep || (new Skip());
this.item = wrapString(item);
this.rep = wrapString(rep);
this.width = Math.max(this.item.width, this.rep.width) + Options.AR*2;
this.height = this.item.height;
this.up = this.item.up;
this.down = Math.max(Options.AR*2, this.item.down + Options.VS + this.rep.up + this.rep.height + this.rep.down);
this.needsSpace = true;
if(Options.DEBUG) {
this.attrs['data-updown'] = this.up + " " + this.height + " " + this.down;
this.attrs['data-type'] = "oneormore";
}
}
format(x,y,width) {
// Hook up the two sides if this is narrower than its stated width.
var gaps = determineGaps(width, this.width);
new Path(x,y).h(gaps[0]).addTo(this);
new Path(x+gaps[0]+this.width,y+this.height).h(gaps[1]).addTo(this);
x += gaps[0];
// Draw item
new Path(x,y).right(Options.AR).addTo(this);
this.item.format(x+Options.AR,y,this.width-Options.AR*2).addTo(this);
new Path(x+this.width-Options.AR,y+this.height).right(Options.AR).addTo(this);
// Draw repeat arc
var distanceFromY = Math.max(Options.AR*2, this.item.height+this.item.down+Options.VS+this.rep.up);
new Path(x+Options.AR,y).arc('nw').down(distanceFromY-Options.AR*2).arc('ws').addTo(this);
this.rep.format(x+Options.AR, y+distanceFromY, this.width - Options.AR*2).addTo(this);
new Path(x+this.width-Options.AR, y+distanceFromY+this.rep.height).arc('se').up(distanceFromY-Options.AR*2+this.rep.height-this.item.height).arc('en').addTo(this);
return this;
}
walk(cb) {
cb(this);
this.item.walk(cb);
this.rep.walk(cb);
}
}
funcs.OneOrMore = (...args)=>new OneOrMore(...args);
export class ZeroOrMore extends FakeSVG {
constructor(item, rep, skip) {
return new Optional(new OneOrMore(item, rep), skip);
}
}
funcs.ZeroOrMore = (...args)=>new ZeroOrMore(...args);
export class Group extends FakeSVG {
constructor(item, label) {
super('g');
this.item = wrapString(item);
this.label =
label instanceof FakeSVG
? label
: label
? new Comment(label)
: undefined;
this.width = Math.max(
this.item.width + (this.item.needsSpace?20:0),
this.label ? this.label.width : 0,
Options.AR*2);
this.height = this.item.height;
this.boxUp = this.up = Math.max(this.item.up + Options.VS, Options.AR);
if(this.label) {
this.up += this.label.up + this.label.height + this.label.down;
}
this.down = Math.max(this.item.down + Options.VS, Options.AR);
this.needsSpace = true;
if(Options.DEBUG) {
this.attrs['data-updown'] = this.up + " " + this.height + " " + this.down;
this.attrs['data-type'] = "group";
}
}
format(x, y, width) {
var gaps = determineGaps(width, this.width);
new Path(x,y).h(gaps[0]).addTo(this);
new Path(x+gaps[0]+this.width,y+this.height).h(gaps[1]).addTo(this);
x += gaps[0];
new FakeSVG('rect', {
x,
y:y-this.boxUp,
width:this.width,
height:this.boxUp + this.height + this.down,
rx: Options.AR,
ry: Options.AR,
'class':'group-box',
}).addTo(this);
this.item.format(x,y,this.width).addTo(this);
if(this.label) {
this.label.format(
x,
y-(this.boxUp+this.label.down+this.label.height),
this.label.width).addTo(this);
}
return this;
}
walk(cb) {
cb(this);
this.item.walk(cb);
this.label.walk(cb);
}
}
funcs.Group = (...args)=>new Group(...args);
export class Start extends FakeSVG {
constructor({type="simple", label}={}) {
super('g');
this.width = 20;
this.height = 0;
this.up = 10;
this.down = 10;
this.type = type;
if(label) {
this.label = ""+label;
this.width = Math.max(20, this.label.length * Options.CHAR_WIDTH + 10);
}
if(Options.DEBUG) {
this.attrs['data-updown'] = this.up + " " + this.height + " " + this.down;
this.attrs['data-type'] = "start";
}
}
format(x,y) {
let path = new Path(x, y-10);
if (this.type === "complex") {
path.down(20)
.m(0, -10)
.right(this.width)
.addTo(this);
} else {
path.down(20)
.m(10, -20)
.down(20)
.m(-10, -10)
.right(this.width)
.addTo(this);
}
if(this.label) {
new FakeSVG('text', {x:x, y:y-15, style:"text-anchor:start"}, this.label).addTo(this);
}
return this;
}
}
funcs.Start = (...args)=>new Start(...args);
export class End extends FakeSVG {
constructor({type="simple"}={}) {
super('path');
this.width = 20;
this.height = 0;
this.up = 10;
this.down = 10;
this.type = type;
if(Options.DEBUG) {
this.attrs['data-updown'] = this.up + " " + this.height + " " + this.down;
this.attrs['data-type'] = "end";
}
}
format(x,y) {
if (this.type === "complex") {
this.attrs.d = 'M '+x+' '+y+' h 20 m 0 -10 v 20';
} else {
this.attrs.d = 'M '+x+' '+y+' h 20 m -10 -10 v 20 m 10 -20 v 20';
}
return this;
}
}
funcs.End = (...args)=>new End(...args);
export class Terminal extends FakeSVG {
constructor(text, {href, title, cls}={}) {
super('g', {'class': ['terminal', cls].join(" ")});
this.text = ""+text;
this.href = href;
this.title = title;
this.cls = cls;
this.width = this.text.length * Options.CHAR_WIDTH + 20; /* Assume that each char is .5em, and that the em is 16px */
this.height = 0;
this.up = 11;
this.down = 11;
this.needsSpace = true;
if(Options.DEBUG) {
this.attrs['data-updown'] = this.up + " " + this.height + " " + this.down;
this.attrs['data-type'] = "terminal";
}
}
format(x, y, width) {
// Hook up the two sides if this is narrower than its stated width.
var gaps = determineGaps(width, this.width);
new Path(x,y).h(gaps[0]).addTo(this);
new Path(x+gaps[0]+this.width,y).h(gaps[1]).addTo(this);
x += gaps[0];
new FakeSVG('rect', {x:x, y:y-11, width:this.width, height:this.up+this.down, rx:10, ry:10}).addTo(this);
var text = new FakeSVG('text', {x:x+this.width/2, y:y+4}, this.text);
if(this.href)
new FakeSVG('a', {'xlink:href': this.href}, [text]).addTo(this);
else
text.addTo(this);
if(this.title)
new FakeSVG('title', {}, [this.title]).addTo(this);
return this;
}
}
funcs.Terminal = (...args)=>new Terminal(...args);
export class NonTerminal extends FakeSVG {
constructor(text, {href, title, cls=""}={}) {
super('g', {'class': ['non-terminal', cls].join(" ")});
this.text = ""+text;
this.href = href;
this.title = title;
this.cls = cls;
this.width = this.text.length * Options.CHAR_WIDTH + 20;
this.height = 0;
this.up = 11;
this.down = 11;
this.needsSpace = true;
if(Options.DEBUG) {
this.attrs['data-updown'] = this.up + " " + this.height + " " + this.down;
this.attrs['data-type'] = "nonterminal";
}
}
format(x, y, width) {
// Hook up the two sides if this is narrower than its stated width.
var gaps = determineGaps(width, this.width);
new Path(x,y).h(gaps[0]).addTo(this);
new Path(x+gaps[0]+this.width,y).h(gaps[1]).addTo(this);
x += gaps[0];
new FakeSVG('rect', {x:x, y:y-11, width:this.width, height:this.up+this.down}).addTo(this);
var text = new FakeSVG('text', {x:x+this.width/2, y:y+4}, this.text);
if(this.href)
new FakeSVG('a', {'xlink:href': this.href}, [text]).addTo(this);
else
text.addTo(this);
if(this.title)
new FakeSVG('title', {}, [this.title]).addTo(this);
return this;
}
}
funcs.NonTerminal = (...args)=>new NonTerminal(...args);
export class Comment extends FakeSVG {
constructor(text, {href, title, cls=""}={}) {
super('g', {'class': ['comment', cls].join(" ")});
this.text = ""+text;
this.href = href;
this.title = title;
this.cls = cls;
this.width = this.text.length * Options.COMMENT_CHAR_WIDTH + 10;
this.height = 0;
this.up = 8;
this.down = 8;
this.needsSpace = true;
if(Options.DEBUG) {
this.attrs['data-updown'] = this.up + " " + this.height + " " + this.down;
this.attrs['data-type'] = "comment";
}
}
format(x, y, width) {
// Hook up the two sides if this is narrower than its stated width.
var gaps = determineGaps(width, this.width);
new Path(x,y).h(gaps[0]).addTo(this);
new Path(x+gaps[0]+this.width,y+this.height).h(gaps[1]).addTo(this);
x += gaps[0];
var text = new FakeSVG('text', {x:x+this.width/2, y:y+5, class:'comment'}, this.text);
if(this.href)
new FakeSVG('a', {'xlink:href': this.href}, [text]).addTo(this);
else
text.addTo(this);
if(this.title)
new FakeSVG('title', {}, this.title).addTo(this);
return this;
}
}
funcs.Comment = (...args)=>new Comment(...args);
export class Skip extends FakeSVG {
constructor() {
super('g');
this.width = 0;
this.height = 0;
this.up = 0;
this.down = 0;
this.needsSpace = false;
if(Options.DEBUG) {
this.attrs['data-updown'] = this.up + " " + this.height + " " + this.down;
this.attrs['data-type'] = "skip";
}
}
format(x, y, width) {
new Path(x,y).right(width).addTo(this);
return this;
}
}
funcs.Skip = (...args)=>new Skip(...args);
export class Block extends FakeSVG {
constructor({width=50, up=15, height=25, down=15, needsSpace=true}={}) {
super('g');
this.width = width;
this.height = height;
this.up = up;
this.down = down;
this.needsSpace = true;
if(Options.DEBUG) {
this.attrs['data-updown'] = this.up + " " + this.height + " " + this.down;
this.attrs['data-type'] = "block";
}
}
format(x, y, width) {
// Hook up the two sides if this is narrower than its stated width.
var gaps = determineGaps(width, this.width);
new Path(x,y).h(gaps[0]).addTo(this);
new Path(x+gaps[0]+this.width,y).h(gaps[1]).addTo(this);
x += gaps[0];
new FakeSVG('rect', {x:x, y:y-this.up, width:this.width, height:this.up+this.height+this.down}).addTo(this);
return this;
}
}
funcs.Block = (...args)=>new Block(...args);
function unnull(...args) {
// Return the first value that isn't undefined.
// More correct than `v1 || v2 || v3` because falsey values will be returned.
return args.reduce(function(sofar, x) { return sofar !== undefined ? sofar : x; });
}
function determineGaps(outer, inner) {
var diff = outer - inner;
switch(Options.INTERNAL_ALIGNMENT) {
case 'left': return [0, diff];
case 'right': return [diff, 0];
default: return [diff/2, diff/2];
}
}
function wrapString(value) {
return value instanceof FakeSVG ? value : new Terminal(""+value);
}
function sum(iter, func) {
if(!func) func = function(x) { return x; };
return iter.map(func).reduce(function(a,b){return a+b}, 0);
}
function max(iter, func) {
if(!func) func = function(x) { return x; };
return Math.max.apply(null, iter.map(func));
}
function SVG(name, attrs, text) {
attrs = attrs || {};
text = text || '';
var el = document.createElementNS("http://www.w3.org/2000/svg",name);
for(var attr in attrs) {
if(attr === 'xlink:href')
el.setAttributeNS("http://www.w3.org/1999/xlink", 'href', attrs[attr]);
else
el.setAttribute(attr, attrs[attr]);
}
el.textContent = text;
return el;
}
function escapeString(string) {
// Escape markdown and HTML special characters
return string.replace(/[*_\`\[\]<&]/g, function(charString) {
return '&#' + charString.charCodeAt(0) + ';';
});
}
function* enumerate(iter) {
var count = 0;
for(const x of iter) {
yield [count, x];
count++;
}
}