diff --git a/assets/js/lib/railroad.js b/assets/js/lib/railroad.js new file mode 100644 index 0000000..ab8f126 --- /dev/null +++ b/assets/js/lib/railroad.js @@ -0,0 +1,1420 @@ +"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: 11, // minimum vertical separation between things. For a 3px stroke, must be at least 4 + AR: 11, // radius of arcs + DIAGRAM_CLASS: 'railroad-diagram', // class to put on the root + STROKE_ODD_PIXEL_LENGTH: false, // 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: 11, // 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, '&').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 += '\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++; + } +}