nomnoml
Version:
The sassy UML renderer that generates diagrams from text
1,214 lines (1,205 loc) • 71.8 kB
JavaScript
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('graphre')) :
typeof define === 'function' && define.amd ? define(['exports', 'graphre'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.nomnoml = {}, global.graphre));
})(this, (function (exports, graphre) { 'use strict';
function range([min, max], count) {
const output = [];
for (let i = 0; i < count; i++)
output.push(min + ((max - min) * i) / (count - 1));
return output;
}
function sum(list, transform) {
let summa = 0;
for (let i = 0, len = list.length; i < len; i++)
summa += transform(list[i]);
return summa;
}
function last(list) {
return list[list.length - 1];
}
function indexBy(list, key) {
const obj = {};
for (let i = 0; i < list.length; i++)
obj[list[i][key]] = list[i];
return obj;
}
var util = /*#__PURE__*/Object.freeze({
__proto__: null,
indexBy: indexBy,
last: last,
range: range,
sum: sum
});
function buildStyle(conf, title, body = {}) {
return {
title: {
bold: title.bold || false,
underline: title.underline || false,
italic: title.italic || false,
center: title.center || false,
},
body: {
bold: body.bold || false,
underline: body.underline || false,
italic: body.italic || false,
center: body.center || false,
},
dashed: conf.dashed || false,
fill: conf.fill || undefined,
stroke: conf.stroke || undefined,
visual: conf.visual || 'class',
direction: conf.direction || undefined,
};
}
const styles = {
abstract: buildStyle({ visual: 'class' }, { center: true, italic: true }),
actor: buildStyle({ visual: 'actor' }, { center: true }, { center: true }),
choice: buildStyle({ visual: 'rhomb' }, { center: true }, { center: true }),
class: buildStyle({ visual: 'class' }, { center: true, bold: true }),
database: buildStyle({ visual: 'database' }, { center: true, bold: true }, { center: true }),
end: buildStyle({ visual: 'end' }, {}),
frame: buildStyle({ visual: 'frame' }, {}),
hidden: buildStyle({ visual: 'hidden' }, {}),
input: buildStyle({ visual: 'input' }, { center: true }),
instance: buildStyle({ visual: 'class' }, { center: true, underline: true }),
label: buildStyle({ visual: 'none' }, { center: true }),
lollipop: buildStyle({ visual: 'lollipop' }, { center: true }),
note: buildStyle({ visual: 'note' }, {}),
pipe: buildStyle({ visual: 'pipe' }, { center: true, bold: true }),
package: buildStyle({ visual: 'package' }, {}),
receiver: buildStyle({ visual: 'receiver' }, {}),
reference: buildStyle({ visual: 'class', dashed: true }, { center: true }),
sender: buildStyle({ visual: 'sender' }, {}),
socket: buildStyle({ visual: 'socket' }, {}),
start: buildStyle({ visual: 'start' }, {}),
state: buildStyle({ visual: 'roundrect' }, { center: true }),
sync: buildStyle({ visual: 'sync' }, { center: true }),
table: buildStyle({ visual: 'table' }, { center: true, bold: true }),
transceiver: buildStyle({ visual: 'transceiver' }, {}),
usecase: buildStyle({ visual: 'ellipse' }, { center: true }, { center: true }),
};
function offsetBox(config, clas, offset) {
clas.width = Math.max(...clas.parts.map((e) => e.width ?? 0));
clas.height = sum(clas.parts, (e) => e.height ?? 0 ?? 0);
clas.dividers = [];
let y = 0;
for (const comp of clas.parts) {
comp.x = 0 + offset.x;
comp.y = y + offset.y;
comp.width = clas.width;
y += comp.height ?? 0 ?? 0;
if (comp != last(clas.parts))
clas.dividers.push([
{ x: 0, y: y },
{ x: clas.width, y: y },
]);
}
}
function box(config, clas) {
offsetBox(config, clas, { x: 0, y: 0 });
}
function icon(config, clas) {
clas.dividers = [];
clas.parts = [];
clas.width = config.fontSize * 2.5;
clas.height = config.fontSize * 2.5;
}
function labelledIcon(config, clas) {
clas.width = config.fontSize * 1.5;
clas.height = config.fontSize * 1.5;
clas.dividers = [];
let y = config.direction == 'LR' ? clas.height - config.padding : -clas.height / 2;
for (const comp of clas.parts) {
if (config.direction == 'LR') {
comp.x = clas.width / 2 - (comp.width ?? 0) / 2;
comp.y = y;
}
else {
comp.x = clas.width / 2 + config.padding / 2;
comp.y = y;
}
y += comp.height ?? 0 ?? 0;
}
}
const layouters = {
actor: function (config, clas) {
clas.width = Math.max(config.padding * 2, ...clas.parts.map((e) => e.width ?? 0));
clas.height = config.padding * 3 + sum(clas.parts, (e) => e.height ?? 0);
clas.dividers = [];
let y = config.padding * 3;
for (const comp of clas.parts) {
comp.x = 0;
comp.y = y;
comp.width = clas.width;
y += comp.height ?? 0;
if (comp != last(clas.parts))
clas.dividers.push([
{ x: config.padding, y: y },
{ x: clas.width - config.padding, y: y },
]);
}
},
class: box,
database: function (config, clas) {
clas.width = Math.max(...clas.parts.map((e) => e.width ?? 0));
clas.height = sum(clas.parts, (e) => e.height ?? 0) + config.padding * 2;
clas.dividers = [];
let y = config.padding * 1.5;
for (const comp of clas.parts) {
comp.x = 0;
comp.y = y;
comp.width = clas.width;
y += comp.height ?? 0;
if (comp != last(clas.parts)) {
const path = range([0, Math.PI], 16).map((a) => ({
x: clas.width * 0.5 * (1 - Math.cos(a)),
y: y + config.padding * (0.75 * Math.sin(a) - 0.5),
}));
clas.dividers.push(path);
}
}
},
ellipse: function (config, clas) {
const width = Math.max(...clas.parts.map((e) => e.width ?? 0));
const height = sum(clas.parts, (e) => e.height ?? 0);
clas.width = width * 1.25;
clas.height = height * 1.25;
clas.dividers = [];
let y = height * 0.125;
const sq = (x) => x * x;
const rimPos = (y) => Math.sqrt(sq(0.5) - sq(y / clas.height - 0.5)) * clas.width;
for (const comp of clas.parts) {
comp.x = width * 0.125;
comp.y = y;
comp.width = width;
y += comp.height ?? 0;
if (comp != last(clas.parts))
clas.dividers.push([
{ x: clas.width / 2 + rimPos(y) - 1, y: y },
{ x: clas.width / 2 - rimPos(y) + 1, y: y },
]);
}
},
end: icon,
frame: function (config, clas) {
const w = clas.parts[0].width ?? 0;
const h = clas.parts[0].height ?? 0;
clas.parts[0].width = h / 2 + (clas.parts[0].width ?? 0);
box(config, clas);
if (clas.dividers?.length)
clas.dividers.shift();
clas.dividers?.unshift([
{ x: 0, y: h },
{ x: w - h / 4, y: h },
{ x: w + h / 4, y: h / 2 },
{ x: w + h / 4, y: 0 },
]);
},
hidden: function (config, clas) {
clas.dividers = [];
clas.parts = [];
clas.width = 1;
clas.height = 1;
},
input: box,
lollipop: labelledIcon,
none: box,
note: box,
package: box,
pipe: function box(config, clas) {
offsetBox(config, clas, { x: -config.padding / 2, y: 0 });
},
receiver: box,
rhomb: function (config, clas) {
const width = Math.max(...clas.parts.map((e) => e.width ?? 0));
const height = sum(clas.parts, (e) => e.height ?? 0);
clas.width = width * 1.5;
clas.height = height * 1.5;
clas.dividers = [];
let y = height * 0.25;
for (const comp of clas.parts) {
comp.x = width * 0.25;
comp.y = y;
comp.width = width;
y += comp.height ?? 0;
const slope = clas.width / clas.height;
if (comp != last(clas.parts))
clas.dividers.push([
{
x: clas.width / 2 + (y < clas.height / 2 ? y * slope : (clas.height - y) * slope),
y: y,
},
{
x: clas.width / 2 - (y < clas.height / 2 ? y * slope : (clas.height - y) * slope),
y: y,
},
]);
}
},
roundrect: box,
sender: box,
socket: labelledIcon,
start: icon,
sync: function (config, clas) {
clas.dividers = [];
clas.parts = [];
if (config.direction == 'LR') {
clas.width = config.lineWidth * 3;
clas.height = config.fontSize * 5;
}
else {
clas.width = config.fontSize * 5;
clas.height = config.lineWidth * 3;
}
},
table: function (config, clas) {
if (clas.parts.length == 1) {
box(config, clas);
return;
}
const gridcells = clas.parts.slice(1);
const rows = [[]];
function isRowBreak(e) {
return !e.lines.length && !e.nodes.length && !e.assocs.length;
}
function isRowFull(e) {
const current = last(rows);
return rows[0] != current && rows[0].length == current.length;
}
function isEnd(e) {
return e == last(gridcells);
}
for (const comp of gridcells) {
if (!isEnd(comp) && isRowBreak(comp) && last(rows).length) {
rows.push([]);
}
else if (isRowFull()) {
rows.push([comp]);
}
else {
last(rows).push(comp);
}
}
const header = clas.parts[0];
const cellW = Math.max((header.width ?? 0) / rows[0].length, ...gridcells.map((e) => e.width ?? 0));
const cellH = Math.max(...gridcells.map((e) => e.height ?? 0));
clas.width = cellW * rows[0].length;
clas.height = (header.height ?? 0) + cellH * rows.length;
const hh = header.height ?? 0;
clas.dividers = [
[
{ x: 0, y: header.height ?? 0 },
{ x: 0, y: header.height ?? 0 },
],
...rows.map((e, i) => [
{ x: 0, y: hh + i * cellH },
{ x: clas.width ?? 0, y: hh + i * cellH },
]),
...rows[0].map((e, i) => [
{ x: (i + 1) * cellW, y: hh },
{ x: (i + 1) * cellW, y: clas.height },
]),
];
header.x = 0;
header.y = 0;
header.width = clas.width;
for (let i = 0; i < rows.length; i++) {
for (let j = 0; j < rows[i].length; j++) {
const cell = rows[i][j];
cell.x = j * cellW;
cell.y = hh + i * cellH;
cell.width = cellW;
}
}
clas.parts = clas.parts.filter((e) => !isRowBreak(e));
},
transceiver: box,
};
const visualizers = {
actor: function (node, x, y, config, g) {
const a = config.padding / 2;
const yp = y + a * 4;
const faceCenter = { x: node.x, y: yp - a };
g.circle(faceCenter, a).fillAndStroke();
g.path([
{ x: node.x, y: yp },
{ x: node.x, y: yp + 2 * a },
]).stroke();
g.path([
{ x: node.x - a, y: yp + a },
{ x: node.x + a, y: yp + a },
]).stroke();
g.path([
{ x: node.x - a, y: yp + a + config.padding },
{ x: node.x, y: yp + config.padding },
{ x: node.x + a, y: yp + a + config.padding },
]).stroke();
},
class: function (node, x, y, config, g) {
g.rect(x, y, node.width, node.height).fillAndStroke();
},
database: function (node, x, y, config, g) {
const pad = config.padding;
const cy = y - pad / 2;
const pi = 3.1416;
g.rect(x, y + pad, node.width, node.height - pad * 2).fill();
g.path([
{ x: x, y: cy + pad * 1.5 },
{ x: x, y: cy - pad * 0.5 + node.height },
]).stroke();
g.path([
{ x: x + node.width, y: cy + pad * 1.5 },
{ x: x + node.width, y: cy - pad * 0.5 + node.height },
]).stroke();
g.ellipse({ x: node.x, y: cy + pad * 1.5 }, node.width, pad * 1.5).fillAndStroke();
g.ellipse({ x: node.x, y: cy - pad * 0.5 + node.height }, node.width, pad * 1.5, 0, pi).fillAndStroke();
},
ellipse: function (node, x, y, config, g) {
g.ellipse({ x: node.x, y: node.y }, node.width, node.height).fillAndStroke();
},
end: function (node, x, y, config, g) {
g.circle({ x: node.x, y: y + node.height / 2 }, node.height / 3).fillAndStroke();
g.fillStyle(config.stroke);
g.circle({ x: node.x, y: y + node.height / 2 }, node.height / 3 - config.padding / 2).fill();
},
frame: function (node, x, y, config, g) {
g.rect(x, y, node.width, node.height).fillAndStroke();
},
hidden: function (node, x, y, config, g) { },
input: function (node, x, y, config, g) {
g.circuit([
{ x: x + config.padding, y: y },
{ x: x + node.width, y: y },
{ x: x + node.width - config.padding, y: y + node.height },
{ x: x, y: y + node.height },
]).fillAndStroke();
},
lollipop: function (node, x, y, config, g) {
g.circle({ x: node.x, y: y + node.height / 2 }, node.height / 2.5).fillAndStroke();
},
none: function (node, x, y, config, g) { },
note: function (node, x, y, config, g) {
g.circuit([
{ x: x, y: y },
{ x: x + node.width - config.padding, y: y },
{ x: x + node.width, y: y + config.padding },
{ x: x + node.width, y: y + node.height },
{ x: x, y: y + node.height },
{ x: x, y: y },
]).fillAndStroke();
g.path([
{ x: x + node.width - config.padding, y: y },
{ x: x + node.width - config.padding, y: y + config.padding },
{ x: x + node.width, y: y + config.padding },
]).stroke();
},
package: function (node, x, y, config, g) {
const headHeight = node.parts[0].height ?? 0;
g.rect(x, y + headHeight, node.width, node.height - headHeight).fillAndStroke();
const w = g.measureText(node.parts[0].lines[0]).width + 2 * config.padding;
g.circuit([
{ x: x, y: y + headHeight },
{ x: x, y: y },
{ x: x + w, y: y },
{ x: x + w, y: y + headHeight },
]).fillAndStroke();
},
pipe: function (node, x, y, config, g) {
const pad = config.padding;
const pi = 3.1416;
g.rect(x, y, node.width, node.height).fill();
g.path([
{ x: x, y: y },
{ x: x + node.width, y: y },
]).stroke();
g.path([
{ x: x, y: y + node.height },
{ x: x + node.width, y: y + node.height },
]).stroke();
g.ellipse({ x: x + node.width, y: node.y }, pad * 1.5, node.height).fillAndStroke();
g.ellipse({ x: x, y: node.y }, pad * 1.5, node.height, pi / 2, (pi * 3) / 2).fillAndStroke();
},
receiver: function (node, x, y, config, g) {
g.circuit([
{ x: x - config.padding, y: y },
{ x: x + node.width, y: y },
{ x: x + node.width, y: y + node.height },
{ x: x - config.padding, y: y + node.height },
{ x: x, y: y + node.height / 2 },
]).fillAndStroke();
},
rhomb: function (node, x, y, config, g) {
g.circuit([
{ x: node.x, y: y },
{ x: x + node.width, y: node.y },
{ x: node.x, y: y + node.height },
{ x: x, y: node.y },
]).fillAndStroke();
},
roundrect: function (node, x, y, config, g) {
const r = Math.min(config.padding * 2 * config.leading, node.height / 2);
g.roundRect(x, y, node.width, node.height, r).fillAndStroke();
},
sender: function (node, x, y, config, g) {
g.circuit([
{ x: x, y: y },
{ x: x + node.width - config.padding, y: y },
{ x: x + node.width, y: y + node.height / 2 },
{ x: x + node.width - config.padding, y: y + node.height },
{ x: x, y: y + node.height },
]).fillAndStroke();
},
socket: function (node, x, y, config, g) {
const from = config.direction === 'TB' ? Math.PI : Math.PI / 2;
const to = config.direction === 'TB' ? 2 * Math.PI : -Math.PI / 2;
g.ellipse({ x: node.x, y: node.y }, node.width, node.height, from, to).stroke();
},
start: function (node, x, y, config, g) {
g.fillStyle(config.stroke);
g.circle({ x: node.x, y: y + node.height / 2 }, node.height / 2.5).fill();
},
sync: function (node, x, y, config, g) {
g.fillStyle(config.stroke);
g.rect(x, y, node.width, node.height).fillAndStroke();
},
table: function (node, x, y, config, g) {
g.rect(x, y, node.width, node.height).fillAndStroke();
},
transceiver: function (node, x, y, config, g) {
g.circuit([
{ x: x - config.padding, y: y },
{ x: x + node.width - config.padding, y: y },
{ x: x + node.width, y: y + node.height / 2 },
{ x: x + node.width - config.padding, y: y + node.height },
{ x: x - config.padding, y: y + node.height },
{ x: x, y: y + node.height / 2 },
]).fillAndStroke();
},
};
function layout(measurer, config, ast) {
function measureLines(lines, fontWeight) {
if (!lines.length)
return { width: 0, height: config.padding };
measurer.setFont(config.font, config.fontSize, fontWeight, 'normal');
return {
width: Math.round(Math.max(...lines.map(measurer.textWidth)) + 2 * config.padding),
height: Math.round(measurer.textHeight() * lines.length + 2 * config.padding),
};
}
function layoutCompartment(c, compartmentIndex, style) {
const textSize = measureLines(c.lines, compartmentIndex ? 'normal' : 'bold');
if (!c.nodes.length && !c.assocs.length) {
const layoutedPart = c;
layoutedPart.width = textSize.width;
layoutedPart.height = textSize.height;
layoutedPart.offset = { x: config.padding, y: config.padding };
return;
}
const styledConfig = {
...config,
direction: style.direction ?? config.direction,
};
const layoutedNodes = c.nodes;
const layoutedAssoc = c.assocs;
for (let i = 0; i < layoutedAssoc.length; i++)
layoutedAssoc[i].id = `${i}`;
for (const e of layoutedNodes)
layoutNode(e, styledConfig);
const g = new graphre.graphlib.Graph({
multigraph: true,
});
g.setGraph({
rankdir: style.direction || config.direction,
nodesep: config.spacing,
edgesep: config.spacing,
ranksep: config.spacing,
acyclicer: config.acyclicer,
ranker: config.ranker,
});
for (const e of layoutedNodes) {
g.setNode(e.id, { width: e.layoutWidth, height: e.layoutHeight });
}
for (const r of layoutedAssoc) {
if (r.type.indexOf('_') > -1) {
g.setEdge(r.start, r.end, { minlen: 0 }, r.id);
}
else if ((config.gravity ?? 1) != 1) {
g.setEdge(r.start, r.end, { minlen: config.gravity }, r.id);
}
else {
g.setEdge(r.start, r.end, {}, r.id);
}
}
graphre.layout(g);
const rels = indexBy(c.assocs, 'id');
const nodes = indexBy(c.nodes, 'id');
for (const name of g.nodes()) {
const node = g.node(name);
nodes[name].x = node.x;
nodes[name].y = node.y;
}
let left = 0;
let right = 0;
let top = 0;
let bottom = 0;
for (const edgeObj of g.edges()) {
const edge = g.edge(edgeObj);
const start = nodes[edgeObj.v];
const end = nodes[edgeObj.w];
const rel = rels[edgeObj.name];
rel.path = [start, ...edge.points, end].map(toPoint);
const startP = rel.path[1];
const endP = rel.path[rel.path.length - 2];
layoutLabel(rel.startLabel, startP, adjustQuadrant(quadrant(startP, start) ?? 4, start, end));
layoutLabel(rel.endLabel, endP, adjustQuadrant(quadrant(endP, end) ?? 2, end, start));
left = Math.min(left, rel.startLabel.x, rel.endLabel.x, ...edge.points.map((e) => e.x), ...edge.points.map((e) => e.x));
right = Math.max(right, rel.startLabel.x + rel.startLabel.width, rel.endLabel.x + rel.endLabel.width, ...edge.points.map((e) => e.x));
top = Math.min(top, rel.startLabel.y, rel.endLabel.y, ...edge.points.map((e) => e.y));
bottom = Math.max(bottom, rel.startLabel.y + rel.startLabel.height, rel.endLabel.y + rel.endLabel.height, ...edge.points.map((e) => e.y));
}
const graph = g.graph();
const width = Math.max(graph.width + (left < 0 ? -left : 0), right - left);
const height = Math.max(graph.height + (top < 0 ? -top : 0), bottom - top);
const graphHeight = height ? height + 2 * config.gutter : 0;
const graphWidth = width ? width + 2 * config.gutter : 0;
const part = c;
part.width = Math.max(textSize.width, graphWidth) + 2 * config.padding;
part.height = textSize.height + graphHeight + config.padding;
part.offset = { x: config.padding - left, y: config.padding - top };
}
function toPoint(o) {
return { x: o.x, y: o.y };
}
function layoutLabel(label, point, quadrant) {
if (!label.text) {
label.width = 0;
label.height = 0;
label.x = point.x;
label.y = point.y;
}
else {
const fontSize = config.fontSize;
const lines = label.text.split('`');
label.width = Math.max(...lines.map((l) => measurer.textWidth(l)));
label.height = fontSize * lines.length;
label.x =
point.x + (quadrant == 1 || quadrant == 4 ? config.padding : -label.width - config.padding);
label.y =
point.y + (quadrant == 3 || quadrant == 4 ? config.padding : -label.height - config.padding);
}
}
function quadrant(point, node) {
if (point.x < node.x && point.y < node.y)
return 1;
if (point.x > node.x && point.y < node.y)
return 2;
if (point.x > node.x && point.y > node.y)
return 3;
if (point.x < node.x && point.y > node.y)
return 4;
return undefined;
}
function adjustQuadrant(quadrant, point, opposite) {
if (opposite.x == point.x || opposite.y == point.y)
return quadrant;
const flipHorizontally = [4, 3, 2, 1];
const flipVertically = [2, 1, 4, 3];
const oppositeQuadrant = opposite.y < point.y ? (opposite.x < point.x ? 2 : 1) : opposite.x < point.x ? 3 : 4;
if (oppositeQuadrant === quadrant) {
if (config.direction === 'LR')
return flipHorizontally[quadrant - 1];
if (config.direction === 'TB')
return flipVertically[quadrant - 1];
}
return quadrant;
}
function layoutNode(node, config) {
const style = config.styles[node.type] || styles.class;
for (let i = 0; i < node.parts.length; i++) {
layoutCompartment(node.parts[i], i, style);
}
const visual = layouters[style.visual] ?? layouters.class;
visual(config, node);
node.layoutWidth = (node.width ?? 0) + 2 * config.edgeMargin;
node.layoutHeight = (node.height ?? 0) + 2 * config.edgeMargin;
}
const root = ast;
layoutCompartment(root, 0, styles.class);
return root;
}
function extractDirectives(source) {
const directives = [];
for (const line of source.split('\n')) {
if (line[0] === '#') {
const [key, ...values] = line.slice(1).split(':');
directives.push({ key, value: values.join(':').trim() });
}
}
return directives;
}
function linearParse(source) {
let line = 1;
let lineStartIndex = 0;
let index = 0;
const directives = extractDirectives(source);
source = source.replace(/^[ \t]*\/\/[^\n]*/gm, '').replace(/^#[^\n]*/gm, '');
if (source.trim() === '')
return {
root: { nodes: [], assocs: [], lines: [] },
directives,
};
const part = parsePart();
if (index < source.length)
error('end of file', source[index]);
return { root: part, directives };
function advanceLineCounter() {
line++;
lineStartIndex = index;
}
function addNode(nodes, node) {
const i = nodes.findIndex((e) => e.id === node.id);
if (i === -1)
nodes.push(node);
else if (nodes[i].parts.length < node.parts.length)
nodes[i] = node;
}
function parsePart() {
const nodes = [];
const assocs = [];
const lines = [];
while (index < source.length) {
let lastIndex = index;
discard(/ /);
if (source[index] === '\n') {
pop();
advanceLineCounter();
}
else if (source[index] === ';') {
pop();
}
else if (source[index] == '|' || source[index] == ']') {
return { nodes, assocs, lines };
}
else if (source[index] == '[') {
const extracted = parseNodesAndAssocs();
for (const node of extracted.nodes)
addNode(nodes, node);
for (const assoc of extracted.assocs)
assocs.push(assoc);
}
else {
const text = parseLine().trim();
if (text)
lines.push(text);
}
if (index === lastIndex)
throw new Error('Infinite loop');
}
return { nodes, assocs, lines };
}
function parseNodesAndAssocs() {
const nodes = [];
const assocs = [];
let node = parseNode();
addNode(nodes, node);
while (index < source.length) {
let lastIndex = index;
discard(/ /);
if (isOneOf('\n', ']', '|', ';')) {
return { nodes, assocs };
}
else {
const { association, target } = parseAssociation(node);
assocs.push(association);
addNode(nodes, target);
node = target;
}
if (index === lastIndex)
throw new Error('Infinite loop');
}
return { nodes, assocs };
}
function transformEscapes(char) {
if (char === 'n')
return '\n';
return char;
}
function parseAssociation(fromNode) {
let startLabel = '';
while (index < source.length) {
let lastIndex = index;
if (isOneOf('\\')) {
pop();
startLabel += transformEscapes(pop());
}
if (isOneOf('(o-', '(-', 'o<-', 'o-', '+-', '<:-', '<-', '-'))
break;
else if (isOneOf('[', ']', '|', '<', '>', ';'))
error('label', source[index]);
else
startLabel += pop();
if (index === lastIndex)
throw new Error('Infinite loop');
}
const assoc1 = consumeOneOf('(o', '(', 'o<', 'o', '+', '<:', '<', '');
const assoc2 = consumeOneOf('--', '-/-', '-');
const assoc3 = consumeOneOf('o)', 'o', '>o', '>', ')', '+', ':>', '');
const endLabel = consumeOptional(/[^\[]/);
const target = parseNode();
return {
association: {
type: `${assoc1}${assoc2}${assoc3}`,
start: fromNode.id,
end: target.id,
startLabel: { text: startLabel.trim() },
endLabel: { text: endLabel.trim() },
},
target: target,
};
}
function parseNode() {
index++;
let attr = {};
let type = 'class';
if (source[index] == '<') {
const meta = parseMeta();
attr = meta.attr;
type = meta.type ?? 'class';
}
const parts = [parsePart()];
while (source[index] == '|') {
let lastIndex = index;
pop();
parts.push(parsePart());
if (lastIndex === index)
throw new Error('Infinite loop');
}
if (source[index] == ']') {
pop();
discard(/ /);
return { parts: parts, attr, id: attr.id ?? parts[0].lines[0], type };
}
error(']', source[index]);
}
function parseLine() {
const chars = [];
while (index < source.length) {
let lastIndex = index;
if (source[index] === '\\') {
pop();
chars.push(transformEscapes(pop()));
}
else if (source[index].match(/[\[\]|;\n]/)) {
break;
}
else {
chars.push(pop());
}
if (lastIndex === index)
throw new Error('Infinite loop');
}
return chars.join('');
}
function parseMeta() {
index++;
const type = consume(/[a-zA-Z0-9_]/);
const char = pop();
if (char == '>')
return { type, attr: {} };
if (char != ' ')
error([' ', '>'], char);
return { type, attr: parseAttrs() };
}
function parseAttrs() {
const key = consume(/[a-zA-Z0-9_]/);
const separator = pop();
if (separator != '=')
error('=', separator);
const value = consume(/[^> ]/);
const char = pop();
if (char == '>')
return { [key]: value };
if (char == ' ')
return { [key]: value, ...parseAttrs() };
error([' ', '>'], char);
}
function pop() {
const char = source[index];
index++;
return char;
}
function discard(regex) {
while (source[index]?.match(regex))
index++;
}
function consume(regex, optional) {
const start = index;
while (source[index]?.match(regex))
index++;
const end = index;
if (!optional && start == end)
error(regex, source[index]);
return source.slice(start, end);
}
function consumeOptional(regex) {
return consume(regex, 'optional');
}
function isOneOf(...patterns) {
for (const pattern of patterns) {
const token = source.slice(index, index + pattern.length);
if (token == pattern) {
return true;
}
}
return false;
}
function consumeOneOf(...patterns) {
for (const pattern of patterns) {
const token = source.slice(index, index + pattern.length);
if (token == pattern) {
index += pattern.length;
return pattern;
}
}
const maxPatternLength = Math.max(...patterns.map((e) => e.length));
if (index + 1 >= source.length)
error(patterns, undefined);
else
error(patterns, source.slice(index + 1, maxPatternLength));
}
function error(expected, actual) {
throw new ParseError(expected, actual, line, index - lineStartIndex);
}
}
function serializeValue(value) {
if (value == null)
return 'end of file';
if (value instanceof RegExp)
return value.toString().slice(1, -1);
if (Array.isArray(value))
return value.map(serializeValue).join(' or ');
return JSON.stringify(value);
}
class ParseError extends Error {
constructor(expected, actual, line, column) {
const exp = serializeValue(expected);
const act = serializeValue(actual);
super(`Parse error at line ${line} column ${column}, expected ${exp} but got ${act}`);
this.expected = exp;
this.actual = act;
this.line = line;
this.column = column;
}
}
function parse(source) {
const { root, directives } = linearParse(source);
return { root, directives, config: getConfig(directives) };
function directionToDagre(word) {
if (word == 'down')
return 'TB';
if (word == 'right')
return 'LR';
else
return 'TB';
}
function parseRanker(word) {
if (word == 'network-simplex' || word == 'tight-tree' || word == 'longest-path') {
return word;
}
return 'network-simplex';
}
function parseCustomStyle(styleDef) {
const floatingKeywords = styleDef.replace(/[a-z]*=[^ ]+/g, '');
const titleDef = last(styleDef.match('title=([^ ]*)') || ['']);
const bodyDef = last(styleDef.match('body=([^ ]*)') || ['']);
return {
title: {
bold: titleDef.includes('bold') || floatingKeywords.includes('bold'),
underline: titleDef.includes('underline') || floatingKeywords.includes('underline'),
italic: titleDef.includes('italic') || floatingKeywords.includes('italic'),
center: !(titleDef.includes('left') || styleDef.includes('align=left')),
},
body: {
bold: bodyDef.includes('bold'),
underline: bodyDef.includes('underline'),
italic: bodyDef.includes('italic'),
center: bodyDef.includes('center'),
},
dashed: styleDef.includes('dashed'),
fill: last(styleDef.match('fill=([^ ]*)') || []),
stroke: last(styleDef.match('stroke=([^ ]*)') || []),
visual: (last(styleDef.match('visual=([^ ]*)') || []) || 'class'),
direction: directionToDagre(last(styleDef.match('direction=([^ ]*)') || [])),
};
}
function getConfig(directives) {
const d = Object.fromEntries(directives.map((e) => [e.key, e.value]));
const userStyles = {};
for (const key in d) {
if (key[0] != '.')
continue;
const styleDef = d[key];
userStyles[key.substring(1)] = parseCustomStyle(styleDef);
}
return {
arrowSize: +d.arrowSize || 1,
bendSize: +d.bendSize || 0.3,
direction: directionToDagre(d.direction),
gutter: +d.gutter || 20,
edgeMargin: +d.edgeMargin || 0,
gravity: Math.round(+(d.gravity ?? 1)),
edges: d.edges == 'hard' ? 'hard' : 'rounded',
fill: (d.fill || '#eee8d5;#fdf6e3;#eee8d5;#fdf6e3').split(';'),
background: d.background || 'transparent',
fillArrows: d.fillArrows === 'true',
font: d.font || 'Helvetica',
fontSize: +d.fontSize || 12,
leading: +d.leading || 1.35,
lineWidth: +d.lineWidth || 3,
padding: +d.padding || 8,
spacing: +d.spacing || 40,
stroke: d.stroke || '#33322E',
title: d.title || '',
zoom: +d.zoom || 1,
acyclicer: d.acyclicer === 'greedy' ? 'greedy' : undefined,
ranker: parseRanker(d.ranker),
styles: { ...styles, ...userStyles },
};
}
}
function add(a, b) {
return { x: a.x + b.x, y: a.y + b.y };
}
function diff(a, b) {
return { x: a.x - b.x, y: a.y - b.y };
}
function mult(v, factor) {
return { x: factor * v.x, y: factor * v.y };
}
function mag(v) {
return Math.sqrt(v.x * v.x + v.y * v.y);
}
function normalize(v) {
return mult(v, 1 / mag(v));
}
function rot(a) {
return { x: a.y, y: -a.x };
}
const empty = false;
const filled = true;
function getPath(config, r) {
const path = r.path.slice(1, -1);
const endDir = normalize(diff(path[path.length - 2], last(path)));
const startDir = normalize(diff(path[1], path[0]));
const size = (config.spacing * config.arrowSize) / 30;
const head = 0;
const end = path.length - 1;
const copy = path.map((p) => ({ x: p.x, y: p.y }));
const tokens = r.type.split(/[-_]/);
copy[head] = add(copy[head], mult(startDir, size * terminatorSize(tokens[0])));
copy[end] = add(copy[end], mult(endDir, size * terminatorSize(last(tokens))));
return copy;
}
function terminatorSize(id) {
if (id === '>' || id === '<')
return 5;
if (id === ':>' || id === '<:')
return 10;
if (id === '+')
return 14;
if (id === 'o')
return 14;
if (id === '(' || id === ')')
return 11;
if (id === '(o' || id === 'o)')
return 11;
if (id === '>o' || id === 'o<')
return 15;
return 0;
}
function drawTerminators(g, config, r) {
const start = r.path[1];
const end = r.path[r.path.length - 2];
const path = r.path.slice(1, -1);
const tokens = r.type.split(/[-_]/);
drawArrowEnd(last(tokens), path, end);
drawArrowEnd(tokens[0], path.reverse(), start);
function drawArrowEnd(id, path, end) {
const dir = normalize(diff(path[path.length - 2], last(path)));
const size = (config.spacing * config.arrowSize) / 30;
if (id === '>' || id === '<')
drawArrow(dir, size, filled, end);
else if (id === ':>' || id === '<:')
drawArrow(dir, size, empty, end);
else if (id === '+')
drawDiamond(dir, size, filled, end);
else if (id === 'o')
drawDiamond(dir, size, empty, end);
else if (id === '(' || id === ')') {
drawSocket(dir, size, 11, end);
drawStem(dir, size, 5, end);
}
else if (id === '(o' || id === 'o)') {
drawSocket(dir, size, 11, end);
drawStem(dir, size, 5, end);
drawBall(dir, size, 11, end);
}
else if (id === '>o' || id === 'o<') {
drawArrow(dir, size * 0.75, empty, add(end, mult(dir, size * 10)));
drawStem(dir, size, 8, end);
drawBall(dir, size, 8, end);
}
}
function drawBall(nv, size, stem, end) {
const center = add(end, mult(nv, size * stem));
g.fillStyle(config.fill[0]);
g.ellipse(center, size * 6, size * 6).fillAndStroke();
}
function drawStem(nv, size, stem, end) {
const center = add(end, mult(nv, size * stem));
g.path([center, end]).stroke();
}
function drawSocket(nv, size, stem, end) {
const base = add(end, mult(nv, size * stem));
const t = rot(nv);
const socket = range([-Math.PI / 2, Math.PI / 2], 12).map((a) => add(base, add(mult(nv, -6 * size * Math.cos(a)), mult(t, 6 * size * Math.sin(a)))));
g.path(socket).stroke();
}
function drawArrow(nv, size, isOpen, end) {
const x = (s) => add(end, mult(nv, s * size));
const y = (s) => mult(rot(nv), s * size);
const arrow = [
add(x(10), y(4)),
x(isOpen && !config.fillArrows ? 5 : 10),
add(x(10), y(-4)),
end,
];
g.fillStyle(isOpen ? config.stroke : config.fill[0]);
g.circuit(arrow).fillAndStroke();
}
function drawDiamond(nv, size, isOpen, end) {
const x = (s) => add(end, mult(nv, s * size));
const y = (s) => mult(rot(nv), s * size);
const arrow = [add(x(7), y(4)), x(14), add(x(7), y(-4)), end];
g.save();
g.fillStyle(isOpen ? config.stroke : config.fill[0]);
g.circuit(arrow).fillAndStroke();
g.restore();
}
}
function render(graphics, config, compartment) {
const g = graphics;
function renderCompartment(compartment, color, style, level) {
g.save();
g.translate(compartment.offset.x, compartment.offset.y);
g.fillStyle(color || config.stroke);
for (let i = 0; i < compartment.lines.length; i++) {
const text = compartment.lines[i];
g.textAlign(style.center ? 'center' : 'left');
const x = style.center ? compartment.width / 2 - config.padding : 0;
let y = (0.5 + (i + 0.5) * config.leading) * config.fontSize;
if (text) {
g.fillText(text, x, y);
}
if (style.underline) {
const w = g.measureText(text).width;
y += Math.round(config.fontSize * 0.2) + 0.5;
if (style.center) {
g.path([
{ x: x - w / 2, y: y },
{ x: x + w / 2, y: y },
]).stroke();
}
else {
g.path([
{ x: x, y: y },
{ x: x + w, y: y },
]).stroke();
}
g.lineWidth(config.lineWidth);
}
}
g.save();
g.translate(config.gutter, config.gutter);
for (const r of compartment.assocs)
renderRelation(r);
for (const n of compartment.nodes)
renderNode(n, level);
g.restore();
g.restore();
}
function renderNode(node, level) {
const x = node.x - node.width / 2;
const y = node.y - node.height / 2;
const style = config.styles[node.type] || styles.class;
g.save();
g.setData('name', node.id);
g.setData('compartment', undefined);
g.save();
g.fillStyle(style.fill || config.fill[level] || last(config.fill));
g.strokeStyle(style.stroke || config.stroke);
if (style.dashed) {
const dash = Math.max(4, 2 * config.lineWidth);
g.setLineDash([dash, dash]);
}
const drawNode = visualizers[style.visual] || visualizers.class;
drawNode(node, x, y, config, g);
for (const divider of node.dividers) {
g.path(divider.map((e) => add(e, { x, y }))).stroke();
}
g.restore();
let partIndex = 0;
for (let part of node.parts) {
const textStyle = part === node.parts[0] ? style.title : style.body;
g.save();
g.setData('compartment', String(partIndex));
g.translate(x + part.x, y + part.y);
g.setFont(config.font, config.fontSize, textStyle.bold ? 'bold' : 'normal', textStyle.italic ? 'italic' : 'normal');
renderCompartment(part, style.stroke, textStyle, level + 1);
partIndex++;
g.restore();
}
g.restore();
}
function strokePath(p) {
if (config.edges === 'rounded') {
const radius = config.spacing * config.bendSize;
g.beginPath();
g.moveTo(p[0].x, p[0].y);
for (let i = 1; i < p.length - 1; i++) {
g.arcTo(p[i].x, p[i].y, p[i + 1].x, p[i + 1].y, radius);
}
g.lineTo(last(p).x, last(p).y);
g.stroke();
}
else
g.path(p).stroke();
}
function renderLabel(label) {
if (!label || !label.text)
return;
const fontSize = config.fontSize;
const lines = label.text.split('`');
for (let i = 0; i < lines.length; i++) {
g.fillText(lines[i], label.x, label.y + fontSize * (i + 1));
}
}
function renderRelation(r) {
const path = getPath(config, r);
g.fillStyle(config.stroke);
g.setFont(config.font, config.fontSize, 'normal', 'normal');
renderLabel(r.startLabel);
renderLabel(r.endLabel);
if (r.type !== '-/-') {
if (r.type.includes('--')) {
const dash = Math.max(4, 2 * config.lineWidth);
g.save();
g.setLineDash([dash, dash]);
strokePath(path);
g.restore();
}
else
strokePath(path);
}
drawTerminators(g, confi