ebnf-railroad-visualizer
Version:
A web-based EBNF railroad diagram visualizer
1,135 lines (1,134 loc) • 102 kB
JavaScript
"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.
*/
/*
Notes on changes I made to the original code:
* Added the ability to pass a "title" to the Group-class, which interallly passes it to the Comment-class.
* Add "x" next to comment text so it can be collapsed on click.
*/
// 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
ESCAPE_HTML: true, // Should Diagram.toText() produce HTML-escaped text, or raw?
};
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, '&').replace(/"/g, '"') + '"';
}
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;
}
toTextDiagram() {
return new TextDiagram(0, 0, []);
}
toText() {
var outputTD = this.toTextDiagram();
var output = outputTD.lines.join("\n") + "\n";
if (Options.ESCAPE_HTML) {
output = output.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """);
}
return output;
}
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;
}
toTextDiagram() {
return new TextDiagram(0, 0, []);
}
}
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;
}
toTextDiagram() {
var [separator] = TextDiagram._getParts(["separator"]);
var diagramTD = this.items[0].toTextDiagram();
for (const item of this.items.slice(1)) {
var itemTD = item.toTextDiagram();
if (item.needsSpace) {
itemTD = itemTD.expand(1, 1, 0, 0);
}
diagramTD = diagramTD.appendRight(itemTD, separator);
}
return diagramTD;
}
}
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;
}
toTextDiagram() {
var [separator] = TextDiagram._getParts(["separator"]);
var diagramTD = new TextDiagram(0, 0, [""]);
for (const item of this.items) {
var itemTD = item.toTextDiagram();
if (item.needsSpace) {
itemTD = itemTD.expand(1, 1, 0, 0);
}
diagramTD = diagramTD.appendRight(itemTD, separator);
}
return diagramTD;
}
}
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;
}
toTextDiagram() {
var [corner_bot_left, corner_bot_right, corner_top_left, corner_top_right, line, line_vertical] = TextDiagram._getParts(["corner_bot_left", "corner_bot_right", "corner_top_left", "corner_top_right", "line", "line_vertical"]);
// Format all the child items, so we can know the maximum width.
var itemTDs = [];
for (const item of this.items) {
itemTDs.push(item.toTextDiagram());
}
var maxWidth = Math.max(...(itemTDs.map(function (itemTD) { return itemTD.width; })));
var leftLines = [];
var rightLines = [];
var separatorTD = new TextDiagram(0, 0, [line.repeat(maxWidth)]);
var diagramTD = null; // Top item will replace it
for (var [itemNum, itemTD] of enumerate(itemTDs)) {
if (itemNum == 0) {
// The top item enters directly from its left.
leftLines.push(line + line);
for (var i = 0; i < (itemTD.height - itemTD.entry - 1); i++) {
leftLines.push(" ");
}
}
else {
// All items below the top enter from a snake-line from the previous item's exit.
// Here, we resume that line, already having descended from above on the right.
diagramTD = diagramTD.appendBelow(separatorTD, []);
leftLines.push(corner_top_left + line);
for (i = 0; i < itemTD.entry; i++) {
leftLines.push(line_vertical + " ");
}
leftLines.push(corner_bot_left + line);
for (i = 0; i < (itemTD.height - itemTD.entry - 1); i++) {
leftLines.push(" ");
}
for (i = 0; i < itemTD.exit; i++) {
rightLines.push(" ");
}
}
if (itemNum < itemTDs.length - 1) {
// All items above the bottom exit via a snake-line to the next item's entry.
// Here, we start that line on the right.
rightLines.push(line + corner_top_right);
for (i = 0; i < (itemTD.height - itemTD.exit - 1); i++) {
rightLines.push(" " + line_vertical);
}
rightLines.push(line + corner_bot_right);
}
else {
// The bottom item exits directly to its right.
rightLines.push(line + line);
}
var [leftPad, rightPad] = TextDiagram._gaps(maxWidth, itemTD.width);
itemTD = itemTD.expand(leftPad, rightPad, 0, 0);
if (itemNum == 0) {
diagramTD = itemTD;
}
else {
diagramTD = diagramTD.appendBelow(itemTD, []);
}
}
var leftTD = new TextDiagram(0, 0, leftLines);
diagramTD = leftTD.appendRight(diagramTD, "");
var rightTD = new TextDiagram(0, rightLines.length - 1, rightLines);
diagramTD = diagramTD.appendRight(rightTD, "");
return diagramTD;
}
}
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;
}
toTextDiagram() {
var [line, line_vertical, roundcorner_bot_left, roundcorner_bot_right, roundcorner_top_left, roundcorner_top_right] = TextDiagram._getParts(["line", "line_vertical", "roundcorner_bot_left", "roundcorner_bot_right", "roundcorner_top_left", "roundcorner_top_right"]);
// Format all the child items, so we can know the maximum entry.
var itemTDs = [];
for (const item of this.items) {
itemTDs.push(item.toTextDiagram());
}
// diagramEntry: distance from top to lowest entry, aka distance from top to diagram entry, aka final diagram entry and exit.
var diagramEntry = Math.max(...(itemTDs.map(function (itemTD) { return itemTD.entry; })));
// SOILHeight: distance from top to lowest entry before rightmost item, aka distance from skip-over-items line to rightmost entry, aka SOIL height.
var SOILHeight = Math.max(...(itemTDs.slice(0, -1).map(function (itemTD) { return itemTD.entry; })));
// topToSOIL: distance from top to skip-over-items line.
var topToSOIL = diagramEntry - SOILHeight;
// The diagram starts with a line from its entry up to the skip-over-items line {
var lines = [];
for (var i = 0; i < topToSOIL; i++) {
lines.push(" ");
}
lines.push(roundcorner_top_left + line);
for (i = 0; i < SOILHeight; i++) {
lines.push(line_vertical + " ");
}
lines.push(roundcorner_bot_right + line);
var diagramTD = new TextDiagram(lines.length - 1, lines.length - 1, lines);
for (const [itemNum, itemTD] of enumerate(itemTDs)) {
if (itemNum > 0) {
// All items except the leftmost start with a line from their entry down to their skip-under-item line,
// with a joining-line across at the skip-over-items line
lines = [];
for (i = 0; i < topToSOIL; i++) {
lines.push(" ");
}
lines.push(line + line);
for (i = 0; i < (diagramTD.exit - topToSOIL - 1); i++) {
lines.push(" ");
}
lines.push(line + roundcorner_top_right);
for (i = 0; i < (itemTD.height - itemTD.entry - 1); i++) {
lines.push(" " + line_vertical);
}
lines.push(" " + roundcorner_bot_left);
var skipDownTD = new TextDiagram(diagramTD.exit, diagramTD.exit, lines);
diagramTD = diagramTD.appendRight(skipDownTD, "");
// All items except the leftmost next have a line from skip-over-items line down to their entry,
// with joining-lines at their entry and at their skip-under-item line
lines = [];
for (i = 0; i < topToSOIL; i++) {
lines.push(" ");
}
// All such items except the rightmost also have a continuation of the skip-over-items line
var lineToNextItem = itemNum < itemTDs.length - 1 ? line : " ";
lines.push(line + roundcorner_top_right + lineToNextItem);
for (i = 0; i < (diagramTD.exit - topToSOIL - 1); i++) {
lines.push(" " + line_vertical + " ");
}
lines.push(line + roundcorner_bot_left + line);
for (i = 0; i < (itemTD.height - itemTD.entry - 1); i++) {
lines.push(" ");
}
lines.push(line + line + line);
var entryTD = new TextDiagram(diagramTD.exit, diagramTD.exit, lines);
diagramTD = diagramTD.appendRight(entryTD, "");
}
var partTD = new TextDiagram(0, 0, []);
if (itemNum < itemTDs.length - 1) {
// All items except the rightmost have a segment of the skip-over-items line at the top,
// followed by enough blank lines to push their entry down to the previous item's exit
lines = [];
lines.push(line.repeat(itemTD.width));
for (i = 0; i < (SOILHeight - itemTD.entry); i++) {
lines.push(" ".repeat(itemTD.width));
}
var SOILSegment = new TextDiagram(0, 0, lines);
partTD = partTD.appendBelow(SOILSegment, []);
}
partTD = partTD.appendBelow(itemTD, [], true, true);
if (itemNum > 0) {
// All items except the leftmost have their skip-under-item line at the bottom.
var SUILSegment = new TextDiagram(0, 0, [line.repeat(itemTD.width)]);
partTD = partTD.appendBelow(SUILSegment, []);
}
diagramTD = diagramTD.appendRight(partTD, "");
if (0 < itemNum) {
// All items except the leftmost have a line from their skip-under-item line to their exit
lines = [];
for (i = 0; i < topToSOIL; i++) {
lines.push(" ");
}
// All such items except the rightmost also have a joining-line across at the skip-over-items line
var skipOverChar = itemNum < itemTDs.length - 1 ? line : " ";
lines.push(skipOverChar.repeat(2));
for (i = 0; i < (diagramTD.exit - topToSOIL - 1); i++) {
lines.push(" ");
}
lines.push(line + roundcorner_top_left);
for (i = 0; i < (partTD.height - partTD.exit - 2); i++) {
lines.push(" " + line_vertical);
}
lines.push(line + roundcorner_bot_right);
var skipUpTD = new TextDiagram(diagramTD.exit, diagramTD.exit, lines);
diagramTD = diagramTD.appendRight(skipUpTD, "");
}
}
return diagramTD;
}
}
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;
}
toTextDiagram() {
var [cross_diag, corner_bot_left, corner_bot_right, corner_top_left, corner_top_right, line, line_vertical, tee_left, tee_right] = TextDiagram._getParts(["cross_diag", "roundcorner_bot_left", "roundcorner_bot_right", "roundcorner_top_left", "roundcorner_top_right", "line", "line_vertical", "tee_left", "tee_right"]);
var firstTD = this.items[0].toTextDiagram();
var secondTD = this.items[1].toTextDiagram();
var maxWidth = TextDiagram._maxWidth(firstTD, secondTD);
var [leftWidth, rightWidth] = TextDiagram._gaps(maxWidth, 0);
var leftLines = [];
var rightLines = [];
var separator = [];
var [leftSize, rightSize] = TextDiagram._gaps(firstTD.width, 0);
var diagramTD = firstTD.expand(leftWidth - leftSize, rightWidth - rightSize, 0, 0);
for (var i = 0; i < diagramTD.entry; i++) {
leftLines.push(" ");
}
leftLines.push(corner_top_left + line);
for (i = 0; i < (diagramTD.height - diagramTD.entry - 1); i++) {
leftLines.push(line_vertical + " ");
}
leftLines.push(corner_bot_left + line);
for (i = 0; i < diagramTD.exit; i++) {
rightLines.push(" ");
}
rightLines.push(line + corner_top_right);
for (i = 0; i < (diagramTD.height - diagramTD.exit - 1); i++) {
rightLines.push(" " + line_vertical);
}
rightLines.push(line + corner_bot_right);
separator.push((line.repeat(leftWidth - 1)) + corner_top_right + " " + corner_top_left + (line.repeat(rightWidth - 2)));
separator.push((" ".repeat(leftWidth - 1)) + " " + cross_diag + " " + (" ".repeat(rightWidth - 2)));
separator.push((line.repeat(leftWidth - 1)) + corner_bot_right + " " + corner_bot_left + (line.repeat(rightWidth - 2)));
leftLines.push(" ");
rightLines.push(" ");
[leftSize, rightSize] = TextDiagram._gaps(secondTD.width, 0);
secondTD = secondTD.expand(leftWidth - leftSize, rightWidth - rightSize, 0, 0);
diagramTD = diagramTD.appendBelow(secondTD, separator, true, true);
leftLines.push(corner_top_left + line);
for (i = 0; i < secondTD.entry; i++) {
leftLines.push(line_vertical + " ");
}
leftLines.push(corner_bot_left + line);
rightLines.push(line + corner_top_right);
for (i = 0; i < secondTD.exit; i++) {
rightLines.push(" " + line_vertical);
}
rightLines.push(line + corner_bot_right);
diagramTD = diagramTD.alter(firstTD.height + Math.trunc(separator.length / 2), firstTD.height + Math.trunc(separator.length / 2));
var leftTD = new TextDiagram(firstTD.height + Math.trunc(separator.length / 2), firstTD.height + Math.trunc(separator.length / 2), leftLines);
var rightTD = new TextDiagram(firstTD.height + Math.trunc(separator.length / 2), firstTD.height + Math.trunc(separator.length / 2), rightLines);
diagramTD = leftTD.appendRight(diagramTD, "").appendRight(rightTD, "");
diagramTD = new TextDiagram(1, 1, [corner_top_left, tee_left, corner_bot_left]).appendRight(diagramTD, "").appendRight(new TextDiagram(1, 1, [corner_top_right, tee_right, corner_bot_right]), "");
return diagramTD;
}
}
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;
}
this.width = max(this.items, el => el.width) + Options.AR * 4;
var firstItem = this.items[0];
var lastItem = this.items[items.length - 1];
var normalItem = this.items[normal];
// The size of the vertical separation between an item
// and the following item.
// The calcs are non-trivial and need to be done both here
// and in .format(), so no reason to do it twice.
this.separators = Array.from({ length: items.length - 1 }, x => 0);
// If the entry or exit lines would be too close together
// to accommodate the arcs,
// bump up the vertical separation to compensate.
this.up = 0;
var arcs;
for (var i = normal - 1; i >= 0; i--) {
if (i == normal - 1)
arcs = Options.AR * 2;
else
arcs = Options.AR;
let item = this.items[i];
let lowerItem = this.items[i + 1];
let entryDelta = lowerItem.up + Options.VS + item.down + item.height;
let exitDelta = lowerItem.height + lowerItem.up + Options.VS + item.down;
let separator = Options.VS;
if (exitDelta < arcs || entryDelta < arcs) {
separator += Math.max(arcs - entryDelta, arcs - exitDelta);
}
this.separators[i] = separator;
this.up += lowerItem.up + separator + item.down + item.height;
}
this.up += firstItem.up;
this.height = normalItem.height;
this.down = 0;
for (var i = normal + 1; i < this.items.length; i++) {
if (i == normal + 1)
arcs = Options.AR * 2;
else
arcs = Options.AR;
let item = this.items[i];
let upperItem = this.items[i - 1];
let entryDelta = upperItem.height + upperItem.down + Options.VS + item.up;
let exitDelta = upperItem.down + Options.VS + item.up + item.height;
let separator = Options.VS;
if (entryDelta < arcs || exitDelta < arcs) {
separator += Math.max(arcs - entryDelta, arcs - exitDelta);
}
this.separators[i - 1] = separator;
this.down += upperItem.down + separator + item.up + item.height;
}
this.down += lastItem.down;
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 = 0;
for (var i = this.normal - 1; i >= 0; i--) {
let item = this.items[i];
let lowerItem = this.items[i + 1];
distanceFromY += lowerItem.up + this.separators[i] + 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);
}
// 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
var distanceFromY = 0;
for (var i = this.normal + 1; i <= last; i++) {
let item = this.items[i];
let upperItem = this.items[i - 1];
distanceFromY += upperItem.height + upperItem.down + this.separators[i - 1] + item.up;
new Path(x, y)
.arc('ne')
.down(distanceFromY - Options.AR * 2)
.arc('ws').addTo(this);
if (!item.format)
console.log(item);
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);
}
return this;
}
toTextDiagram() {
var [cross, line, line_vertical, roundcorner_bot_left, roundcorner_bot_right, roundcorner_top_left, roundcorner_top_right] = TextDiagram._getParts(["cross", "line", "line_vertical", "roundcorner_bot_left", "roundcorner_bot_right", "roundcorner_top_left", "roundcorner_top_right"]);
// Format all the child items, so we can know the maximum width.
var itemTDs = [];
for (const item of this.items) {
itemTDs.push(item.toTextDiagram().expand(1, 1, 0, 0));
}
var max_item_width = Math.max(...(itemTDs.map(function (itemTD) { return itemTD.width; })));
var diagramTD = new TextDiagram(0, 0, []);
// Format the choice collection.
for (var [itemNum, itemTD] of enumerate(itemTDs)) {
var [leftPad, rightPad] = TextDiagram._gaps(max_item_width, itemTD.width);
itemTD = itemTD.expand(leftPad, rightPad, 0, 0);
var hasSeparator = true;
var leftLines = [];
var rightLines = [];
for (var i = 0; i < itemTD.height; i++) {
leftLines.push(line_vertical);
rightLines.push(line_vertical);
}
var moveEntry = false;
var moveExit = false;
if (itemNum <= this.normal) {
// Item above the line: round off the entry/exit lines upwards.
leftLines[itemTD.entry] = roundcorner_top_left;
rightLines[itemTD.exit] = roundcorner_top_right;
if (itemNum == 0) {
// First item and above the line: also remove ascenders above the item's entry and exit, suppress the separator above it.
hasSeparator = false;
for (i = 0; i < itemTD.entry; i++) {
leftLines[i] = " ";
}
for (i = 0; i < itemTD.exit; i++) {
rightLines[i] = " ";
}
}
}
if (itemNum >= this.normal) {
// Item below the line: round off the entry/exit lines downwards.
leftLines[itemTD.entry] = roundcorner_bot_left;
rightLines[itemTD.exit] = roundcorner_bot_right;
if (itemNum == 0) {
// First item and below the line: also suppress the separator above it.
hasSeparator = false;
}
if (itemNum == (this.items.length - 1)) {
// Last item and below the line: also remove descenders below the item's entry and exit
for (i = itemTD.entry + 1; i < itemTD.height; i++) {
leftLines[i] = " ";
}
for (i = itemTD.exit + 1; i < itemTD.height; i++) {
rightLines[i] = " ";
}
}
}
if (itemNum == this.normal) {
// Item on the line: entry/exit are horizontal, and sets the outer entry/exit.
leftLines[itemTD.entry] = cross;
rightLines[itemTD.exit] = cross;
moveEntry = true;
moveExit = true;
if (itemNum == 0 && itemNum == (this.items.length - 1)) {
// Only item and on the line: set entry/exit for straight through.
leftLines[itemTD.entry] = line;
rightLines[itemTD.exit] = line;
}
else if (itemNum == 0) {
// First item and on the line: set entry/exit for no ascenders.
leftLines[itemTD.entry] = roundcorner_top_right;
rightLines[itemTD.exit] = roundcorner_top_left;
}
else if (itemNum == (this.items.length - 1)) {
// Last item and on the line: set entry/exit for no descenders.
leftLines[itemTD.entry] = roundcorner_bot_right;
rightLines[itemTD.exit] = roundcorner_bot_left;
}
}
var leftJointTD = new TextDiagram(itemTD.entry, itemTD.entry, leftLines);
var rightJointTD = new TextDiagram(itemTD.exit, itemTD.exit, rightLines);
itemTD = leftJointTD.appendRight(itemTD, "").appendRight(rightJointTD, "");
var separator = hasSeparator ? [line_vertical + (" ".repeat(TextDiagram._maxWidth(diagramTD, itemTD) - 2)) + line_vertical] : [];
diagramTD = diagramTD.appendBelow(itemTD, separator, moveEntry, moveExit);
}
return diagramTD;
}
}
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 =