Relocation of files

This commit is contained in:
Knut Sveidqvist
2022-09-21 11:03:33 +02:00
parent 24ef5f9fe4
commit 8dd82839cb
292 changed files with 12479 additions and 892 deletions

View File

@@ -0,0 +1,20 @@
const { esmBuild, esmCoreBuild, iifeBuild } = require('./util.cjs');
const { build } = require('esbuild');
const handler = (e) => {
console.error(e);
process.exit(1);
};
const watch = process.argv.includes('--watch');
// mermaid.js
build(iifeBuild({ minify: false, watch })).catch(handler);
// mermaid.esm.mjs
build(esmBuild({ minify: false, watch })).catch(handler);
// mermaid.min.js
build(iifeBuild()).catch(handler);
// mermaid.esm.min.mjs
build(esmBuild()).catch(handler);
// mermaid.core.mjs (node_modules unbundled)
build(esmCoreBuild()).catch(handler);

View File

@@ -0,0 +1,14 @@
const { Generator } = require('jison');
exports.transformJison = (src) => {
const parser = new Generator(src, {
moduleType: 'js',
'token-stack': true,
});
const source = parser.generate({ moduleMain: '() => {}' });
const exporter = `
parser.parser = parser;
export { parser };
export default parser;
`;
return `${source} ${exporter}`;
};

View File

@@ -0,0 +1,79 @@
const esbuild = require('esbuild');
const http = require('http');
const { iifeBuild, esmBuild } = require('./util.cjs');
const express = require('express');
// Start 2 esbuild servers. One for IIFE and one for ESM
// Serve 2 static directories: demo & cypress/platform
// Have 3 entry points:
// mermaid: './src/mermaid',
// e2e: './cypress/platform/viewer.js',
// 'bundle-test': './cypress/platform/bundle-test.js',
const getEntryPointsAndExtensions = (format) => {
return {
entryPoints: {
mermaid: './src/mermaid',
e2e: 'cypress/platform/viewer.js',
'bundle-test': 'cypress/platform/bundle-test.js',
},
outExtension: { '.js': format === 'iife' ? '.js' : '.esm.mjs' },
};
};
const generateHandler = (server) => {
return (req, res) => {
const options = {
hostname: server.host,
port: server.port,
path: req.url,
method: req.method,
headers: req.headers,
};
// Forward each incoming request to esbuild
const proxyReq = http.request(options, (proxyRes) => {
// If esbuild returns "not found", send a custom 404 page
if (proxyRes.statusCode === 404) {
if (!req.url.endsWith('.html')) {
res.writeHead(404, { 'Content-Type': 'text/html' });
res.end('<h1>A custom 404 page</h1>');
return;
}
}
// Otherwise, forward the response from esbuild to the client
res.writeHead(proxyRes.statusCode, proxyRes.headers);
proxyRes.pipe(res, { end: true });
});
// Forward the body of the request to esbuild
req.pipe(proxyReq, { end: true });
};
};
(async () => {
const iifeServer = await esbuild.serve(
{},
{
...iifeBuild({ minify: false, outfile: undefined, outdir: 'dist' }),
...getEntryPointsAndExtensions('iife'),
}
);
const esmServer = await esbuild.serve(
{},
{
...esmBuild({ minify: false, outfile: undefined, outdir: 'dist' }),
...getEntryPointsAndExtensions('esm'),
}
);
const app = express();
app.use(express.static('demos'));
app.use(express.static('cypress/platform'));
app.all('/mermaid.js', generateHandler(iifeServer));
app.all('/mermaid.esm.mjs', generateHandler(esmServer));
app.all('/e2e.esm.mjs', generateHandler(esmServer));
app.all('/bundle-test.esm.mjs', generateHandler(esmServer));
app.listen(9000, () => {
console.log(`Listening on http://localhost:9000`);
});
})();

View File

@@ -0,0 +1,94 @@
const { transformJison } = require('./jisonTransformer.cjs');
const fs = require('fs');
const { dependencies } = require('../package.json');
/** @typedef {import('esbuild').BuildOptions} Options */
/**
* @param {Options} override
* @returns {Options}
*/
const buildOptions = (override = {}) => {
return {
bundle: true,
minify: true,
keepNames: true,
banner: { js: '"use strict";' },
globalName: 'mermaid',
platform: 'browser',
tsconfig: 'tsconfig.json',
resolveExtensions: ['.ts', '.js', '.mjs', '.json', '.jison'],
external: ['require', 'fs', 'path'],
entryPoints: ['src/mermaid.ts'],
outfile: 'dist/mermaid-mindmap.min.js',
plugins: [jisonPlugin],
sourcemap: 'external',
...override,
};
};
/**
* Build options for mermaid.esm.* build.
*
* For ESM browser use.
*
* @param {Options} override - Override options.
* @returns {Options} ESBuild build options.
*/
exports.esmBuild = (override = { minify: true }) => {
return buildOptions({
format: 'esm',
outfile: `dist/mermaid.esm${override.minify ? '.min' : ''}.mjs`,
...override,
});
};
/**
* Build options for mermaid.core.* build.
*
* This build does not bundle `./node_modules/`, as it is designed to be used with
* Webpack/ESBuild/Vite to use mermaid inside an app/website.
*
* @param {Options} override - Override options.
* @returns {Options} ESBuild build options.
*/
exports.esmCoreBuild = (override) => {
return buildOptions({
format: 'esm',
outfile: `dist/mermaid.core.mjs`,
external: ['require', 'fs', 'path', ...Object.keys(dependencies)],
platform: 'neutral',
...override,
});
};
/**
* Build options for mermaid.js build.
*
* For IIFE browser use (where ESM is not yet supported).
*
* @param {Options} override - Override options.
* @returns {Options} ESBuild build options.
*/
exports.iifeBuild = (override = { minify: true }) => {
return buildOptions({
outfile: `dist/mermaid${override.minify ? '.min' : ''}.js`,
format: 'iife',
footer: {
js: 'mermaid = mermaid.default;',
},
...override,
});
};
const jisonPlugin = {
name: 'jison',
setup(build) {
build.onLoad({ filter: /\.jison$/ }, async (args) => {
// Load the file from the file system
const source = await fs.promises.readFile(args.path, 'utf8');
const contents = transformJison(source);
return { contents, warnings: [] };
});
},
};

View File

@@ -0,0 +1,78 @@
{
"name": "@mermaid-js/mermaid-mindmap",
"version": "9.2.0-rc1",
"description": "Markdownish syntax for generating flowcharts, sequence diagrams, class diagrams, gantt charts and git graphs.",
"main": "dist/mermaid-mindmap.core.mjs",
"module": "dist/mermaid-mindmap.core.mjs",
"type": "module",
"exports": {
".": {
"require": "./dist/mermaid-mindmap.min.js",
"import": "./dist/mermaid-mindmap.core.mjs"
},
"./*": "./*"
},
"keywords": [
"diagram",
"markdown",
"mindmap",
"mermaid"
],
"scripts": {
"clean": "rimraf dist",
"build:code": "node .esbuild/esbuild.cjs",
"build:types": "tsc -p ./tsconfig.json --emitDeclarationOnly",
"build:watch": "yarn build:code --watch",
"build:esbuild": "concurrently \"yarn build:code\" \"yarn build:types\"",
"build": "yarn clean; yarn build:esbuild",
"dev": "node .esbuild/serve.cjs",
"docs:build": "ts-node-esm src/docs.mts",
"docs:verify": "yarn docs:build --verify",
"postbuild": "documentation build src/mermaidAPI.ts src/config.ts src/defaultConfig.ts --shallow -f md --markdown-toc false > src/docs/Setup.md && prettier --write src/docs/Setup.md",
"release": "yarn build",
"lint": "eslint --cache --ignore-path .gitignore . && yarn lint:jison && prettier --check .",
"lint:fix": "eslint --fix --ignore-path .gitignore . && prettier --write .",
"lint:jison": "ts-node-esm src/jison/lint.mts",
"cypress": "cypress run",
"cypress:open": "cypress open",
"e2e": "start-server-and-test dev http://localhost:9000/ cypress",
"ci": "vitest run",
"test": "yarn lint && vitest run",
"test:watch": "vitest --coverage --watch",
"prepublishOnly": "yarn build && yarn test",
"prepare": "concurrently \"husky install\" \"yarn build\"",
"pre-commit": "lint-staged"
},
"repository": {
"type": "git",
"url": "https://github.com/mermaid-js/mermaid"
},
"author": "Knut Sveidqvist",
"license": "MIT",
"standard": {
"ignore": [
"**/parser/*.js",
"dist/**/*.js",
"cypress/**/*.js"
],
"globals": [
"page"
]
},
"dependencies": {
"@braintree/sanitize-url": "^6.0.0",
"d3": "^7.0.0",
"non-layered-tidy-tree-layout": "^2.0.2"
},
"devDependencies": {},
"resolutions": {
"d3": "^7.0.0"
},
"files": [
"dist"
],
"sideEffects": [
"**/*.css",
"**/*.scss"
]
}

View File

@@ -0,0 +1,5 @@
import { DiagramDetector } from '../../diagram-api/detectType';
export const mindmapDetector: DiagramDetector = (txt) => {
return txt.match(/^\s*mindmap/) !== null;
};

View File

@@ -0,0 +1,328 @@
import { parser as mindmap } from './parser/mindmap';
import * as mindmapDB from './mindmapDb';
import { setLogLevel } from '../../diagram-api/diagramAPI';
describe('when parsing a mindmap ', function () {
beforeEach(function () {
mindmap.yy = mindmapDB;
mindmap.yy.clear();
setLogLevel('trace');
});
describe('hiearchy', function () {
it('MMP-1 should handle a simple root definition abc122', function () {
let str = `mindmap
root`;
mindmap.parse(str);
// console.log('Time for checks', mindmap.yy.getMindmap().descr);
expect(mindmap.yy.getMindmap().descr).toEqual('root');
});
it('MMP-2 should handle a hierachial mindmap definition', function () {
let str = `mindmap
root
child1
child2
`;
mindmap.parse(str);
const mm = mindmap.yy.getMindmap();
expect(mm.descr).toEqual('root');
expect(mm.children.length).toEqual(2);
expect(mm.children[0].descr).toEqual('child1');
expect(mm.children[1].descr).toEqual('child2');
});
it('3 should handle a simple root definition with a shape and without an id abc123', function () {
let str = `mindmap
(root)`;
mindmap.parse(str);
// console.log('Time for checks', mindmap.yy.getMindmap().descr);
expect(mindmap.yy.getMindmap().descr).toEqual('root');
});
it('MMP-4 should handle a deeper hierachial mindmap definition', function () {
let str = `mindmap
root
child1
leaf1
child2`;
mindmap.parse(str);
const mm = mindmap.yy.getMindmap();
expect(mm.descr).toEqual('root');
expect(mm.children.length).toEqual(2);
expect(mm.children[0].descr).toEqual('child1');
expect(mm.children[0].children[0].descr).toEqual('leaf1');
expect(mm.children[1].descr).toEqual('child2');
});
it('5 Multiple roots are illegal', function () {
let str = `mindmap
root
fakeRoot`;
try {
mindmap.parse(str);
// Fail test if above expression doesn't throw anything.
expect(true).toBe(false);
} catch (e) {
expect(e.message).toBe(
'There can be only one root. No parent could be found for ("fakeRoot")'
);
}
});
it('MMP-6 real root in wrong place', function () {
let str = `mindmap
root
fakeRoot
realRootWrongPlace`;
try {
mindmap.parse(str);
// Fail test if above expression doesn't throw anything.
expect(true).toBe(false);
} catch (e) {
expect(e.message).toBe(
'There can be only one root. No parent could be found for ("fakeRoot")'
);
}
});
});
describe('nodes', function () {
it('MMP-7 should handle an id and type for a node definition', function () {
let str = `mindmap
root[The root]
`;
mindmap.parse(str);
const mm = mindmap.yy.getMindmap();
expect(mm.nodeId).toEqual('root');
expect(mm.descr).toEqual('The root');
expect(mm.type).toEqual(mindmap.yy.nodeType.RECT);
});
it('MMP-8 should handle an id and type for a node definition', function () {
let str = `mindmap
root
theId(child1)`;
mindmap.parse(str);
const mm = mindmap.yy.getMindmap();
expect(mm.descr).toEqual('root');
expect(mm.children.length).toEqual(1);
const child = mm.children[0];
expect(child.descr).toEqual('child1');
expect(child.nodeId).toEqual('theId');
expect(child.type).toEqual(mindmap.yy.nodeType.ROUNDED_RECT);
});
it('MMP-9 should handle an id and type for a node definition', function () {
let str = `mindmap
root
theId(child1)`;
mindmap.parse(str);
const mm = mindmap.yy.getMindmap();
expect(mm.descr).toEqual('root');
expect(mm.children.length).toEqual(1);
const child = mm.children[0];
expect(child.descr).toEqual('child1');
expect(child.nodeId).toEqual('theId');
expect(child.type).toEqual(mindmap.yy.nodeType.ROUNDED_RECT);
});
it('MMP-10 mutiple types (circle)', function () {
let str = `mindmap
root((the root))
`;
mindmap.parse(str);
const mm = mindmap.yy.getMindmap();
expect(mm.descr).toEqual('the root');
expect(mm.children.length).toEqual(0);
expect(mm.type).toEqual(mindmap.yy.nodeType.CIRCLE);
});
it('MMP-11 mutiple types (cloud)', function () {
let str = `mindmap
root)the root(
`;
mindmap.parse(str);
const mm = mindmap.yy.getMindmap();
expect(mm.descr).toEqual('the root');
expect(mm.children.length).toEqual(0);
expect(mm.type).toEqual(mindmap.yy.nodeType.CLOUD);
});
it('MMP-12 mutiple types (bang)', function () {
let str = `mindmap
root))the root((
`;
mindmap.parse(str);
const mm = mindmap.yy.getMindmap();
expect(mm.descr).toEqual('the root');
expect(mm.children.length).toEqual(0);
expect(mm.type).toEqual(mindmap.yy.nodeType.BANG);
});
});
describe('decorations', function () {
it('MMP-13 should be possible to set an icon for the node', function () {
let str = `mindmap
root[The root]
::icon(bomb)
`;
// ::class1 class2
mindmap.parse(str);
const mm = mindmap.yy.getMindmap();
expect(mm.nodeId).toEqual('root');
expect(mm.descr).toEqual('The root');
expect(mm.type).toEqual(mindmap.yy.nodeType.RECT);
expect(mm.icon).toEqual('bomb');
});
it('MMP-14 should be possible to set classes for the node', function () {
let str = `mindmap
root[The root]
:::m-4 p-8
`;
// ::class1 class2
mindmap.parse(str);
const mm = mindmap.yy.getMindmap();
expect(mm.nodeId).toEqual('root');
expect(mm.descr).toEqual('The root');
expect(mm.type).toEqual(mindmap.yy.nodeType.RECT);
expect(mm.class).toEqual('m-4 p-8');
});
it('MMP-15 should be possible to set both classes and icon for the node', function () {
let str = `mindmap
root[The root]
:::m-4 p-8
::icon(bomb)
`;
// ::class1 class2
mindmap.parse(str);
const mm = mindmap.yy.getMindmap();
expect(mm.nodeId).toEqual('root');
expect(mm.descr).toEqual('The root');
expect(mm.type).toEqual(mindmap.yy.nodeType.RECT);
expect(mm.class).toEqual('m-4 p-8');
expect(mm.icon).toEqual('bomb');
});
it('MMP-16 should be possible to set both classes and icon for the node', function () {
let str = `mindmap
root[The root]
::icon(bomb)
:::m-4 p-8
`;
// ::class1 class2
mindmap.parse(str);
const mm = mindmap.yy.getMindmap();
expect(mm.nodeId).toEqual('root');
expect(mm.descr).toEqual('The root');
expect(mm.type).toEqual(mindmap.yy.nodeType.RECT);
expect(mm.class).toEqual('m-4 p-8');
expect(mm.icon).toEqual('bomb');
});
});
describe('descriptions', function () {
it('MMP-17 should be possible to use node syntax in the descriptions', function () {
let str = `mindmap
root["String containing []"]
`;
mindmap.parse(str);
const mm = mindmap.yy.getMindmap();
expect(mm.nodeId).toEqual('root');
expect(mm.descr).toEqual('String containing []');
});
it('MMP-18 should be possible to use node syntax in the descriptions in children', function () {
let str = `mindmap
root["String containing []"]
child1["String containing ()"]
`;
mindmap.parse(str);
const mm = mindmap.yy.getMindmap();
expect(mm.nodeId).toEqual('root');
expect(mm.descr).toEqual('String containing []');
expect(mm.children.length).toEqual(1);
expect(mm.children[0].descr).toEqual('String containing ()');
});
it('MMP-19 should be possible to have a child after a class assignment', function () {
let str = `mindmap
root(Root)
Child(Child)
:::hot
a(a)
b[New Stuff]`;
mindmap.parse(str);
const mm = mindmap.yy.getMindmap();
expect(mm.nodeId).toEqual('root');
expect(mm.descr).toEqual('Root');
expect(mm.children.length).toEqual(1);
const child = mm.children[0];
expect(child.nodeId).toEqual('Child');
expect(child.children[0].nodeId).toEqual('a');
expect(child.children.length).toEqual(2);
expect(child.children[1].nodeId).toEqual('b');
});
});
it('MMP-20 should be possible to have meaningless empty rows in a mindmap abc124', function () {
let str = `mindmap
root(Root)
Child(Child)
a(a)
b[New Stuff]`;
mindmap.parse(str);
const mm = mindmap.yy.getMindmap();
expect(mm.nodeId).toEqual('root');
expect(mm.descr).toEqual('Root');
expect(mm.children.length).toEqual(1);
const child = mm.children[0];
expect(child.nodeId).toEqual('Child');
expect(child.children[0].nodeId).toEqual('a');
expect(child.children.length).toEqual(2);
expect(child.children[1].nodeId).toEqual('b');
});
it('MMP-21 should be possible to have comments in a mindmap', function () {
let str = `mindmap
root(Root)
Child(Child)
a(a)
%% This is a comment
b[New Stuff]`;
mindmap.parse(str);
const mm = mindmap.yy.getMindmap();
expect(mm.nodeId).toEqual('root');
expect(mm.descr).toEqual('Root');
expect(mm.children.length).toEqual(1);
const child = mm.children[0];
expect(child.nodeId).toEqual('Child');
expect(child.children[0].nodeId).toEqual('a');
expect(child.children.length).toEqual(2);
expect(child.children[1].nodeId).toEqual('b');
});
it('MMP-22 should be possible to have comments at the end of a line', function () {
let str = `mindmap
root(Root)
Child(Child)
a(a) %% This is a comment
b[New Stuff]`;
mindmap.parse(str);
const mm = mindmap.yy.getMindmap();
expect(mm.nodeId).toEqual('root');
expect(mm.descr).toEqual('Root');
expect(mm.children.length).toEqual(1);
const child = mm.children[0];
expect(child.nodeId).toEqual('Child');
expect(child.children[0].nodeId).toEqual('a');
expect(child.children.length).toEqual(2);
expect(child.children[1].nodeId).toEqual('b');
});
});

View File

@@ -0,0 +1,151 @@
/** Created by knut on 15-01-14. */
import { sanitizeText, getConfig, log as _log } from '../../diagram-api/diagramAPI';
let nodes = [];
let cnt = 0;
let elements = {};
export const clear = () => {
nodes = [];
cnt = 0;
elements = {};
};
const getParent = function (level) {
for (let i = nodes.length - 1; i >= 0; i--) {
if (nodes[i].level < level) {
return nodes[i];
}
}
// No parent found
return null;
};
export const getMindmap = () => {
return nodes.length > 0 ? nodes[0] : null;
};
export const addNode = (level, id, descr, type) => {
log.info('addNode', level, id, descr, type);
const conf = getConfig();
const node = {
id: cnt++,
nodeId: sanitizeText(id),
level,
descr: sanitizeText(descr),
type,
children: [],
width: getConfig().mindmap.maxNodeWidth,
};
switch (node.type) {
case nodeType.ROUNDED_RECT:
node.padding = 2 * conf.mindmap.padding;
break;
case nodeType.RECT:
node.padding = 2 * conf.mindmap.padding;
break;
default:
node.padding = conf.mindmap.padding;
}
const parent = getParent(level);
if (parent) {
parent.children.push(node);
// Keep all nodes in the list
nodes.push(node);
} else {
if (nodes.length === 0) {
// First node, the root
nodes.push(node);
} else {
// Syntax error ... there can only bee one root
let error = new Error(
'There can be only one root. No parent could be found for ("' + node.descr + '")'
);
error.hash = {
text: 'branch ' + name,
token: 'branch ' + name,
line: '1',
loc: { first_line: 1, last_line: 1, first_column: 1, last_column: 1 },
expected: ['"checkout ' + name + '"'],
};
throw error;
}
}
};
export const nodeType = {
DEFAULT: 0,
NO_BORDER: 0,
ROUNDED_RECT: 1,
RECT: 2,
CIRCLE: 3,
CLOUD: 4,
BANG: 5,
};
export const getType = (startStr, endStr) => {
log.debug('In get type', startStr, endStr);
switch (startStr) {
case '[':
return nodeType.RECT;
case '(':
return endStr === ')' ? nodeType.ROUNDED_RECT : nodeType.CLOUD;
case '((':
return nodeType.CIRCLE;
case ')':
return nodeType.CLOUD;
case '))':
return nodeType.BANG;
default:
return nodeType.DEFAULT;
}
};
export const setElementForId = (id, element) => {
elements[id] = element;
};
export const decorateNode = (decoration) => {
const node = nodes[nodes.length - 1];
if (decoration && decoration.icon) {
node.icon = sanitizeText(decoration.icon);
}
if (decoration && decoration.class) {
node.class = sanitizeText(decoration.class);
}
};
export const type2Str = (type) => {
switch (type) {
case nodeType.DEFAULT:
return 'no-border';
case nodeType.RECT:
return 'rect';
case nodeType.ROUNDED_RECT:
return 'rounded-rect';
case nodeType.CIRCLE:
return 'circle';
case nodeType.CLOUD:
return 'cloud';
case nodeType.BANG:
return 'bang';
default:
return 'no-border';
}
};
// Expose logger to grammar
export const log = _log;
export const getNodeById = (id) => nodes[id];
export const getElementById = (id) => elements[id];
// export default {
// // getMindmap,
// // addNode,
// // clear,
// // nodeType,
// // getType,
// // decorateNode,
// // setElementForId,
// getElementById: (id) => elements[id],
// // getNodeById: (id) => nodes.find((node) => node.id === id),
// getNodeById: (id) => nodes[id],
// // type2Str,
// // parseError
// };

View File

@@ -0,0 +1,8 @@
const detector = function detect(txt) {
if (txt.match(/^\s*mindmap/)) {
return 'mindmap';
}
return null;
};
export default detector;

View File

@@ -0,0 +1,5 @@
import type { DiagramDetector } from '../../diagram-api/detectType';
export const mindmapDetector: DiagramDetector = (txt) => {
return txt.match(/^\s*mindmap/) !== null;
};

View File

@@ -0,0 +1,267 @@
/** Created by knut on 14-12-11. */
import { select } from 'd3';
import { log, getConfig, setupGraphViewbox } from '../../diagram-api/diagramAPI';
import svgDraw from './svgDraw';
import { BoundingBox, Layout } from 'non-layered-tidy-tree-layout';
import clone from 'fast-clone';
import * as db from './mindmapDb';
/**
* @param {any} svg The svg element to draw the diagram onto
* @param {object} mindmap The maindmap data and hierarchy
* @param section
* @param {object} conf The configuration object
*/
function drawNodes(svg, mindmap, section, conf) {
svgDraw.drawNode(svg, mindmap, section, conf);
if (mindmap.children) {
mindmap.children.forEach((child, index) => {
drawNodes(svg, child, section < 0 ? index : section, conf);
});
}
}
/**
* @param edgesElem
* @param mindmap
* @param parent
* @param depth
* @param section
* @param conf
*/
function drawEdges(edgesElem, mindmap, parent, depth, section, conf) {
if (parent) {
svgDraw.drawEdge(edgesElem, mindmap, parent, depth, section, conf);
}
if (mindmap.children) {
mindmap.children.forEach((child, index) => {
drawEdges(edgesElem, child, mindmap, depth + 1, section < 0 ? index : section, conf);
});
}
}
/**
* @param mindmap
* @param callback
*/
function eachNode(mindmap, callback) {
callback(mindmap);
if (mindmap.children) {
mindmap.children.forEach((child) => {
eachNode(child, callback);
});
}
}
/** @param {object} mindmap */
function transpose(mindmap) {
eachNode(mindmap, (node) => {
const orgWidth = node.width;
const orgX = node.x;
node.width = node.height;
node.height = orgWidth;
node.x = node.y;
node.y = orgX;
});
return mindmap;
}
/** @param {object} mindmap */
function bottomToUp(mindmap) {
log.debug('bottomToUp', mindmap);
eachNode(mindmap.result, (node) => {
// node.y = node.y - (node.y - bb.top) * 2 - node.height;
node.y = node.y - (node.y - 0) * 2 - node.height;
});
return mindmap;
}
/** @param {object} mindmap The mindmap hierarchy */
function rightToLeft(mindmap) {
eachNode(mindmap.result, (node) => {
// node.y = node.y - (node.y - bb.top) * 2 - node.height;
node.x = node.x - (node.x - 0) * 2 - node.width;
});
return mindmap;
}
/**
* @param mindmap
* @param dir
*/
function layout(mindmap, dir) {
const bb = new BoundingBox(30, 60);
const layout = new Layout(bb);
switch (dir) {
case 'TB':
return layout.layout(mindmap);
case 'BT':
return bottomToUp(layout.layout(mindmap));
case 'RL': {
transpose(mindmap);
let newRes = layout.layout(mindmap);
transpose(newRes.result);
return rightToLeft(newRes);
}
case 'LR': {
transpose(mindmap);
let newRes = layout.layout(mindmap);
transpose(newRes.result);
return newRes;
}
default:
}
}
const dirFromIndex = (index) => {
const dirNum = (index + 2) % 4;
switch (dirNum) {
case 0:
return 'LR';
case 1:
return 'RL';
case 2:
return 'TB';
case 3:
return 'BT';
default:
return 'TB';
}
};
const mergeTrees = (node, trees) => {
node.x = trees[0].result.x;
node.y = trees[0].result.y;
trees.forEach((tree) => {
tree.result.children.forEach((child) => {
const dx = node.x - tree.result.x;
const dy = node.y - tree.result.y;
eachNode(child, (childNode) => {
const orgNode = db.getNodeById(childNode.id);
if (orgNode) {
orgNode.x = childNode.x + dx;
orgNode.y = childNode.y + dy;
}
});
});
});
return node;
};
/**
* @param node
* @param conf
*/
function layoutMindmap(node, conf) {
// BoundingBox(gap, bottomPadding)
// const bb = new BoundingBox(10, 10);
// const layout = new Layout(bb);
// // const layout = new HorizontalLayout(bb);
if (node.children.length === 0) {
return node;
}
const trees = [];
// node.children.forEach((child, index) => {
// const tree = clone(node);
// tree.children = [tree.children[index]];
// trees.push(layout(tree, dirFromIndex(index), conf));
// });
let cnt = 0;
// For each direction, create a new tree with the same root, and add a ubset of the children to it.
for (let i = 0; i < 4; i++) {
// Calculate the number of the children of the root node that will be used in this direction
const numChildren =
Math.floor(node.children.length / 4) + (node.children.length % 4 > i ? 1 : 0);
// Copy the original root node
const tree = clone(node);
// Setup the new copy with the children to be rendered in this direction
tree.children = [];
for (let j = 0; j < numChildren; j++) {
tree.children.push(node.children[cnt]);
cnt++;
}
if (tree.children.length > 0) {
trees.push(layout(tree, dirFromIndex(i), conf));
}
}
// Let each node know the direct of its tree for when we draw the branches.
trees.forEach((tree, index) => {
tree.result.direction = dirFromIndex(index);
eachNode(tree.result, (node) => {
node.direction = dirFromIndex(index);
});
});
// Merge the trees into a single tree
mergeTrees(node, trees);
return node;
}
/**
* @param node
* @param conf
*/
function positionNodes(node, conf) {
svgDraw.positionNode(node, conf);
if (node.children) {
node.children.forEach((child) => {
positionNodes(child, conf);
});
}
}
/**
* Draws a an info picture in the tag with id: id based on the graph definition in text.
*
* @param {any} text
* @param {any} id
* @param {any} version
* @param diagObj
*/
export const draw = (text, id, version, diagObj) => {
const conf = getConfig();
try {
log.debug('Renering info diagram\n' + text);
const securityLevel = getConfig().securityLevel;
// Handle root and Document for when rendering in sanbox mode
let sandboxElement;
if (securityLevel === 'sandbox') {
sandboxElement = select('#i' + id);
}
const root =
securityLevel === 'sandbox'
? select(sandboxElement.nodes()[0].contentDocument.body)
: select('body');
// Parse the graph definition
const svg = root.select('#' + id);
svg.append('g');
const mm = diagObj.db.getMindmap();
// Draw the graph and start with drawing the nodes without proper position
// this gives us the size of the nodes and we can set the positions later
const edgesElem = svg.append('g');
edgesElem.attr('class', 'mindmap-edges');
const nodesElem = svg.append('g');
nodesElem.attr('class', 'mindmap-nodes');
drawNodes(nodesElem, mm, -1, conf);
// Next step is to layout the mindmap, giving each node a position
const positionedMindmap = layoutMindmap(mm, conf);
// After this we can draw, first the edges and the then nodes with the correct position
drawEdges(edgesElem, positionedMindmap, null, 0, -1, conf);
positionNodes(positionedMindmap, conf);
// Setup the view box and size of the svg element
setupGraphViewbox(undefined, svg, conf.mindmap.padding, conf.mindmap.useMaxWidth);
} catch (e) {
log.error('Error while rendering info diagram');
log.error(e.message);
}
};
export default {
draw,
};

View File

@@ -0,0 +1,108 @@
/** mermaid
* https://knsv.github.io/mermaid
* (c) 2015 Knut Sveidqvist
* MIT license.
*/
%lex
%options case-insensitive
%{
// Pre-lexer code can go here
%}
%x NODE
%x NSTR
%x ICON
%x CLASS
%%
\s*\%\%.* {yy.log.trace('Found comment',yytext);}
// \%\%[^\n]*\n /* skip comments */
"mindmap" return 'MINDMAP';
":::" { this.begin('CLASS'); }
<CLASS>.+ { this.popState();return 'CLASS'; }
<CLASS>\n { this.popState();}
// [\s]*"::icon(" { this.begin('ICON'); }
"::icon(" { yy.log.trace('Begin icon');this.begin('ICON'); }
[\n]+ return 'NL';
<ICON>[^\)]+ { return 'ICON'; }
<ICON>\) {yy.log.trace('end icon');this.popState();}
"-)" { yy.log.trace('Exploding node'); this.begin('NODE');return 'NODE_DSTART'; }
"(-" { yy.log.trace('Cloud'); this.begin('NODE');return 'NODE_DSTART'; }
"))" { yy.log.trace('Explosion Bang'); this.begin('NODE');return 'NODE_DSTART'; }
")" { yy.log.trace('Cloud Bang'); this.begin('NODE');return 'NODE_DSTART'; }
"((" { this.begin('NODE');return 'NODE_DSTART'; }
"(" { this.begin('NODE');return 'NODE_DSTART'; }
"[" { this.begin('NODE');return 'NODE_DSTART'; }
[\s]+ return 'SPACELIST' /* skip all whitespace */ ;
// !(-\() return 'NODE_ID';
[^\(\[\n\-\)]+ return 'NODE_ID';
<<EOF>> return 'EOF';
<NODE>["] { yy.log.trace('Starting NSTR');this.begin("NSTR");}
<NSTR>[^"]+ { yy.log.trace('description:', yytext); return "NODE_DESCR";}
<NSTR>["] {this.popState();}
<NODE>[\)]\) {this.popState();yy.log.trace('node end ))');return "NODE_DEND";}
<NODE>[\)] {this.popState();yy.log.trace('node end )');return "NODE_DEND";}
<NODE>[\]] {this.popState();yy.log.trace('node end ...',yytext);return "NODE_DEND";}
<NODE>"(-" {this.popState();yy.log.trace('node end (-');return "NODE_DEND";}
<NODE>"-)" {this.popState();yy.log.trace('node end (-');return "NODE_DEND";}
<NODE>"((" {this.popState();yy.log.trace('node end ((');return "NODE_DEND";}
<NODE>"(" {this.popState();yy.log.trace('node end ((');return "NODE_DEND";}
<NODE>[^\)\]\(]+ { yy.log.trace('Long description:', yytext); return 'NODE_DESCR';}
<NODE>.+(?!\(\() { yy.log.trace('Long description:', yytext); return 'NODE_DESCR';}
// [\[] return 'NODE_START';
// .+ return 'TXT' ;
/lex
%start start
%% /* language grammar */
start
// %{ : info document 'EOF' { return yy; } }
: MINDMAP document { return yy; }
| MINDMAP NL document { return yy; }
| SPACELIST MINDMAP document { return yy; }
;
stop
: NL {yy.log.trace('Stop NL ');}
| EOF {yy.log.trace('Stop EOF ');}
| stop NL {yy.log.trace('Stop NL2 ');}
| stop EOF {yy.log.trace('Stop EOF2 ');}
;
document
: document statement stop
| statement stop
;
statement
: SPACELIST node { yy.log.trace('Node: ',$2.id);yy.addNode($1.length, $2.id, $2.descr, $2.type); }
| SPACELIST ICON { yy.log.trace('Icon: ',$2);yy.decorateNode({icon: $2}); }
| SPACELIST CLASS { yy.decorateNode({class: $2}); }
| node { yy.log.trace('Node: ',$1.id);yy.addNode(0, $1.id, $1.descr, $1.type); }
| ICON { yy.decorateNode({icon: $1}); }
| CLASS { yy.decorateNode({class: $1}); }
| SPACELIST
;
node
:nodeWithId
|nodeWithoutId
;
nodeWithoutId
: NODE_DSTART NODE_DESCR NODE_DEND
{ yy.log.trace("node found ..", $1); $$ = { id: $2, descr: $2, type: yy.getType($1, $3) }; }
;
nodeWithId
: NODE_ID { $$ = { id: $1, descr: $1, type: yy.nodeType.DEFAULT }; }
| NODE_ID NODE_DSTART NODE_DESCR NODE_DEND
{ yy.log.trace("node found ..", $1); $$ = { id: $1, descr: $3, type: yy.getType($2, $4) }; }
;
%%

View File

@@ -0,0 +1,6 @@
// @ts-ignore: TODO Fix ts errors
import mindmapParser from './parser/mindmap';
import * as mindmapDb from './mindmapDb';
import { mindmapDetector } from './mindmapDetector';
import mindmapRenderer from './mindmapRenderer';
import mindmapStyles from './styles';

View File

@@ -0,0 +1,76 @@
import { darken, lighten, isDark } from 'khroma';
const genSections = (options) => {
let sections = '';
for (let i = 0; i < 8; i++) {
options['lineColor' + i] = options['lineColor' + i] || options['gitInv' + i];
if (isDark(options['lineColor' + i])) {
options['lineColor' + i] = lighten(options['lineColor' + i], 20);
} else {
options['lineColor' + i] = darken(options['lineColor' + i], 20);
}
}
for (let i = 0; i < 8; i++) {
const sw = '' + (17 - 3 * i);
sections += `
.section-${i - 1} rect, .section-${i - 1} path, .section-${i - 1} circle, .section-${
i - 1
} path {
fill: ${options['git' + i]};
}
.section-${i - 1} text {
fill: ${options['gitBranchLabel' + i]};
// fill: ${options['gitInv' + i]};
}
.node-icon-${i - 1} {
font-size: 40px;
color: ${options['gitBranchLabel' + i]};
// color: ${options['gitInv' + i]};
}
.section-edge-${i - 1}{
stroke: ${options['git' + i]};
}
.edge-depth-${i - 1}{
stroke-width: ${sw};
}
.section-${i - 1} line {
stroke: ${options['lineColor' + i]} ;
stroke-width: 3;
}
.disabled, .disabled circle, .disabled text {
fill: lightgray;
}
.disabled text {
fill: #efefef;
}
`;
}
return sections;
};
const getStyles = (options) =>
`
.edge {
stroke-width: 3;
}
${genSections(options)}
.section-root rect, .section-root path, .section-root circle {
fill: ${options.git0};
}
.section-root text {
fill: ${options.gitBranchLabel0};
}
.icon-container {
height:100%;
display: flex;
justify-content: center;
align-items: center;
}
.edge {
fill: none;
}
`;
export default getStyles;

View File

@@ -0,0 +1,292 @@
import { select } from 'd3';
import * as db from './mindmapDb';
/**
* @param {string} text The text to be wrapped
* @param {number} width The max width of the text
*/
function wrap(text, width) {
text.each(function () {
var text = select(this),
words = text
.text()
.split(/(\s+|<br>)/)
.reverse(),
word,
line = [],
lineHeight = 1.1, // ems
y = text.attr('y'),
dy = parseFloat(text.attr('dy')),
tspan = text
.text(null)
.append('tspan')
.attr('x', 0)
.attr('y', y)
.attr('dy', dy + 'em');
for (let j = 0; j < words.length; j++) {
word = words[words.length - 1 - j];
line.push(word);
tspan.text(line.join(' ').trim());
if (tspan.node().getComputedTextLength() > width || word === '<br>') {
line.pop();
tspan.text(line.join(' ').trim());
if (word === '<br>') {
line = [''];
} else {
line = [word];
}
tspan = text
.append('tspan')
.attr('x', 0)
.attr('y', y)
.attr('dy', lineHeight + 'em')
.text(word);
}
}
});
}
const defaultBkg = function (elem, node, section) {
const rd = 5;
elem
.append('path')
.attr('id', 'node-' + node.id)
.attr('class', 'node-bkg node-' + db.type2Str(node.type))
.attr(
'd',
`M0 ${node.height - rd} v${-node.height + 2 * rd} q0,-5 5,-5 h${
node.width - 2 * rd
} q5,0 5,5 v${node.height - rd} H0 Z`
);
elem
.append('line')
.attr('class', 'node-line-' + section)
.attr('x1', 0)
.attr('y1', node.height)
.attr('x2', node.width)
.attr('y2', node.height);
};
const rectBkg = function (elem, node) {
elem
.append('rect')
.attr('id', 'node-' + node.id)
.attr('class', 'node-bkg node-' + db.type2Str(node.type))
.attr('height', node.height)
.attr('width', node.width);
};
const cloudBkg = function (elem, node) {
const w = node.width;
const h = node.height;
const r1 = 0.15 * w;
const r2 = 0.25 * w;
const r3 = 0.35 * w;
const r4 = 0.2 * w;
elem
.append('path')
.attr('id', 'node-' + node.id)
.attr('class', 'node-bkg node-' + db.type2Str(node.type))
.attr(
'd',
`M0 0 a${r1},${r1} 0 0,1 ${w * 0.25},${-1 * w * 0.1}
a${r3},${r3} 1 0,1 ${w * 0.4},${-1 * w * 0.1}
a${r2},${r2} 1 0,1 ${w * 0.35},${1 * w * 0.2}
a${r1},${r1} 1 0,1 ${w * 0.15},${1 * h * 0.35}
a${r4},${r4} 1 0,1 ${-1 * w * 0.15},${1 * h * 0.65}
a${r2},${r1} 1 0,1 ${-1 * w * 0.25},${w * 0.15}
a${r3},${r3} 1 0,1 ${-1 * w * 0.5},${0}
a${r1},${r1} 1 0,1 ${-1 * w * 0.25},${-1 * w * 0.15}
a${r1},${r1} 1 0,1 ${-1 * w * 0.1},${-1 * h * 0.35}
a${r4},${r4} 1 0,1 ${w * 0.1},${-1 * h * 0.65}
H0 V0 Z`
);
};
const bangBkg = function (elem, node) {
const w = node.width;
const h = node.height;
const r = 0.15 * w;
elem
.append('path')
.attr('id', 'node-' + node.id)
.attr('class', 'node-bkg node-' + db.type2Str(node.type))
.attr(
'd',
`M0 0 a${r},${r} 1 0,0 ${w * 0.25},${-1 * h * 0.1}
a${r},${r} 1 0,0 ${w * 0.25},${0}
a${r},${r} 1 0,0 ${w * 0.25},${0}
a${r},${r} 1 0,0 ${w * 0.25},${1 * h * 0.1}
a${r},${r} 1 0,0 ${w * 0.15},${1 * h * 0.33}
a${r * 0.8},${r * 0.8} 1 0,0 ${0},${1 * h * 0.34}
a${r},${r} 1 0,0 ${-1 * w * 0.15},${1 * h * 0.33}
a${r},${r} 1 0,0 ${-1 * w * 0.25},${h * 0.15}
a${r},${r} 1 0,0 ${-1 * w * 0.25},${0}
a${r},${r} 1 0,0 ${-1 * w * 0.25},${0}
a${r},${r} 1 0,0 ${-1 * w * 0.25},${-1 * h * 0.15}
a${r},${r} 1 0,0 ${-1 * w * 0.1},${-1 * h * 0.33}
a${r * 0.8},${r * 0.8} 1 0,0 ${0},${-1 * h * 0.34}
a${r},${r} 1 0,0 ${w * 0.1},${-1 * h * 0.33}
H0 V0 Z`
);
};
const circleBkg = function (elem, node) {
elem
.append('circle')
.attr('id', 'node-' + node.id)
.attr('class', 'node-bkg node-' + db.type2Str(node.type))
.attr('r', node.width / 2);
};
const roundedRectBkg = function (elem, node) {
elem
.append('rect')
.attr('id', 'node-' + node.id)
.attr('class', 'node-bkg node-' + db.type2Str(node.type))
.attr('height', node.height)
.attr('rx', node.padding)
.attr('ry', node.padding)
.attr('width', node.width);
};
/**
* @param {object} elem The D3 dom element in which the node is to be added
* @param {object} node The node to be added
* @param section
* @param {object} conf The configuration object
* @returns {number} The height nodes dom element
*/
export const drawNode = function (elem, node, section, conf) {
const nodeElem = elem.append('g');
nodeElem.attr(
'class',
(node.class ? node.class + ' ' : '') +
'mindmap-node ' +
(section === -1 ? 'section-root' : 'section-' + section)
);
const bkgElem = nodeElem.append('g');
// Create the wrapped text element
const textElem = nodeElem.append('g');
const txt = textElem
.append('text')
.text(node.descr)
.attr('dy', '1em')
.attr('alignment-baseline', 'middle')
.attr('dominant-baseline', 'middle')
.attr('text-anchor', 'middle')
.call(wrap, node.width);
const bbox = txt.node().getBBox();
const fontSize = conf.fontSize.replace ? conf.fontSize.replace('px', '') : conf.fontSize;
node.height = bbox.height + fontSize * 1.1 * 0.5 + node.padding;
node.width = bbox.width + 2 * node.padding;
if (node.icon) {
if (node.type === db.nodeType.CIRCLE) {
node.height += 50;
node.width += 50;
const icon = nodeElem
.append('foreignObject')
.attr('height', '50px')
.attr('width', node.width)
.attr('style', 'text-align: center;');
icon
.append('div')
.attr('class', 'icon-container')
.append('i')
.attr('class', 'node-icon-' + section + ' ' + node.icon);
textElem.attr(
'transform',
'translate(' + node.width / 2 + ', ' + (node.height / 2 - 1.5 * node.padding) + ')'
);
} else {
node.width += 50;
const orgHeight = node.height;
node.height = Math.max(orgHeight, 60);
const heightDiff = Math.abs(node.height - orgHeight);
const icon = nodeElem
.append('foreignObject')
.attr('width', '60px')
.attr('height', node.height)
.attr('style', 'text-align: center;margin-top:' + heightDiff / 2 + 'px;');
icon
.append('div')
.attr('class', 'icon-container')
.append('i')
.attr('class', 'node-icon-' + section + ' ' + node.icon);
textElem.attr(
'transform',
'translate(' + (25 + node.width / 2) + ', ' + (heightDiff / 2 + node.padding / 2) + ')'
);
}
} else {
textElem.attr('transform', 'translate(' + node.width / 2 + ', ' + node.padding / 2 + ')');
}
switch (node.type) {
case db.nodeType.DEFAULT:
defaultBkg(bkgElem, node, section, conf);
break;
case db.nodeType.ROUNDED_RECT:
roundedRectBkg(bkgElem, node, section, conf);
break;
case db.nodeType.RECT:
rectBkg(bkgElem, node, section, conf);
break;
case db.nodeType.CIRCLE:
bkgElem.attr('transform', 'translate(' + node.width / 2 + ', ' + +node.height / 2 + ')');
circleBkg(bkgElem, node, section, conf);
break;
case db.nodeType.CLOUD:
cloudBkg(bkgElem, node, section, conf);
break;
case db.nodeType.BANG:
bangBkg(bkgElem, node, section, conf);
break;
}
// Position the node to its coordinate
if (typeof node.x !== 'undefined' && typeof node.y !== 'undefined') {
nodeElem.attr('transform', 'translate(' + node.x + ',' + node.y + ')');
}
db.setElementForId(node.id, nodeElem);
return node.height;
};
export const drawEdge = function drawEdge(edgesElem, mindmap, parent, depth, section) {
const sx = parent.x + parent.width / 2;
const sy = parent.y + parent.height / 2;
const ex = mindmap.x + mindmap.width / 2;
const ey = mindmap.y + mindmap.height / 2;
const mx = ex > sx ? sx + Math.abs(sx - ex) / 2 : sx - Math.abs(sx - ex) / 2;
const my = ey > sy ? sy + Math.abs(sy - ey) / 2 : sy - Math.abs(sy - ey) / 2;
const qx = ex > sx ? Math.abs(sx - mx) / 2 + sx : -Math.abs(sx - mx) / 2 + sx;
const qy = ey > sy ? Math.abs(sy - my) / 2 + sy : -Math.abs(sy - my) / 2 + sy;
edgesElem
.append('path')
.attr(
'd',
parent.direction === 'TB' || parent.direction === 'BT'
? `M${sx},${sy} Q${sx},${qy} ${mx},${my} T${ex},${ey}`
: `M${sx},${sy} Q${qx},${sy} ${mx},${my} T${ex},${ey}`
)
.attr('class', 'edge section-edge-' + section + ' edge-depth-' + depth);
};
export const positionNode = function (node) {
const nodeElem = db.getElementById(node.id);
const x = node.x || 0;
const y = node.y || 0;
// Position the node to its coordinate
nodeElem.attr('transform', 'translate(' + x + ',' + y + ')');
};
export default { drawNode, positionNode, drawEdge };

View File

@@ -0,0 +1,108 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */
/* Projects */
// "incremental": true /* Enable incremental compilation */,
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "ES6" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
"lib": [
"DOM",
"ES2021"
] /* Specify a set of bundled library declaration files that describe the target runtime environment. */,
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */
// "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
/* Modules */
"module": "ES6" /* Specify what module code is generated. */,
"rootDir": "./src" /* Specify the root folder within your source files. */,
"moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */,
// "baseUrl": "./src" /* Specify the base directory to resolve non-relative module names. */,
// "paths": {} /* Specify a set of entries that re-map imports to additional lookup locations. */,
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [] /* Specify multiple folders that act like `./node_modules/@types`. */,
"types": [
"vitest/globals"
] /* Specify type package names to be included without being referenced in a source file. */,
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
"resolveJsonModule": true /* Enable importing .json files */,
// "noResolve": true, /* Disallow `import`s, `require`s or `<reference>`s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
"allowJs": true /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */,
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */
/* Emit */
"declaration": true /* Generate .d.ts files from TypeScript and JavaScript files in your project. */,
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */
"outDir": "./dist" /* Specify an output folder for all emitted files. */,
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */,
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
/* Type Checking */
"strict": true /* Enable all strict type-checking options. */,
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */
// "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */
// "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
},
"include": ["./src/**/*.ts", "./package.json"]
}

View File

@@ -0,0 +1,20 @@
const { esmBuild, esmCoreBuild, iifeBuild } = require('./util.cjs');
const { build } = require('esbuild');
const handler = (e) => {
console.error(e);
process.exit(1);
};
const watch = process.argv.includes('--watch');
// mermaid.js
build(iifeBuild({ minify: false, watch })).catch(handler);
// mermaid.esm.mjs
build(esmBuild({ minify: false, watch })).catch(handler);
// mermaid.min.js
build(iifeBuild()).catch(handler);
// mermaid.esm.min.mjs
build(esmBuild()).catch(handler);
// mermaid.core.mjs (node_modules unbundled)
build(esmCoreBuild()).catch(handler);

View File

@@ -0,0 +1,14 @@
const { Generator } = require('jison');
exports.transformJison = (src) => {
const parser = new Generator(src, {
moduleType: 'js',
'token-stack': true,
});
const source = parser.generate({ moduleMain: '() => {}' });
const exporter = `
parser.parser = parser;
export { parser };
export default parser;
`;
return `${source} ${exporter}`;
};

View File

@@ -0,0 +1,79 @@
const esbuild = require('esbuild');
const http = require('http');
const { iifeBuild, esmBuild } = require('./util.cjs');
const express = require('express');
// Start 2 esbuild servers. One for IIFE and one for ESM
// Serve 2 static directories: demo & cypress/platform
// Have 3 entry points:
// mermaid: './src/mermaid',
// e2e: './cypress/platform/viewer.js',
// 'bundle-test': './cypress/platform/bundle-test.js',
const getEntryPointsAndExtensions = (format) => {
return {
entryPoints: {
mermaid: './src/mermaid',
e2e: 'cypress/platform/viewer.js',
'bundle-test': 'cypress/platform/bundle-test.js',
},
outExtension: { '.js': format === 'iife' ? '.js' : '.esm.mjs' },
};
};
const generateHandler = (server) => {
return (req, res) => {
const options = {
hostname: server.host,
port: server.port,
path: req.url,
method: req.method,
headers: req.headers,
};
// Forward each incoming request to esbuild
const proxyReq = http.request(options, (proxyRes) => {
// If esbuild returns "not found", send a custom 404 page
if (proxyRes.statusCode === 404) {
if (!req.url.endsWith('.html')) {
res.writeHead(404, { 'Content-Type': 'text/html' });
res.end('<h1>A custom 404 page</h1>');
return;
}
}
// Otherwise, forward the response from esbuild to the client
res.writeHead(proxyRes.statusCode, proxyRes.headers);
proxyRes.pipe(res, { end: true });
});
// Forward the body of the request to esbuild
req.pipe(proxyReq, { end: true });
};
};
(async () => {
const iifeServer = await esbuild.serve(
{},
{
...iifeBuild({ minify: false, outfile: undefined, outdir: 'dist' }),
...getEntryPointsAndExtensions('iife'),
}
);
const esmServer = await esbuild.serve(
{},
{
...esmBuild({ minify: false, outfile: undefined, outdir: 'dist' }),
...getEntryPointsAndExtensions('esm'),
}
);
const app = express();
app.use(express.static('demos'));
app.use(express.static('cypress/platform'));
app.all('/mermaid.js', generateHandler(iifeServer));
app.all('/mermaid.esm.mjs', generateHandler(esmServer));
app.all('/e2e.esm.mjs', generateHandler(esmServer));
app.all('/bundle-test.esm.mjs', generateHandler(esmServer));
app.listen(9000, () => {
console.log(`Listening on http://localhost:9000`);
});
})();

View File

@@ -0,0 +1,94 @@
const { transformJison } = require('./jisonTransformer.cjs');
const fs = require('fs');
const { dependencies } = require('../package.json');
/** @typedef {import('esbuild').BuildOptions} Options */
/**
* @param {Options} override
* @returns {Options}
*/
const buildOptions = (override = {}) => {
return {
bundle: true,
minify: true,
keepNames: true,
banner: { js: '"use strict";' },
globalName: 'mermaid',
platform: 'browser',
tsconfig: 'tsconfig.json',
resolveExtensions: ['.ts', '.js', '.mjs', '.json', '.jison'],
external: ['require', 'fs', 'path'],
entryPoints: ['src/mermaid.ts'],
outfile: 'dist/mermaid.min.js',
plugins: [jisonPlugin],
sourcemap: 'external',
...override,
};
};
/**
* Build options for mermaid.esm.* build.
*
* For ESM browser use.
*
* @param {Options} override - Override options.
* @returns {Options} ESBuild build options.
*/
exports.esmBuild = (override = { minify: true }) => {
return buildOptions({
format: 'esm',
outfile: `dist/mermaid.esm${override.minify ? '.min' : ''}.mjs`,
...override,
});
};
/**
* Build options for mermaid.core.* build.
*
* This build does not bundle `./node_modules/`, as it is designed to be used with
* Webpack/ESBuild/Vite to use mermaid inside an app/website.
*
* @param {Options} override - Override options.
* @returns {Options} ESBuild build options.
*/
exports.esmCoreBuild = (override) => {
return buildOptions({
format: 'esm',
outfile: `dist/mermaid.core.mjs`,
external: ['require', 'fs', 'path', ...Object.keys(dependencies)],
platform: 'neutral',
...override,
});
};
/**
* Build options for mermaid.js build.
*
* For IIFE browser use (where ESM is not yet supported).
*
* @param {Options} override - Override options.
* @returns {Options} ESBuild build options.
*/
exports.iifeBuild = (override = { minify: true }) => {
return buildOptions({
outfile: `dist/mermaid${override.minify ? '.min' : ''}.js`,
format: 'iife',
footer: {
js: 'mermaid = mermaid.default;',
},
...override,
});
};
const jisonPlugin = {
name: 'jison',
setup(build) {
build.onLoad({ filter: /\.jison$/ }, async (args) => {
// Load the file from the file system
const source = await fs.promises.readFile(args.path, 'utf8');
const contents = transformJison(source);
return { contents, warnings: [] };
});
},
};

View File

@@ -0,0 +1,141 @@
{
"name": "mermaid",
"version": "9.2.0-rc1",
"description": "Markdownish syntax for generating flowcharts, sequence diagrams, class diagrams, gantt charts and git graphs.",
"main": "dist/mermaid.core.mjs",
"module": "dist/mermaid.core.mjs",
"types": "dist/mermaid.d.ts",
"type": "module",
"exports": {
".": {
"require": "./dist/mermaid.min.js",
"import": "./dist/mermaid.core.mjs",
"types": "./dist/mermaid.d.ts"
},
"./*": "./*"
},
"keywords": [
"diagram",
"markdown",
"flowchart",
"sequence diagram",
"gantt",
"class diagram",
"git graph"
],
"scripts": {
"clean": "rimraf dist",
"build:code": "node .esbuild/esbuild.cjs",
"build:types": "tsc -p ./tsconfig.json --emitDeclarationOnly",
"build:watch": "yarn build:code --watch",
"build:esbuild": "concurrently \"yarn build:code\" \"yarn build:types\"",
"build": "yarn clean; yarn build:esbuild",
"dev": "node .esbuild/serve.cjs",
"docs:build": "ts-node-esm src/docs.mts",
"docs:verify": "yarn docs:build --verify",
"postbuild": "documentation build src/mermaidAPI.ts src/config.ts src/defaultConfig.ts --shallow -f md --markdown-toc false > src/docs/Setup.md && prettier --write src/docs/Setup.md",
"release": "yarn build",
"lint": "eslint --cache --ignore-path .gitignore . && yarn lint:jison && prettier --check .",
"lint:fix": "eslint --fix --ignore-path .gitignore . && prettier --write .",
"lint:jison": "ts-node-esm src/jison/lint.mts",
"cypress": "cypress run",
"cypress:open": "cypress open",
"e2e": "start-server-and-test dev http://localhost:9000/ cypress",
"ci": "vitest run",
"test": "yarn lint && vitest run",
"test:watch": "vitest --coverage --watch",
"prepublishOnly": "yarn build && yarn test",
"prepare": "concurrently \"husky install\" \"yarn build\"",
"pre-commit": "lint-staged"
},
"repository": {
"type": "git",
"url": "https://github.com/mermaid-js/mermaid"
},
"author": "Knut Sveidqvist",
"license": "MIT",
"standard": {
"ignore": [
"**/parser/*.js",
"dist/**/*.js",
"cypress/**/*.js"
],
"globals": [
"page"
]
},
"dependencies": {
"@braintree/sanitize-url": "^6.0.0",
"d3": "^7.0.0",
"dagre": "^0.8.5",
"dagre-d3": "^0.6.4",
"dompurify": "2.4.0",
"fast-clone": "^1.5.13",
"graphlib": "^2.1.8",
"khroma": "^2.0.0",
"lodash": "^4.17.21",
"moment-mini": "^2.24.0",
"non-layered-tidy-tree-layout": "^2.0.2",
"stylis": "^4.1.2"
},
"devDependencies": {
"@applitools/eyes-cypress": "^3.25.7",
"@commitlint/cli": "^17.1.2",
"@commitlint/config-conventional": "^17.0.0",
"@types/d3": "^7.4.0",
"@types/dompurify": "^2.3.4",
"@types/eslint": "^8.4.6",
"@types/express": "^4.17.13",
"@types/jsdom": "^20.0.0",
"@types/lodash": "^4.14.185",
"@types/prettier": "^2.7.0",
"@types/stylis": "^4.0.2",
"@typescript-eslint/eslint-plugin": "^5.37.0",
"@typescript-eslint/parser": "^5.37.0",
"@vitest/coverage-c8": "^0.23.2",
"@vitest/ui": "^0.23.2",
"concurrently": "^7.4.0",
"coveralls": "^3.1.1",
"cypress": "^10.0.0",
"cypress-image-snapshot": "^4.0.1",
"documentation": "13.2.0",
"esbuild": "^0.15.8",
"eslint": "^8.23.1",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-cypress": "^2.12.1",
"eslint-plugin-html": "^7.1.0",
"eslint-plugin-jest": "^27.0.4",
"eslint-plugin-jsdoc": "^39.3.6",
"eslint-plugin-json": "^3.1.0",
"eslint-plugin-markdown": "^3.0.0",
"express": "^4.18.1",
"globby": "^13.1.2",
"husky": "^8.0.0",
"identity-obj-proxy": "^3.0.0",
"jison": "^0.4.18",
"js-base64": "3.7.2",
"jsdom": "^20.0.0",
"lint-staged": "^13.0.0",
"moment": "^2.23.0",
"path-browserify": "^1.0.1",
"prettier": "^2.7.1",
"prettier-plugin-jsdoc": "^0.4.2",
"remark": "^14.0.2",
"rimraf": "^3.0.2",
"start-server-and-test": "^1.12.6",
"ts-node": "^10.9.1",
"typescript": "^4.8.3",
"unist-util-flatmap": "^1.0.0",
"vitest": "^0.23.1"
},
"resolutions": {
"d3": "^7.0.0"
},
"files": [
"dist"
],
"sideEffects": [
"**/*.css",
"**/*.scss"
]
}

View File

@@ -0,0 +1,73 @@
import * as configApi from './config';
import { log } from './logger';
import { getDiagram } from './diagram-api/diagramAPI';
import { detectType } from './diagram-api/detectType';
import { isDetailedError } from './utils';
export class Diagram {
type = 'graph';
parser;
renderer;
db;
// eslint-disable-next-line @typescript-eslint/ban-types
constructor(public txt: string, parseError?: Function) {
const cnf = configApi.getConfig();
this.txt = txt;
this.type = detectType(txt, cnf);
const diagram = getDiagram(this.type);
log.debug('Type ' + this.type);
// Setup diagram
this.db = diagram.db;
this.db.clear?.();
this.renderer = diagram.renderer;
this.parser = diagram.parser;
this.parser.parser.yy = this.db;
if (diagram.init) {
diagram.init(cnf);
log.debug('Initialized diagram ' + this.type, cnf);
}
this.txt += '\n';
this.parser.parser.yy.graphType = this.type;
this.parser.parser.yy.parseError = (str: string, hash: string) => {
const error = { str, hash };
throw error;
};
this.parse(this.txt, parseError);
}
// eslint-disable-next-line @typescript-eslint/ban-types
parse(text: string, parseError?: Function): boolean {
try {
text = text + '\n';
this.db.clear();
this.parser.parse(text);
return true;
} catch (error) {
// Is this the correct way to access mermiad's parseError()
// method ? (or global.mermaid.parseError()) ?
if (parseError) {
if (isDetailedError(error)) {
// handle case where error string and hash were
// wrapped in object like`const error = { str, hash };`
parseError(error.str, error.hash);
} else {
// assume it is just error string and pass it on
parseError(error);
}
} else {
// No mermaid.parseError() handler defined, so re-throw it
throw error;
}
}
return false;
}
getParser() {
return this.parser;
}
getType() {
return this.type;
}
}
export default Diagram;

View File

@@ -0,0 +1,48 @@
/**
* Mocks for `./mermaidAPI`.
*
* We can't easily use `vi.spyOn(mermaidAPI, "function")` since the object is frozen with `Object.freeze()`.
*/
import * as configApi from '../config';
import { vi } from 'vitest';
import { addDiagrams } from '../diagram-api/diagram-orchestration';
import Diagram from '../Diagram';
// Normally, we could just do the following to get the original `parse()`
// implementation, however, requireActual returns a promise and it's not documented how to use withing mock file.
let hasLoadedDiagrams = false;
/**
* @param text
* @param parseError
*/
// eslint-disable-next-line @typescript-eslint/ban-types
function parse(text: string, parseError?: Function): boolean {
if (!hasLoadedDiagrams) {
addDiagrams();
hasLoadedDiagrams = true;
}
const diagram = new Diagram(text, parseError);
return diagram.parse(text, parseError);
}
// original version cannot be modified since it was frozen with `Object.freeze()`
export const mermaidAPI = {
render: vi.fn(),
parse,
parseDirective: vi.fn(),
initialize: vi.fn(),
getConfig: configApi.getConfig,
setConfig: configApi.setConfig,
getSiteConfig: configApi.getSiteConfig,
updateSiteConfig: configApi.updateSiteConfig,
reset: () => {
configApi.reset();
},
globalReset: () => {
configApi.reset(configApi.defaultConfig);
},
defaultConfig: configApi.defaultConfig,
};
export default mermaidAPI;

View File

@@ -0,0 +1,29 @@
/**
* This method will add a basic title and description element to a chart. The yy parser will need to
* respond to getAccTitle and getAccDescription, where the title is the title element on the chart,
* which is generally not displayed and the accDescription is the description element on the chart,
* which is never displayed.
*
* The following charts display their title as a visual and accessibility element: gantt
*
* @param yy_parser
* @param svg
* @param id
*/
export default function addSVGAccessibilityFields(yy_parser, svg, id) {
if (typeof svg.insert === 'undefined') {
return;
}
let title_string = yy_parser.getAccTitle();
let description = yy_parser.getAccDescription();
svg.attr('role', 'img').attr('aria-labelledby', 'chart-title-' + id + ' chart-desc-' + id);
svg
.insert('desc', ':first-child')
.attr('id', 'chart-desc-' + id)
.text(description);
svg
.insert('title', ':first-child')
.attr('id', 'chart-title-' + id)
.text(title_string);
}

View File

@@ -0,0 +1,66 @@
'use strict';
/**
* @function assignWithDepth Extends the functionality of {@link ObjectConstructor.assign} with the
* ability to merge arbitrary-depth objects For each key in src with path `k` (recursively)
* performs an Object.assign(dst[`k`], src[`k`]) with a slight change from the typical handling of
* undefined for dst[`k`]: instead of raising an error, dst[`k`] is auto-initialized to {} and
* effectively merged with src[`k`]<p> Additionally, dissimilar types will not clobber unless the
* config.clobber parameter === true. Example:
*
* ```js
* let config_0 = { foo: { bar: 'bar' }, bar: 'foo' };
* let config_1 = { foo: 'foo', bar: 'bar' };
* let result = assignWithDepth(config_0, config_1);
* console.log(result);
* //-> result: { foo: { bar: 'bar' }, bar: 'bar' }
* ```
*
* Traditional Object.assign would have clobbered foo in config_0 with foo in config_1. If src is a
* destructured array of objects and dst is not an array, assignWithDepth will apply each element
* of src to dst in order.
* @param {any} dst - The destination of the merge
* @param {any} src - The source object(s) to merge into destination
* @param {{ depth: number; clobber: boolean }} [config={ depth: 2, clobber: false }] - Depth: depth
* to traverse within src and dst for merging - clobber: should dissimilar types clobber (default:
* { depth: 2, clobber: false }). Default is `{ depth: 2, clobber: false }`
* @returns {any}
*/
const assignWithDepth = function (dst, src, config) {
const { depth, clobber } = Object.assign({ depth: 2, clobber: false }, config);
if (Array.isArray(src) && !Array.isArray(dst)) {
src.forEach((s) => assignWithDepth(dst, s, config));
return dst;
} else if (Array.isArray(src) && Array.isArray(dst)) {
src.forEach((s) => {
if (dst.indexOf(s) === -1) {
dst.push(s);
}
});
return dst;
}
if (typeof dst === 'undefined' || depth <= 0) {
if (dst !== undefined && dst !== null && typeof dst === 'object' && typeof src === 'object') {
return Object.assign(dst, src);
} else {
return src;
}
}
if (typeof src !== 'undefined' && typeof dst === 'object' && typeof src === 'object') {
Object.keys(src).forEach((key) => {
if (
typeof src[key] === 'object' &&
(dst[key] === undefined || typeof dst[key] === 'object')
) {
if (dst[key] === undefined) {
dst[key] = Array.isArray(src[key]) ? [] : {};
}
dst[key] = assignWithDepth(dst[key], src[key], { depth: depth - 1, clobber });
} else if (clobber || (typeof dst[key] !== 'object' && typeof src[key] !== 'object')) {
dst[key] = src[key];
}
});
}
return dst;
};
export default assignWithDepth;

View File

@@ -0,0 +1,46 @@
import { sanitizeText as _sanitizeText } from './diagrams/common/common';
import { getConfig } from './config';
let title = '';
let diagramTitle = '';
let description = '';
const sanitizeText = (txt: string): string => _sanitizeText(txt, getConfig());
export const clear = function (): void {
title = '';
description = '';
diagramTitle = '';
};
export const setAccTitle = function (txt: string): void {
title = sanitizeText(txt).replace(/^\s+/g, '');
};
export const getAccTitle = function (): string {
return title || diagramTitle;
};
export const setAccDescription = function (txt: string): void {
description = sanitizeText(txt).replace(/\n\s+/g, '\n');
};
export const getAccDescription = function (): string {
return description;
};
export const setDiagramTitle = function (txt: string): void {
diagramTitle = sanitizeText(txt);
};
export const getDiagramTitle = function (): string {
return diagramTitle;
};
export default {
setAccTitle,
getAccTitle,
setDiagramTitle,
getDiagramTitle: getDiagramTitle,
getAccDescription,
setAccDescription,
clear,
};

View File

@@ -0,0 +1,56 @@
import * as configApi from './config';
describe('when working with site config', function () {
beforeEach(() => {
// Resets the site config to default config
configApi.setSiteConfig({});
});
it('should set site config and config properly', function () {
let config_0 = { foo: 'bar', bar: 0 };
configApi.setSiteConfig(config_0);
let config_1 = configApi.getSiteConfig();
let config_2 = configApi.getConfig();
expect(config_1.foo).toEqual(config_0.foo);
expect(config_1.bar).toEqual(config_0.bar);
expect(config_1).toEqual(config_2);
});
it('should respect secure keys when applying directives', function () {
let config_0 = {
foo: 'bar',
bar: 'cant-be-changed',
secure: [...configApi.defaultConfig.secure, 'bar'],
};
configApi.setSiteConfig(config_0);
const directive = { foo: 'baf', bar: 'should-not-be-allowed' };
const cfg = configApi.updateCurrentConfig(config_0, [directive]);
expect(cfg.foo).toEqual(directive.foo);
expect(cfg.bar).toBe(config_0.bar);
});
it('should set reset config properly', function () {
let config_0 = { foo: 'bar', bar: 0 };
configApi.setSiteConfig(config_0);
let config_1 = { foo: 'baf' };
configApi.setConfig(config_1);
let config_2 = configApi.getConfig();
expect(config_2.foo).toEqual(config_1.foo);
configApi.reset();
let config_3 = configApi.getConfig();
expect(config_3.foo).toEqual(config_0.foo);
let config_4 = configApi.getSiteConfig();
expect(config_4.foo).toEqual(config_0.foo);
});
it('should set global reset config properly', function () {
let config_0 = { foo: 'bar', bar: 0 };
configApi.setSiteConfig(config_0);
let config_1 = configApi.getSiteConfig();
expect(config_1.foo).toEqual(config_0.foo);
let config_2 = configApi.getConfig();
expect(config_2.foo).toEqual(config_0.foo);
configApi.setConfig({ foobar: 'bar0' });
let config_3 = configApi.getConfig();
expect(config_3.foobar).toEqual('bar0');
configApi.reset();
let config_4 = configApi.getConfig();
expect(config_4.foobar).toBeUndefined();
});
});

View File

@@ -0,0 +1,226 @@
import assignWithDepth from './assignWithDepth';
import { log } from './logger';
import theme from './themes';
import config from './defaultConfig';
import type { MermaidConfig } from './config.type';
export const defaultConfig: MermaidConfig = Object.freeze(config);
let siteConfig: MermaidConfig = assignWithDepth({}, defaultConfig);
let configFromInitialize: MermaidConfig;
let directives: any[] = [];
let currentConfig: MermaidConfig = assignWithDepth({}, defaultConfig);
export const updateCurrentConfig = (siteCfg: MermaidConfig, _directives: any[]) => {
// start with config being the siteConfig
let cfg: MermaidConfig = assignWithDepth({}, siteCfg);
// let sCfg = assignWithDepth(defaultConfig, siteConfigDelta);
// Join directives
let sumOfDirectives: MermaidConfig = {};
for (let i = 0; i < _directives.length; i++) {
const d = _directives[i];
sanitize(d);
// Apply the data from the directive where the the overrides the themeVariables
sumOfDirectives = assignWithDepth(sumOfDirectives, d);
}
cfg = assignWithDepth(cfg, sumOfDirectives);
if (sumOfDirectives.theme && sumOfDirectives.theme in theme) {
const tmpConfigFromInitialize = assignWithDepth({}, configFromInitialize);
const themeVariables = assignWithDepth(
tmpConfigFromInitialize.themeVariables || {},
sumOfDirectives.themeVariables
);
if (cfg.theme && cfg.theme in theme) {
cfg.themeVariables = theme[cfg.theme as keyof typeof theme].getThemeVariables(themeVariables);
}
}
currentConfig = cfg;
return cfg;
};
/**
* ## setSiteConfig
*
* | Function | Description | Type | Values |
* | ------------- | ------------------------------------- | ----------- | --------------------------------------- |
* | setSiteConfig | Sets the siteConfig to desired values | Put Request | Any Values, except ones in secure array |
*
* **Notes:** Sets the siteConfig. The siteConfig is a protected configuration for repeat use. Calls
* to reset() will reset the currentConfig to siteConfig. Calls to reset(configApi.defaultConfig)
* will reset siteConfig and currentConfig to the defaultConfig Note: currentConfig is set in this
* function _Default value: At default, will mirror Global Config_
*
* @param conf - The base currentConfig to use as siteConfig
* @returns {object} - The siteConfig
*/
export const setSiteConfig = (conf: MermaidConfig): MermaidConfig => {
siteConfig = assignWithDepth({}, defaultConfig);
siteConfig = assignWithDepth(siteConfig, conf);
// @ts-ignore: TODO Fix ts errors
if (conf.theme && theme[conf.theme]) {
// @ts-ignore: TODO Fix ts errors
siteConfig.themeVariables = theme[conf.theme].getThemeVariables(conf.themeVariables);
}
currentConfig = updateCurrentConfig(siteConfig, directives);
return siteConfig;
};
export const saveConfigFromInitialize = (conf: MermaidConfig): void => {
configFromInitialize = assignWithDepth({}, conf);
};
export const updateSiteConfig = (conf: MermaidConfig): MermaidConfig => {
siteConfig = assignWithDepth(siteConfig, conf);
updateCurrentConfig(siteConfig, directives);
return siteConfig;
};
/**
* ## getSiteConfig
*
* | Function | Description | Type | Values |
* | ------------- | ------------------------------------------------- | ----------- | -------------------------------- |
* | setSiteConfig | Returns the current siteConfig base configuration | Get Request | Returns Any Values in siteConfig |
*
* **Notes**: Returns **any** values in siteConfig.
*
* @returns {object} - The siteConfig
*/
export const getSiteConfig = (): MermaidConfig => {
return assignWithDepth({}, siteConfig);
};
/**
* ## setConfig
*
* | Function | Description | Type | Values |
* | ------------- | ------------------------------------- | ----------- | --------------------------------------- |
* | setSiteConfig | Sets the siteConfig to desired values | Put Request | Any Values, except ones in secure array |
*
* **Notes**: Sets the currentConfig. The parameter conf is sanitized based on the siteConfig.secure
* keys. Any values found in conf with key found in siteConfig.secure will be replaced with the
* corresponding siteConfig value.
*
* @param {any} conf - The potential currentConfig
* @returns {any} - The currentConfig merged with the sanitized conf
*/
export const setConfig = (conf: MermaidConfig): MermaidConfig => {
// sanitize(conf);
// Object.keys(conf).forEach(key => {
// const manipulator = manipulators[key];
// conf[key] = manipulator ? manipulator(conf[key]) : conf[key];
// });
assignWithDepth(currentConfig, conf);
return getConfig();
};
/**
* ## getConfig
*
* | Function | Description | Type | Return Values |
* | --------- | ------------------------- | ----------- | ------------------------------ |
* | getConfig | Obtains the currentConfig | Get Request | Any Values from current Config |
*
* **Notes**: Returns **any** the currentConfig
*
* @returns {any} - The currentConfig
*/
export const getConfig = (): MermaidConfig => {
return assignWithDepth({}, currentConfig);
};
/**
* ## sanitize
*
* | Function | Description | Type | Values |
* | -------- | -------------------------------------- | ----------- | ------ |
* | sanitize | Sets the siteConfig to desired values. | Put Request | None |
*
* Ensures options parameter does not attempt to override siteConfig secure keys **Notes**: modifies
* options in-place
*
* @param {any} options - The potential setConfig parameter
*/
export const sanitize = (options: any) => {
// Checking that options are not in the list of excluded options
['secure', ...(siteConfig.secure ?? [])].forEach((key) => {
if (typeof options[key] !== 'undefined') {
// DO NOT attempt to print options[key] within `${}` as a malicious script
// can exploit the logger's attempt to stringify the value and execute arbitrary code
log.debug(`Denied attempt to modify a secure key ${key}`, options[key]);
delete options[key];
}
});
// Check that there no attempts of prototype pollution
Object.keys(options).forEach((key) => {
if (key.indexOf('__') === 0) {
delete options[key];
}
});
// Check that there no attempts of xss, there should be no tags at all in the directive
// blocking data urls as base64 urls can contain svgs with inline script tags
Object.keys(options).forEach((key) => {
if (typeof options[key] === 'string') {
if (
options[key].indexOf('<') > -1 ||
options[key].indexOf('>') > -1 ||
options[key].indexOf('url(data:') > -1
) {
delete options[key];
}
}
if (typeof options[key] === 'object') {
sanitize(options[key]);
}
});
};
/**
* Pushes in a directive to the configuration
*
* @param {object} directive The directive to push in
*/
export const addDirective = (directive: any) => {
if (directive.fontFamily) {
if (!directive.themeVariables) {
directive.themeVariables = { fontFamily: directive.fontFamily };
} else {
if (!directive.themeVariables.fontFamily) {
directive.themeVariables = { fontFamily: directive.fontFamily };
}
}
}
directives.push(directive);
updateCurrentConfig(siteConfig, directives);
};
/**
* ## reset
*
* | Function | Description | Type | Required | Values |
* | -------- | ---------------------------- | ----------- | -------- | ------ |
* | reset | Resets currentConfig to conf | Put Request | Required | None |
*
* ## conf
*
* | Parameter | Description | Type | Required | Values |
* | --------- | -------------------------------------------------------------- | ---------- | -------- | -------------------------------------------- |
* | conf | base set of values, which currentConfig could be **reset** to. | Dictionary | Required | Any Values, with respect to the secure Array |
*
* **Notes**: (default: current siteConfig ) (optional, default `getSiteConfig()`)
*
* @param config
*/
export const reset = (config = siteConfig): void => {
// Replace current config with siteConfig
directives = [];
updateCurrentConfig(config, directives);
};

View File

@@ -0,0 +1,359 @@
// TODO: This was auto generated from defaultConfig. Needs to be verified.
import DOMPurify from 'dompurify';
export interface MermaidConfig {
theme?: string;
themeVariables?: any;
themeCSS?: string;
maxTextSize?: number;
darkMode?: boolean;
htmlLabels?: boolean;
fontFamily?: string;
altFontFamily?: string;
logLevel?: number;
securityLevel?: string;
startOnLoad?: boolean;
arrowMarkerAbsolute?: boolean;
secure?: string[];
deterministicIds?: boolean;
deterministicIDSeed?: string;
flowchart?: FlowchartDiagramConfig;
sequence?: SequenceDiagramConfig;
gantt?: GanttDiagramConfig;
journey?: JourneyDiagramConfig;
class?: ClassDiagramConfig;
state?: StateDiagramConfig;
er?: ErDiagramConfig;
pie?: PieDiagramConfig;
requirement?: RequirementDiagramConfig;
mindmap?: MindmapDiagramConfig;
gitGraph?: GitGraphDiagramConfig;
c4?: C4DiagramConfig;
dompurifyConfig?: DOMPurify.Config;
wrap?: boolean;
fontSize?: number;
}
// TODO: More configs needs to be moved in here
export interface BaseDiagramConfig {
useWidth?: number;
useMaxWidth?: boolean;
}
export interface C4DiagramConfig extends BaseDiagramConfig {
diagramMarginX?: number;
diagramMarginY?: number;
c4ShapeMargin?: number;
c4ShapePadding?: number;
width?: number;
height?: number;
boxMargin?: number;
c4ShapeInRow?: number;
nextLinePaddingX?: number;
c4BoundaryInRow?: number;
personFontSize?: string | number;
personFontFamily?: string;
personFontWeight?: string | number;
external_personFontSize?: string | number;
external_personFontFamily?: string;
external_personFontWeight?: string | number;
systemFontSize?: string | number;
systemFontFamily?: string;
systemFontWeight?: string | number;
external_systemFontSize?: string | number;
external_systemFontFamily?: string;
external_systemFontWeight?: string | number;
system_dbFontSize?: string | number;
system_dbFontFamily?: string;
system_dbFontWeight?: string | number;
external_system_dbFontSize?: string | number;
external_system_dbFontFamily?: string;
external_system_dbFontWeight?: string | number;
system_queueFontSize?: string | number;
system_queueFontFamily?: string;
system_queueFontWeight?: string | number;
external_system_queueFontSize?: string | number;
external_system_queueFontFamily?: string;
external_system_queueFontWeight?: string | number;
boundaryFontSize?: string | number;
boundaryFontFamily?: string;
boundaryFontWeight?: string | number;
messageFontSize?: string | number;
messageFontFamily?: string;
messageFontWeight?: string | number;
containerFontSize?: string | number;
containerFontFamily?: string;
containerFontWeight?: string | number;
external_containerFontSize?: string | number;
external_containerFontFamily?: string;
external_containerFontWeight?: string | number;
container_dbFontSize?: string | number;
container_dbFontFamily?: string;
container_dbFontWeight?: string | number;
external_container_dbFontSize?: string | number;
external_container_dbFontFamily?: string;
external_container_dbFontWeight?: string | number;
container_queueFontSize?: string | number;
container_queueFontFamily?: string;
container_queueFontWeight?: string | number;
external_container_queueFontSize?: string | number;
external_container_queueFontFamily?: string;
external_container_queueFontWeight?: string | number;
componentFontSize?: string | number;
componentFontFamily?: string;
componentFontWeight?: string | number;
external_componentFontSize?: string | number;
external_componentFontFamily?: string;
external_componentFontWeight?: string | number;
component_dbFontSize?: string | number;
component_dbFontFamily?: string;
component_dbFontWeight?: string | number;
external_component_dbFontSize?: string | number;
external_component_dbFontFamily?: string;
external_component_dbFontWeight?: string | number;
component_queueFontSize?: string | number;
component_queueFontFamily?: string;
component_queueFontWeight?: string | number;
external_component_queueFontSize?: string | number;
external_component_queueFontFamily?: string;
external_component_queueFontWeight?: string | number;
wrap?: boolean;
wrapPadding?: number;
person_bg_color?: string;
person_border_color?: string;
external_person_bg_color?: string;
external_person_border_color?: string;
system_bg_color?: string;
system_border_color?: string;
system_db_bg_color?: string;
system_db_border_color?: string;
system_queue_bg_color?: string;
system_queue_border_color?: string;
external_system_bg_color?: string;
external_system_border_color?: string;
external_system_db_bg_color?: string;
external_system_db_border_color?: string;
external_system_queue_bg_color?: string;
external_system_queue_border_color?: string;
container_bg_color?: string;
container_border_color?: string;
container_db_bg_color?: string;
container_db_border_color?: string;
container_queue_bg_color?: string;
container_queue_border_color?: string;
external_container_bg_color?: string;
external_container_border_color?: string;
external_container_db_bg_color?: string;
external_container_db_border_color?: string;
external_container_queue_bg_color?: string;
external_container_queue_border_color?: string;
component_bg_color?: string;
component_border_color?: string;
component_db_bg_color?: string;
component_db_border_color?: string;
component_queue_bg_color?: string;
component_queue_border_color?: string;
external_component_bg_color?: string;
external_component_border_color?: string;
external_component_db_bg_color?: string;
external_component_db_border_color?: string;
external_component_queue_bg_color?: string;
external_component_queue_border_color?: string;
personFont?: FontCalculator;
external_personFont?: FontCalculator;
systemFont?: FontCalculator;
external_systemFont?: FontCalculator;
system_dbFont?: FontCalculator;
external_system_dbFont?: FontCalculator;
system_queueFont?: FontCalculator;
external_system_queueFont?: FontCalculator;
containerFont?: FontCalculator;
external_containerFont?: FontCalculator;
container_dbFont?: FontCalculator;
external_container_dbFont?: FontCalculator;
container_queueFont?: FontCalculator;
external_container_queueFont?: FontCalculator;
componentFont?: FontCalculator;
external_componentFont?: FontCalculator;
component_dbFont?: FontCalculator;
external_component_dbFont?: FontCalculator;
component_queueFont?: FontCalculator;
external_component_queueFont?: FontCalculator;
boundaryFont?: FontCalculator;
messageFont?: FontCalculator;
}
export interface GitGraphDiagramConfig extends BaseDiagramConfig {
diagramPadding?: number;
nodeLabel?: NodeLabel;
mainBranchName?: string;
mainBranchOrder?: number;
showCommitLabel?: boolean;
showBranches?: boolean;
rotateCommitLabel?: boolean;
arrowMarkerAbsolute?: boolean;
}
export interface NodeLabel {
width?: number;
height?: number;
x?: number;
y?: number;
}
export interface RequirementDiagramConfig extends BaseDiagramConfig {
rect_fill?: string;
text_color?: string;
rect_border_size?: string;
rect_border_color?: string;
rect_min_width?: number;
rect_min_height?: number;
fontSize?: number;
rect_padding?: number;
line_height?: number;
}
export interface MindmapDiagramConfig extends BaseDiagramConfig {
useMaxWidth: boolean;
padding: number;
maxNodeWidth: number;
}
export type PieDiagramConfig = BaseDiagramConfig;
export interface ErDiagramConfig extends BaseDiagramConfig {
diagramPadding?: number;
layoutDirection?: string;
minEntityWidth?: number;
minEntityHeight?: number;
entityPadding?: number;
stroke?: string;
fill?: string;
fontSize?: number;
}
export interface StateDiagramConfig extends BaseDiagramConfig {
arrowMarkerAbsolute?: boolean;
dividerMargin?: number;
sizeUnit?: number;
padding?: number;
textHeight?: number;
titleShift?: number;
noteMargin?: number;
forkWidth?: number;
forkHeight?: number;
miniPadding?: number;
fontSizeFactor?: number;
fontSize?: number;
labelHeight?: number;
edgeLengthFactor?: string;
compositTitleSize?: number;
radius?: number;
defaultRenderer?: string;
}
export interface ClassDiagramConfig extends BaseDiagramConfig {
arrowMarkerAbsolute?: boolean;
dividerMargin?: number;
padding?: number;
textHeight?: number;
defaultRenderer?: string;
}
export interface JourneyDiagramConfig extends BaseDiagramConfig {
diagramMarginX?: number;
diagramMarginY?: number;
leftMargin?: number;
width?: number;
height?: number;
boxMargin?: number;
boxTextMargin?: number;
noteMargin?: number;
messageMargin?: number;
messageAlign?: string;
bottomMarginAdj?: number;
rightAngles?: boolean;
taskFontSize?: string | number;
taskFontFamily?: string;
taskMargin?: number;
activationWidth?: number;
textPlacement?: string;
actorColours?: string[];
sectionFills?: string[];
sectionColours?: string[];
}
export interface GanttDiagramConfig extends BaseDiagramConfig {
titleTopMargin?: number;
barHeight?: number;
barGap?: number;
topPadding?: number;
rightPadding?: number;
leftPadding?: number;
gridLineStartPadding?: number;
fontSize?: number;
sectionFontSize?: string | number;
numberSectionStyles?: number;
axisFormat?: string;
topAxis?: boolean;
}
export interface SequenceDiagramConfig extends BaseDiagramConfig {
arrowMarkerAbsolute?: boolean;
hideUnusedParticipants?: boolean;
activationWidth?: number;
diagramMarginX?: number;
diagramMarginY?: number;
actorMargin?: number;
width?: number;
height?: number;
boxMargin?: number;
boxTextMargin?: number;
noteMargin?: number;
messageMargin?: number;
messageAlign?: string;
mirrorActors?: boolean;
forceMenus?: boolean;
bottomMarginAdj?: number;
rightAngles?: boolean;
showSequenceNumbers?: boolean;
actorFontSize?: string | number;
actorFontFamily?: string;
actorFontWeight?: string | number;
noteFontSize?: string | number;
noteFontFamily?: string;
noteFontWeight?: string | number;
noteAlign?: string;
messageFontSize?: string | number;
messageFontFamily?: string;
messageFontWeight?: string | number;
wrap?: boolean;
wrapPadding?: number;
labelBoxWidth?: number;
labelBoxHeight?: number;
messageFont?: FontCalculator;
noteFont?: FontCalculator;
actorFont?: FontCalculator;
}
export interface FlowchartDiagramConfig extends BaseDiagramConfig {
arrowMarkerAbsolute?: boolean;
diagramPadding?: number;
htmlLabels?: boolean;
nodeSpacing?: number;
rankSpacing?: number;
curve?: string;
padding?: number;
defaultRenderer?: string;
}
export interface FontConfig {
fontSize?: string | number;
fontFamily?: string;
fontWeight?: string | number;
}
export type FontCalculator = () => Partial<FontConfig>;
export {};

View File

@@ -0,0 +1,140 @@
# Cluster handling
Dagre does not support edges between nodes and clusters or between clusters to other clusters. In order to remedy this shortcoming the dagre wrapper implements a few work-arounds.
In the diagram below there are two clusters and there are no edges to nodes outside the own cluster.
```mermaid
flowchart
subgraph C1
a --> b
end
subgraph C2
c
end
C1 --> C2
```
In this case the dagre-wrapper will transform the graph to the graph below.
```mermaid
flowchart
C1 --> C2
```
The new nodes C1 and C2 are a special type of nodes, clusterNodes. ClusterNodes have have the nodes in the cluster including the cluster attached in a graph object.
When rendering this diagram it it beeing rendered recursively. The diagram is rendered by the dagre-mermaid:render function which in turn will be used to render the node C1 and the node C2. The result of those renderings will be inserted as nodes in the "root" diagram. With this recursive approach it would be possible to have different layout direction for each cluster.
```
{ clusterNode: true, graph }
```
_Data for a clusterNode_
When a cluster has edges to or from some of its nodes leading outside the cluster the approach of recursive rendering can not be used as the layout of the graph needs to take responsibility for nodes outside of the cluster.
```mermaid
flowchart
subgraph C1
a
end
subgraph C2
b
end
a --> C2
```
To handle this case a special type of edge is inserted. The edge to/from the cluster is replaced with an edge to/from a node in the cluster which is tagged with toCluster/fromCluster. When rendering this edge the intersection between the edge and the border of the cluster is calculated making the edge start/stop there. In practice this renders like an an edge to/from the cluster.
In the diagram above the root diagram would be rendered with C1 whereas C2 would be rendered recursively.
Of these two approaches the top one renders better and is used when possible. When this is not possible, ie an edge is added crossing the border the non recursive approach is used.
# Graph objects and their properties
Explains the representation of various objects used to render the flow charts and what the properties mean. This ofc from the perspective of the dagre-wrapper.
## node
Sample object:
```json
{
"shape": "rect",
"labelText": "Test",
"rx": 0,
"ry": 0,
"class": "default",
"style": "",
"id": "Test",
"type": "group",
"padding": 15
}
```
This is set by the renderer of the diagram and insert the data that the wrapper neds for rendering.
| property | description |
| ---------- | ------------------------------------------------------------------------------------------------ |
| labelStyle | Css styles for the label. User for instance for styling the labels for clusters |
| shape | The shape of the node. |
| labelText | The text on the label |
| rx | The corner radius - maybe part of the shape instead? Used for rects. |
| ry | The corner radius - maybe part of the shape instead? Used for rects. |
| classes | Classes to be set for the shape. Not used |
| style | Css styles for the actual shape |
| id | id of the shape |
| type | if set to group then this node indicates _a cluster_. |
| padding | Padding. Passed from the render as this might differ between different diagrams. Maybe obsolete. |
| data | Non-generic data specific to the shape. |
# edge
arrowType sets the type of arrows to use. The following arrow types are currently supported:
arrow_cross
double_arrow_cross
arrow_point
double_arrow_point
arrow_circle
double_arrow_circle
Lets try to make these types semantic free so that diagram type semantics does not find its way in to this more generic layer.
Required edgeData for proper rendering:
| property | description |
| ---------- | -------------------------------------------------------------------- |
| id | Id of the edge |
| arrowHead | overlap between arrowHead and arrowType? |
| arrowType | overlap between arrowHead and arrowType? |
| style | |
| labelStyle | |
| label | overlap between label and labelText? |
| labelPos | |
| labelType | overlap between label and labelText? |
| thickness | Sets the thinkess of the edge. Can be \['normal', 'thick'\] |
| pattern | Sets the pattern of the edge. Can be \['solid', 'dotted', 'dashed'\] |
# Markers
Define what markers that should be included in the diagram with the insert markers function. The function takes two arguments, first the element in which the markers should be included and a list of the markers that should be added.
Ex:
insertMarkers(el, \['point', 'circle'\])
The example above adds the markers point and cross. This means that edges with the arrowTypes arrow_cross, double_arrow_cross, arrow_point and double_arrow_cross will get the corresponding markers but arrowType arrow_cross will have no impact.
Current markers:
- point - the standard arrow from flowcharts
- circle - Arrows ending with circle
- cross - arrows starting and ending with a cross
// Todo - in case of common renderer
# Common functions used by the renderer to be implemented by the Db
getDirection
getClasses

View File

@@ -0,0 +1,244 @@
import intersectRect from './intersect/intersect-rect';
import { log } from '../logger';
import createLabel from './createLabel';
import { select } from 'd3';
import { getConfig } from '../config';
import { evaluate } from '../diagrams/common/common';
const rect = (parent, node) => {
log.trace('Creating subgraph rect for ', node.id, node);
// Add outer g element
const shapeSvg = parent
.insert('g')
.attr('class', 'cluster' + (node.class ? ' ' + node.class : ''))
.attr('id', node.id);
// add the rect
const rect = shapeSvg.insert('rect', ':first-child');
// Create the label and insert it after the rect
const label = shapeSvg.insert('g').attr('class', 'cluster-label');
const text = label
.node()
.appendChild(createLabel(node.labelText, node.labelStyle, undefined, true));
// Get the size of the label
let bbox = text.getBBox();
if (evaluate(getConfig().flowchart.htmlLabels)) {
const div = text.children[0];
const dv = select(text);
bbox = div.getBoundingClientRect();
dv.attr('width', bbox.width);
dv.attr('height', bbox.height);
}
const padding = 0 * node.padding;
const halfPadding = padding / 2;
const width = node.width <= bbox.width + padding ? bbox.width + padding : node.width;
if (node.width <= bbox.width + padding) {
node.diff = (bbox.width - node.width) / 2 - node.padding / 2;
} else {
node.diff = -node.padding / 2;
}
log.trace('Data ', node, JSON.stringify(node));
// center the rect around its coordinate
rect
.attr('style', node.style)
.attr('rx', node.rx)
.attr('ry', node.ry)
.attr('x', node.x - width / 2)
.attr('y', node.y - node.height / 2 - halfPadding)
.attr('width', width)
.attr('height', node.height + padding);
// Center the label
label.attr(
'transform',
'translate(' +
(node.x - bbox.width / 2) +
', ' +
(node.y - node.height / 2 + node.padding / 3) +
')'
);
const rectBox = rect.node().getBBox();
node.width = rectBox.width;
node.height = rectBox.height;
node.intersect = function (point) {
return intersectRect(node, point);
};
return shapeSvg;
};
/**
* Non visible cluster where the note is group with its
*
* @param {any} parent
* @param {any} node
* @returns {any} ShapeSvg
*/
const noteGroup = (parent, node) => {
// Add outer g element
const shapeSvg = parent.insert('g').attr('class', 'note-cluster').attr('id', node.id);
// add the rect
const rect = shapeSvg.insert('rect', ':first-child');
const padding = 0 * node.padding;
const halfPadding = padding / 2;
// center the rect around its coordinate
rect
.attr('rx', node.rx)
.attr('ry', node.ry)
.attr('x', node.x - node.width / 2 - halfPadding)
.attr('y', node.y - node.height / 2 - halfPadding)
.attr('width', node.width + padding)
.attr('height', node.height + padding)
.attr('fill', 'none');
const rectBox = rect.node().getBBox();
node.width = rectBox.width;
node.height = rectBox.height;
node.intersect = function (point) {
return intersectRect(node, point);
};
return shapeSvg;
};
const roundedWithTitle = (parent, node) => {
// Add outer g element
const shapeSvg = parent.insert('g').attr('class', node.classes).attr('id', node.id);
// add the rect
const rect = shapeSvg.insert('rect', ':first-child');
// Create the label and insert it after the rect
const label = shapeSvg.insert('g').attr('class', 'cluster-label');
const innerRect = shapeSvg.append('rect');
const text = label
.node()
.appendChild(createLabel(node.labelText, node.labelStyle, undefined, true));
// Get the size of the label
let bbox = text.getBBox();
if (evaluate(getConfig().flowchart.htmlLabels)) {
const div = text.children[0];
const dv = select(text);
bbox = div.getBoundingClientRect();
dv.attr('width', bbox.width);
dv.attr('height', bbox.height);
}
bbox = text.getBBox();
const padding = 0 * node.padding;
const halfPadding = padding / 2;
const width = node.width <= bbox.width + node.padding ? bbox.width + node.padding : node.width;
if (node.width <= bbox.width + node.padding) {
node.diff = (bbox.width + node.padding * 0 - node.width) / 2;
} else {
node.diff = -node.padding / 2;
}
// center the rect around its coordinate
rect
.attr('class', 'outer')
.attr('x', node.x - width / 2 - halfPadding)
.attr('y', node.y - node.height / 2 - halfPadding)
.attr('width', width + padding)
.attr('height', node.height + padding);
innerRect
.attr('class', 'inner')
.attr('x', node.x - width / 2 - halfPadding)
.attr('y', node.y - node.height / 2 - halfPadding + bbox.height - 1)
.attr('width', width + padding)
.attr('height', node.height + padding - bbox.height - 3);
// Center the label
label.attr(
'transform',
'translate(' +
(node.x - bbox.width / 2) +
', ' +
(node.y -
node.height / 2 -
node.padding / 3 +
(evaluate(getConfig().flowchart.htmlLabels) ? 5 : 3)) +
')'
);
const rectBox = rect.node().getBBox();
node.height = rectBox.height;
node.intersect = function (point) {
return intersectRect(node, point);
};
return shapeSvg;
};
const divider = (parent, node) => {
// Add outer g element
const shapeSvg = parent.insert('g').attr('class', node.classes).attr('id', node.id);
// add the rect
const rect = shapeSvg.insert('rect', ':first-child');
const padding = 0 * node.padding;
const halfPadding = padding / 2;
// center the rect around its coordinate
rect
.attr('class', 'divider')
.attr('x', node.x - node.width / 2 - halfPadding)
.attr('y', node.y - node.height / 2)
.attr('width', node.width + padding)
.attr('height', node.height + padding);
const rectBox = rect.node().getBBox();
node.width = rectBox.width;
node.height = rectBox.height;
node.diff = -node.padding / 2;
node.intersect = function (point) {
return intersectRect(node, point);
};
return shapeSvg;
};
const shapes = { rect, roundedWithTitle, noteGroup, divider };
let clusterElems = {};
export const insertCluster = (elem, node) => {
log.trace('Inserting cluster');
const shape = node.shape || 'rect';
clusterElems[node.id] = shapes[shape](elem, node);
};
export const getClusterTitleWidth = (elem, node) => {
const label = createLabel(node.labelText, node.labelStyle, undefined, true);
elem.node().appendChild(label);
const width = label.getBBox().width;
elem.node().removeChild(label);
return width;
};
export const clear = () => {
clusterElems = {};
};
export const positionCluster = (node) => {
log.info('Position cluster (' + node.id + ', ' + node.x + ', ' + node.y + ')');
const el = clusterElems[node.id];
el.attr('transform', 'translate(' + node.x + ', ' + node.y + ')');
};

View File

@@ -0,0 +1,92 @@
import { select } from 'd3';
import { log } from '../logger';
import { getConfig } from '../config';
import { evaluate } from '../diagrams/common/common';
import { decodeEntities } from '../mermaidAPI';
/**
* @param dom
* @param styleFn
*/
function applyStyle(dom, styleFn) {
if (styleFn) {
dom.attr('style', styleFn);
}
}
/**
* @param {any} node
* @returns {SVGForeignObjectElement} Node
*/
function addHtmlLabel(node) {
const fo = select(document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject'));
const div = fo.append('xhtml:div');
const label = node.label;
const labelClass = node.isNode ? 'nodeLabel' : 'edgeLabel';
div.html(
'<span class="' +
labelClass +
'" ' +
(node.labelStyle ? 'style="' + node.labelStyle + '"' : '') +
'>' +
label +
'</span>'
);
applyStyle(div, node.labelStyle);
div.style('display', 'inline-block');
// Fix for firefox
div.style('white-space', 'nowrap');
div.attr('xmlns', 'http://www.w3.org/1999/xhtml');
return fo.node();
}
const createLabel = (_vertexText, style, isTitle, isNode) => {
let vertexText = _vertexText || '';
if (typeof vertexText === 'object') vertexText = vertexText[0];
if (evaluate(getConfig().flowchart.htmlLabels)) {
// TODO: addHtmlLabel accepts a labelStyle. Do we possibly have that?
vertexText = vertexText.replace(/\\n|\n/g, '<br />');
log.info('vertexText' + vertexText);
const node = {
isNode,
label: decodeEntities(vertexText).replace(
/fa[lrsb]?:fa-[\w-]+/g,
(s) => `<i class='${s.replace(':', ' ')}'></i>`
),
labelStyle: style.replace('fill:', 'color:'),
};
let vertexNode = addHtmlLabel(node);
// vertexNode.parentNode.removeChild(vertexNode);
return vertexNode;
} else {
const svgLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text');
svgLabel.setAttribute('style', style.replace('color:', 'fill:'));
let rows = [];
if (typeof vertexText === 'string') {
rows = vertexText.split(/\\n|\n|<br\s*\/?>/gi);
} else if (Array.isArray(vertexText)) {
rows = vertexText;
} else {
rows = [];
}
for (let j = 0; j < rows.length; j++) {
const tspan = document.createElementNS('http://www.w3.org/2000/svg', 'tspan');
tspan.setAttributeNS('http://www.w3.org/XML/1998/namespace', 'xml:space', 'preserve');
tspan.setAttribute('dy', '1em');
tspan.setAttribute('x', '0');
if (isTitle) {
tspan.setAttribute('class', 'title-row');
} else {
tspan.setAttribute('class', 'row');
}
tspan.textContent = rows[j].trim();
svgLabel.appendChild(tspan);
}
return svgLabel;
}
};
export default createLabel;

View File

@@ -0,0 +1,555 @@
import { log } from '../logger';
import createLabel from './createLabel';
import { line, curveBasis, select } from 'd3';
import { getConfig } from '../config';
import utils from '../utils';
import { evaluate } from '../diagrams/common/common';
let edgeLabels = {};
let terminalLabels = {};
export const clear = () => {
edgeLabels = {};
terminalLabels = {};
};
export const insertEdgeLabel = (elem, edge) => {
// Create the actual text element
const labelElement = createLabel(edge.label, edge.labelStyle);
// Create outer g, edgeLabel, this will be positioned after graph layout
const edgeLabel = elem.insert('g').attr('class', 'edgeLabel');
// Create inner g, label, this will be positioned now for centering the text
const label = edgeLabel.insert('g').attr('class', 'label');
label.node().appendChild(labelElement);
// Center the label
let bbox = labelElement.getBBox();
if (evaluate(getConfig().flowchart.htmlLabels)) {
const div = labelElement.children[0];
const dv = select(labelElement);
bbox = div.getBoundingClientRect();
dv.attr('width', bbox.width);
dv.attr('height', bbox.height);
}
label.attr('transform', 'translate(' + -bbox.width / 2 + ', ' + -bbox.height / 2 + ')');
// Make element accessible by id for positioning
edgeLabels[edge.id] = edgeLabel;
// Update the abstract data of the edge with the new information about its width and height
edge.width = bbox.width;
edge.height = bbox.height;
let fo;
if (edge.startLabelLeft) {
// Create the actual text element
const startLabelElement = createLabel(edge.startLabelLeft, edge.labelStyle);
const startEdgeLabelLeft = elem.insert('g').attr('class', 'edgeTerminals');
const inner = startEdgeLabelLeft.insert('g').attr('class', 'inner');
fo = inner.node().appendChild(startLabelElement);
const slBox = startLabelElement.getBBox();
inner.attr('transform', 'translate(' + -slBox.width / 2 + ', ' + -slBox.height / 2 + ')');
if (!terminalLabels[edge.id]) {
terminalLabels[edge.id] = {};
}
terminalLabels[edge.id].startLeft = startEdgeLabelLeft;
setTerminalWidth(fo, edge.startLabelLeft);
}
if (edge.startLabelRight) {
// Create the actual text element
const startLabelElement = createLabel(edge.startLabelRight, edge.labelStyle);
const startEdgeLabelRight = elem.insert('g').attr('class', 'edgeTerminals');
const inner = startEdgeLabelRight.insert('g').attr('class', 'inner');
fo = startEdgeLabelRight.node().appendChild(startLabelElement);
inner.node().appendChild(startLabelElement);
const slBox = startLabelElement.getBBox();
inner.attr('transform', 'translate(' + -slBox.width / 2 + ', ' + -slBox.height / 2 + ')');
if (!terminalLabels[edge.id]) {
terminalLabels[edge.id] = {};
}
terminalLabels[edge.id].startRight = startEdgeLabelRight;
setTerminalWidth(fo, edge.startLabelRight);
}
if (edge.endLabelLeft) {
// Create the actual text element
const endLabelElement = createLabel(edge.endLabelLeft, edge.labelStyle);
const endEdgeLabelLeft = elem.insert('g').attr('class', 'edgeTerminals');
const inner = endEdgeLabelLeft.insert('g').attr('class', 'inner');
fo = inner.node().appendChild(endLabelElement);
const slBox = endLabelElement.getBBox();
inner.attr('transform', 'translate(' + -slBox.width / 2 + ', ' + -slBox.height / 2 + ')');
endEdgeLabelLeft.node().appendChild(endLabelElement);
if (!terminalLabels[edge.id]) {
terminalLabels[edge.id] = {};
}
terminalLabels[edge.id].endLeft = endEdgeLabelLeft;
setTerminalWidth(fo, edge.endLabelLeft);
}
if (edge.endLabelRight) {
// Create the actual text element
const endLabelElement = createLabel(edge.endLabelRight, edge.labelStyle);
const endEdgeLabelRight = elem.insert('g').attr('class', 'edgeTerminals');
const inner = endEdgeLabelRight.insert('g').attr('class', 'inner');
fo = inner.node().appendChild(endLabelElement);
const slBox = endLabelElement.getBBox();
inner.attr('transform', 'translate(' + -slBox.width / 2 + ', ' + -slBox.height / 2 + ')');
endEdgeLabelRight.node().appendChild(endLabelElement);
if (!terminalLabels[edge.id]) {
terminalLabels[edge.id] = {};
}
terminalLabels[edge.id].endRight = endEdgeLabelRight;
setTerminalWidth(fo, edge.endLabelRight);
}
};
/**
* @param {any} fo
* @param {any} value
*/
function setTerminalWidth(fo, value) {
if (getConfig().flowchart.htmlLabels && fo) {
fo.style.width = value.length * 9 + 'px';
fo.style.height = '12px';
}
}
export const positionEdgeLabel = (edge, paths) => {
log.info('Moving label abc78 ', edge.id, edge.label, edgeLabels[edge.id]);
let path = paths.updatedPath ? paths.updatedPath : paths.originalPath;
if (edge.label) {
const el = edgeLabels[edge.id];
let x = edge.x;
let y = edge.y;
if (path) {
// // debugger;
const pos = utils.calcLabelPosition(path);
log.info('Moving label from (', x, ',', y, ') to (', pos.x, ',', pos.y, ') abc78');
// x = pos.x;
// y = pos.y;
}
el.attr('transform', 'translate(' + x + ', ' + y + ')');
}
//let path = paths.updatedPath ? paths.updatedPath : paths.originalPath;
if (edge.startLabelLeft) {
const el = terminalLabels[edge.id].startLeft;
let x = edge.x;
let y = edge.y;
if (path) {
// debugger;
const pos = utils.calcTerminalLabelPosition(edge.arrowTypeStart ? 10 : 0, 'start_left', path);
x = pos.x;
y = pos.y;
}
el.attr('transform', 'translate(' + x + ', ' + y + ')');
}
if (edge.startLabelRight) {
const el = terminalLabels[edge.id].startRight;
let x = edge.x;
let y = edge.y;
if (path) {
// debugger;
const pos = utils.calcTerminalLabelPosition(
edge.arrowTypeStart ? 10 : 0,
'start_right',
path
);
x = pos.x;
y = pos.y;
}
el.attr('transform', 'translate(' + x + ', ' + y + ')');
}
if (edge.endLabelLeft) {
const el = terminalLabels[edge.id].endLeft;
let x = edge.x;
let y = edge.y;
if (path) {
// debugger;
const pos = utils.calcTerminalLabelPosition(edge.arrowTypeEnd ? 10 : 0, 'end_left', path);
x = pos.x;
y = pos.y;
}
el.attr('transform', 'translate(' + x + ', ' + y + ')');
}
if (edge.endLabelRight) {
const el = terminalLabels[edge.id].endRight;
let x = edge.x;
let y = edge.y;
if (path) {
// debugger;
const pos = utils.calcTerminalLabelPosition(edge.arrowTypeEnd ? 10 : 0, 'end_right', path);
x = pos.x;
y = pos.y;
}
el.attr('transform', 'translate(' + x + ', ' + y + ')');
}
};
const outsideNode = (node, point) => {
// log.warn('Checking bounds ', node, point);
const x = node.x;
const y = node.y;
const dx = Math.abs(point.x - x);
const dy = Math.abs(point.y - y);
const w = node.width / 2;
const h = node.height / 2;
if (dx >= w || dy >= h) {
return true;
}
return false;
};
export const intersection = (node, outsidePoint, insidePoint) => {
log.warn(`intersection calc abc89:
outsidePoint: ${JSON.stringify(outsidePoint)}
insidePoint : ${JSON.stringify(insidePoint)}
node : x:${node.x} y:${node.y} w:${node.width} h:${node.height}`);
const x = node.x;
const y = node.y;
const dx = Math.abs(x - insidePoint.x);
// const dy = Math.abs(y - insidePoint.y);
const w = node.width / 2;
let r = insidePoint.x < outsidePoint.x ? w - dx : w + dx;
const h = node.height / 2;
// const edges = {
// x1: x - w,
// x2: x + w,
// y1: y - h,
// y2: y + h
// };
// if (
// outsidePoint.x === edges.x1 ||
// outsidePoint.x === edges.x2 ||
// outsidePoint.y === edges.y1 ||
// outsidePoint.y === edges.y2
// ) {
// log.warn('abc89 calc equals on edge', outsidePoint, edges);
// return outsidePoint;
// }
const Q = Math.abs(outsidePoint.y - insidePoint.y);
const R = Math.abs(outsidePoint.x - insidePoint.x);
// log.warn();
if (Math.abs(y - outsidePoint.y) * w > Math.abs(x - outsidePoint.x) * h) {
// Intersection is top or bottom of rect.
// let q = insidePoint.y < outsidePoint.y ? outsidePoint.y - h - y : y - h - outsidePoint.y;
let q = insidePoint.y < outsidePoint.y ? outsidePoint.y - h - y : y - h - outsidePoint.y;
r = (R * q) / Q;
const res = {
x: insidePoint.x < outsidePoint.x ? insidePoint.x + r : insidePoint.x - R + r,
y: insidePoint.y < outsidePoint.y ? insidePoint.y + Q - q : insidePoint.y - Q + q,
};
if (r === 0) {
res.x = outsidePoint.x;
res.y = outsidePoint.y;
}
if (R === 0) {
res.x = outsidePoint.x;
}
if (Q === 0) {
res.y = outsidePoint.y;
}
log.warn(`abc89 topp/bott calc, Q ${Q}, q ${q}, R ${R}, r ${r}`, res);
return res;
} else {
// Intersection onn sides of rect
if (insidePoint.x < outsidePoint.x) {
r = outsidePoint.x - w - x;
} else {
// r = outsidePoint.x - w - x;
r = x - w - outsidePoint.x;
}
let q = (Q * r) / R;
// OK let _x = insidePoint.x < outsidePoint.x ? insidePoint.x + R - r : insidePoint.x + dx - w;
// OK let _x = insidePoint.x < outsidePoint.x ? insidePoint.x + R - r : outsidePoint.x + r;
let _x = insidePoint.x < outsidePoint.x ? insidePoint.x + R - r : insidePoint.x - R + r;
// let _x = insidePoint.x < outsidePoint.x ? insidePoint.x + R - r : outsidePoint.x + r;
let _y = insidePoint.y < outsidePoint.y ? insidePoint.y + q : insidePoint.y - q;
log.warn(`sides calc abc89, Q ${Q}, q ${q}, R ${R}, r ${r}`, { _x, _y });
if (r === 0) {
_x = outsidePoint.x;
_y = outsidePoint.y;
}
if (R === 0) {
_x = outsidePoint.x;
}
if (Q === 0) {
_y = outsidePoint.y;
}
return { x: _x, y: _y };
}
};
/**
* This function will page a path and node where the last point(s) in the path is inside the node
* and return an update path ending by the border of the node.
*
* @param {Array} _points
* @param {any} boundryNode
* @returns {Array} Points
*/
const cutPathAtIntersect = (_points, boundryNode) => {
log.warn('abc88 cutPathAtIntersect', _points, boundryNode);
let points = [];
let lastPointOutside = _points[0];
let isInside = false;
_points.forEach((point) => {
// const node = clusterDb[edge.toCluster].node;
log.info('abc88 checking point', point, boundryNode);
// check if point is inside the boundry rect
if (!outsideNode(boundryNode, point) && !isInside) {
// First point inside the rect found
// Calc the intersection coord between the point anf the last point outside the rect
const inter = intersection(boundryNode, lastPointOutside, point);
log.warn('abc88 inside', point, lastPointOutside, inter);
log.warn('abc88 intersection', inter);
// // Check case where the intersection is the same as the last point
let pointPresent = false;
points.forEach((p) => {
pointPresent = pointPresent || (p.x === inter.x && p.y === inter.y);
});
// // if (!pointPresent) {
if (!points.find((e) => e.x === inter.x && e.y === inter.y)) {
points.push(inter);
} else {
log.warn('abc88 no intersect', inter, points);
}
// points.push(inter);
isInside = true;
} else {
// Outside
log.warn('abc88 outside', point, lastPointOutside);
lastPointOutside = point;
// points.push(point);
if (!isInside) points.push(point);
}
});
log.warn('abc88 returning points', points);
return points;
};
//(edgePaths, e, edge, clusterDb, diagramtype, graph)
export const insertEdge = function (elem, e, edge, clusterDb, diagramType, graph) {
let points = edge.points;
let pointsHasChanged = false;
const tail = graph.node(e.v);
var head = graph.node(e.w);
log.info('abc88 InsertEdge: ', edge);
if (head.intersect && tail.intersect) {
points = points.slice(1, edge.points.length - 1);
points.unshift(tail.intersect(points[0]));
log.info(
'Last point',
points[points.length - 1],
head,
head.intersect(points[points.length - 1])
);
points.push(head.intersect(points[points.length - 1]));
}
if (edge.toCluster) {
log.info('to cluster abc88', clusterDb[edge.toCluster]);
points = cutPathAtIntersect(edge.points, clusterDb[edge.toCluster].node);
// log.trace('edge', edge);
// points = [];
// let lastPointOutside; // = edge.points[0];
// let isInside = false;
// edge.points.forEach(point => {
// const node = clusterDb[edge.toCluster].node;
// log.warn('checking from', edge.fromCluster, point, node);
// if (!outsideNode(node, point) && !isInside) {
// log.trace('inside', edge.toCluster, point, lastPointOutside);
// // First point inside the rect
// const inter = intersection(node, lastPointOutside, point);
// let pointPresent = false;
// points.forEach(p => {
// pointPresent = pointPresent || (p.x === inter.x && p.y === inter.y);
// });
// // if (!pointPresent) {
// if (!points.find(e => e.x === inter.x && e.y === inter.y)) {
// points.push(inter);
// } else {
// log.warn('no intersect', inter, points);
// }
// isInside = true;
// } else {
// // outside
// lastPointOutside = point;
// if (!isInside) points.push(point);
// }
// });
pointsHasChanged = true;
}
if (edge.fromCluster) {
log.info('from cluster abc88', clusterDb[edge.fromCluster]);
points = cutPathAtIntersect(points.reverse(), clusterDb[edge.fromCluster].node).reverse();
pointsHasChanged = true;
}
// The data for our line
const lineData = points.filter((p) => !Number.isNaN(p.y));
// This is the accessor function we talked about above
let curve;
// Currently only flowcharts get the curve from the settings, perhaps this should
// be expanded to a common setting? Restricting it for now in order not to cause side-effects that
// have not been thought through
if (diagramType === 'graph' || diagramType === 'flowchart') {
curve = edge.curve || curveBasis;
} else {
curve = curveBasis;
}
// curve = curveLinear;
const lineFunction = line()
.x(function (d) {
return d.x;
})
.y(function (d) {
return d.y;
})
.curve(curve);
// Contruct stroke classes based on properties
let strokeClasses;
switch (edge.thickness) {
case 'normal':
strokeClasses = 'edge-thickness-normal';
break;
case 'thick':
strokeClasses = 'edge-thickness-thick';
break;
default:
strokeClasses = '';
}
switch (edge.pattern) {
case 'solid':
strokeClasses += ' edge-pattern-solid';
break;
case 'dotted':
strokeClasses += ' edge-pattern-dotted';
break;
case 'dashed':
strokeClasses += ' edge-pattern-dashed';
break;
}
const svgPath = elem
.append('path')
.attr('d', lineFunction(lineData))
.attr('id', edge.id)
.attr('class', ' ' + strokeClasses + (edge.classes ? ' ' + edge.classes : ''))
.attr('style', edge.style);
// DEBUG code, adds a red circle at each edge coordinate
// edge.points.forEach(point => {
// elem
// .append('circle')
// .style('stroke', 'red')
// .style('fill', 'red')
// .attr('r', 1)
// .attr('cx', point.x)
// .attr('cy', point.y);
// });
let url = '';
// // TODO: Can we load this config only from the rendered graph type?
if (getConfig().flowchart.arrowMarkerAbsolute || getConfig().state.arrowMarkerAbsolute) {
url =
window.location.protocol +
'//' +
window.location.host +
window.location.pathname +
window.location.search;
url = url.replace(/\(/g, '\\(');
url = url.replace(/\)/g, '\\)');
}
log.info('arrowTypeStart', edge.arrowTypeStart);
log.info('arrowTypeEnd', edge.arrowTypeEnd);
switch (edge.arrowTypeStart) {
case 'arrow_cross':
svgPath.attr('marker-start', 'url(' + url + '#' + diagramType + '-crossStart' + ')');
break;
case 'arrow_point':
svgPath.attr('marker-start', 'url(' + url + '#' + diagramType + '-pointStart' + ')');
break;
case 'arrow_barb':
svgPath.attr('marker-start', 'url(' + url + '#' + diagramType + '-barbStart' + ')');
break;
case 'arrow_circle':
svgPath.attr('marker-start', 'url(' + url + '#' + diagramType + '-circleStart' + ')');
break;
case 'aggregation':
svgPath.attr('marker-start', 'url(' + url + '#' + diagramType + '-aggregationStart' + ')');
break;
case 'extension':
svgPath.attr('marker-start', 'url(' + url + '#' + diagramType + '-extensionStart' + ')');
break;
case 'composition':
svgPath.attr('marker-start', 'url(' + url + '#' + diagramType + '-compositionStart' + ')');
break;
case 'dependency':
svgPath.attr('marker-start', 'url(' + url + '#' + diagramType + '-dependencyStart' + ')');
break;
case 'lollipop':
svgPath.attr('marker-start', 'url(' + url + '#' + diagramType + '-lollipopStart' + ')');
break;
default:
}
switch (edge.arrowTypeEnd) {
case 'arrow_cross':
svgPath.attr('marker-end', 'url(' + url + '#' + diagramType + '-crossEnd' + ')');
break;
case 'arrow_point':
svgPath.attr('marker-end', 'url(' + url + '#' + diagramType + '-pointEnd' + ')');
break;
case 'arrow_barb':
svgPath.attr('marker-end', 'url(' + url + '#' + diagramType + '-barbEnd' + ')');
break;
case 'arrow_circle':
svgPath.attr('marker-end', 'url(' + url + '#' + diagramType + '-circleEnd' + ')');
break;
case 'aggregation':
svgPath.attr('marker-end', 'url(' + url + '#' + diagramType + '-aggregationEnd' + ')');
break;
case 'extension':
svgPath.attr('marker-end', 'url(' + url + '#' + diagramType + '-extensionEnd' + ')');
break;
case 'composition':
svgPath.attr('marker-end', 'url(' + url + '#' + diagramType + '-compositionEnd' + ')');
break;
case 'dependency':
svgPath.attr('marker-end', 'url(' + url + '#' + diagramType + '-dependencyEnd' + ')');
break;
case 'lollipop':
svgPath.attr('marker-end', 'url(' + url + '#' + diagramType + '-lollipopEnd' + ')');
break;
default:
}
let paths = {};
if (pointsHasChanged) {
paths.updatedPath = points;
}
paths.originalPath = edge.points;
return paths;
};

View File

@@ -0,0 +1,62 @@
import { intersection } from './edges';
import { setLogLevel } from '../logger';
describe('Graphlib decorations', () => {
let node;
beforeEach(function () {
setLogLevel(1);
node = { x: 171, y: 100, width: 210, height: 184 };
});
describe('intersection', function () {
it('case 1 - intersection on left edge of box', function () {
const o = { x: 31, y: 143.2257070163421 };
const i = { x: 99.3359375, y: 100 };
const int = intersection(node, o, i);
expect(int.x).toBe(66);
expect(int.y).toBeCloseTo(122.139);
});
it('case 2 - intersection on left edge of box', function () {
const o = { x: 310.2578125, y: 169.88002060631462 };
const i = { x: 127.96875, y: 100 };
const node2 = {
height: 337.5,
width: 184.4609375,
x: 100.23046875,
y: 176.75,
};
const int = intersection(node2, o, i);
expect(int.x).toBeCloseTo(192.4609375);
expect(int.y).toBeCloseTo(145.15711441743503);
});
it('case 3 - intersection on top of box outside point greater then inside point', function () {
const o = { x: 157, y: 39 };
const i = { x: 104, y: 105 };
const node2 = {
width: 212,
x: 114,
y: 164,
height: 176,
};
const int = intersection(node2, o, i);
expect(int.x).toBeCloseTo(133.71);
expect(int.y).toBeCloseTo(76);
// expect(int.y).toBeCloseTo(67.833)
});
it('case 4 - intersection on top of box inside point greater then inside point', function () {
const o = { x: 144, y: 38 };
const i = { x: 198, y: 105 };
const node2 = {
width: 212,
x: 114,
y: 164,
height: 176,
};
const int = intersection(node2, o, i);
expect(int.x).toBeCloseTo(174.626);
expect(int.y).toBeCloseTo(76);
// expect(int.y).toBeCloseTo(67.833)
});
});
});

View File

@@ -0,0 +1,171 @@
import dagre from 'dagre';
import graphlib from 'graphlib';
import insertMarkers from './markers';
import { updateNodeBounds } from './shapes/util';
import {
clear as clearGraphlib,
clusterDb,
adjustClustersAndEdges,
findNonClusterChild,
sortNodesByHierarchy,
} from './mermaid-graphlib';
import { insertNode, positionNode, clear as clearNodes, setNodeElem } from './nodes';
import { insertCluster, clear as clearClusters } from './clusters';
import { insertEdgeLabel, positionEdgeLabel, insertEdge, clear as clearEdges } from './edges';
import { log } from '../logger';
const recursiveRender = (_elem, graph, diagramtype, parentCluster) => {
log.info('Graph in recursive render: XXX', graphlib.json.write(graph), parentCluster);
const dir = graph.graph().rankdir;
log.trace('Dir in recursive render - dir:', dir);
const elem = _elem.insert('g').attr('class', 'root');
if (!graph.nodes()) {
log.info('No nodes found for', graph);
} else {
log.info('Recursive render XXX', graph.nodes());
}
if (graph.edges().length > 0) {
log.trace('Recursive edges', graph.edge(graph.edges()[0]));
}
const clusters = elem.insert('g').attr('class', 'clusters');
const edgePaths = elem.insert('g').attr('class', 'edgePaths');
const edgeLabels = elem.insert('g').attr('class', 'edgeLabels');
const nodes = elem.insert('g').attr('class', 'nodes');
// Insert nodes, this will insert them into the dom and each node will get a size. The size is updated
// to the abstract node and is later used by dagre for the layout
graph.nodes().forEach(function (v) {
const node = graph.node(v);
if (typeof parentCluster !== 'undefined') {
const data = JSON.parse(JSON.stringify(parentCluster.clusterData));
// data.clusterPositioning = true;
log.info('Setting data for cluster XXX (', v, ') ', data, parentCluster);
graph.setNode(parentCluster.id, data);
if (!graph.parent(v)) {
log.trace('Setting parent', v, parentCluster.id);
graph.setParent(v, parentCluster.id, data);
}
}
log.info('(Insert) Node XXX' + v + ': ' + JSON.stringify(graph.node(v)));
if (node && node.clusterNode) {
// const children = graph.children(v);
log.info('Cluster identified', v, node.width, graph.node(v));
const o = recursiveRender(nodes, node.graph, diagramtype, graph.node(v));
const newEl = o.elem;
updateNodeBounds(node, newEl);
node.diff = o.diff || 0;
log.info('Node bounds (abc123)', v, node, node.width, node.x, node.y);
setNodeElem(newEl, node);
log.warn('Recursive render complete ', newEl, node);
} else {
if (graph.children(v).length > 0) {
// This is a cluster but not to be rendered recursively
// Render as before
log.info('Cluster - the non recursive path XXX', v, node.id, node, graph);
log.info(findNonClusterChild(node.id, graph));
clusterDb[node.id] = { id: findNonClusterChild(node.id, graph), node };
// insertCluster(clusters, graph.node(v));
} else {
log.info('Node - the non recursive path', v, node.id, node);
insertNode(nodes, graph.node(v), dir);
}
}
});
// Insert labels, this will insert them into the dom so that the width can be calculated
// Also figure out which edges point to/from clusters and adjust them accordingly
// Edges from/to clusters really points to the first child in the cluster.
// TODO: pick optimal child in the cluster to us as link anchor
graph.edges().forEach(function (e) {
const edge = graph.edge(e.v, e.w, e.name);
log.info('Edge ' + e.v + ' -> ' + e.w + ': ' + JSON.stringify(e));
log.info('Edge ' + e.v + ' -> ' + e.w + ': ', e, ' ', JSON.stringify(graph.edge(e)));
// Check if link is either from or to a cluster
log.info('Fix', clusterDb, 'ids:', e.v, e.w, 'Translateing: ', clusterDb[e.v], clusterDb[e.w]);
insertEdgeLabel(edgeLabels, edge);
});
graph.edges().forEach(function (e) {
log.info('Edge ' + e.v + ' -> ' + e.w + ': ' + JSON.stringify(e));
});
log.info('#############################################');
log.info('### Layout ###');
log.info('#############################################');
log.info(graph);
dagre.layout(graph);
log.info('Graph after layout:', graphlib.json.write(graph));
// Move the nodes to the correct place
let diff = 0;
sortNodesByHierarchy(graph).forEach(function (v) {
const node = graph.node(v);
log.info('Position ' + v + ': ' + JSON.stringify(graph.node(v)));
log.info(
'Position ' + v + ': (' + node.x,
',' + node.y,
') width: ',
node.width,
' height: ',
node.height
);
if (node && node.clusterNode) {
// clusterDb[node.id].node = node;
positionNode(node);
} else {
// Non cluster node
if (graph.children(v).length > 0) {
// A cluster in the non-recursive way
// positionCluster(node);
insertCluster(clusters, node);
clusterDb[node.id].node = node;
} else {
positionNode(node);
}
}
});
// Move the edge labels to the correct place after layout
graph.edges().forEach(function (e) {
const edge = graph.edge(e);
log.info('Edge ' + e.v + ' -> ' + e.w + ': ' + JSON.stringify(edge), edge);
const paths = insertEdge(edgePaths, e, edge, clusterDb, diagramtype, graph);
positionEdgeLabel(edge, paths);
});
graph.nodes().forEach(function (v) {
const n = graph.node(v);
log.info(v, n.type, n.diff);
if (n.type === 'group') {
diff = n.diff;
}
});
return { elem, diff };
};
export const render = (elem, graph, markers, diagramtype, id) => {
insertMarkers(elem, markers, diagramtype, id);
clearNodes();
clearEdges();
clearClusters();
clearGraphlib();
log.warn('Graph at first:', graphlib.json.write(graph));
adjustClustersAndEdges(graph);
log.warn('Graph after:', graphlib.json.write(graph));
// log.warn('Graph ever after:', graphlib.json.write(graph.node('A').graph));
recursiveRender(elem, graph, diagramtype);
};
// const shapeDefinitions = {};
// export const addShape = ({ shapeType: fun }) => {
// shapeDefinitions[shapeType] = fun;
// };
// const arrowDefinitions = {};
// export const addArrow = ({ arrowType: fun }) => {
// arrowDefinitions[arrowType] = fun;
// };

View File

@@ -0,0 +1,7 @@
module.exports = {
node: require('./intersect-node'),
circle: require('./intersect-circle'),
ellipse: require('./intersect-ellipse'),
polygon: require('./intersect-polygon'),
rect: require('./intersect-rect'),
};

View File

@@ -0,0 +1,17 @@
/*
* Borrowed with love from from dagrge-d3. Many thanks to cpettitt!
*/
import node from './intersect-node.js';
import circle from './intersect-circle.js';
import ellipse from './intersect-ellipse.js';
import polygon from './intersect-polygon.js';
import rect from './intersect-rect.js';
export default {
node,
circle,
ellipse,
polygon,
rect,
};

View File

@@ -0,0 +1,12 @@
import intersectEllipse from './intersect-ellipse';
/**
* @param node
* @param rx
* @param point
*/
function intersectCircle(node, rx, point) {
return intersectEllipse(node, rx, rx, point);
}
export default intersectCircle;

View File

@@ -0,0 +1,30 @@
/**
* @param node
* @param rx
* @param ry
* @param point
*/
function intersectEllipse(node, rx, ry, point) {
// Formulae from: https://mathworld.wolfram.com/Ellipse-LineIntersection.html
var cx = node.x;
var cy = node.y;
var px = cx - point.x;
var py = cy - point.y;
var det = Math.sqrt(rx * rx * py * py + ry * ry * px * px);
var dx = Math.abs((rx * ry * px) / det);
if (point.x < cx) {
dx = -dx;
}
var dy = Math.abs((rx * ry * py) / det);
if (point.y < cy) {
dy = -dy;
}
return { x: cx + dx, y: cy + dy };
}
export default intersectEllipse;

View File

@@ -0,0 +1,78 @@
/**
* Returns the point at which two lines, p and q, intersect or returns undefined if they do not intersect.
*
* @param p1
* @param p2
* @param q1
* @param q2
*/
function intersectLine(p1, p2, q1, q2) {
// Algorithm from J. Avro, (ed.) Graphics Gems, No 2, Morgan Kaufmann, 1994,
// p7 and p473.
var a1, a2, b1, b2, c1, c2;
var r1, r2, r3, r4;
var denom, offset, num;
var x, y;
// Compute a1, b1, c1, where line joining points 1 and 2 is F(x,y) = a1 x +
// b1 y + c1 = 0.
a1 = p2.y - p1.y;
b1 = p1.x - p2.x;
c1 = p2.x * p1.y - p1.x * p2.y;
// Compute r3 and r4.
r3 = a1 * q1.x + b1 * q1.y + c1;
r4 = a1 * q2.x + b1 * q2.y + c1;
// Check signs of r3 and r4. If both point 3 and point 4 lie on
// same side of line 1, the line segments do not intersect.
if (r3 !== 0 && r4 !== 0 && sameSign(r3, r4)) {
return /*DONT_INTERSECT*/;
}
// Compute a2, b2, c2 where line joining points 3 and 4 is G(x,y) = a2 x + b2 y + c2 = 0
a2 = q2.y - q1.y;
b2 = q1.x - q2.x;
c2 = q2.x * q1.y - q1.x * q2.y;
// Compute r1 and r2
r1 = a2 * p1.x + b2 * p1.y + c2;
r2 = a2 * p2.x + b2 * p2.y + c2;
// Check signs of r1 and r2. If both point 1 and point 2 lie
// on same side of second line segment, the line segments do
// not intersect.
if (r1 !== 0 && r2 !== 0 && sameSign(r1, r2)) {
return /*DONT_INTERSECT*/;
}
// Line segments intersect: compute intersection point.
denom = a1 * b2 - a2 * b1;
if (denom === 0) {
return /*COLLINEAR*/;
}
offset = Math.abs(denom / 2);
// The denom/2 is to get rounding instead of truncating. It
// is added or subtracted to the numerator, depending upon the
// sign of the numerator.
num = b1 * c2 - b2 * c1;
x = num < 0 ? (num - offset) / denom : (num + offset) / denom;
num = a2 * c1 - a1 * c2;
y = num < 0 ? (num - offset) / denom : (num + offset) / denom;
return { x: x, y: y };
}
/**
* @param r1
* @param r2
*/
function sameSign(r1, r2) {
return r1 * r2 > 0;
}
export default intersectLine;

View File

@@ -0,0 +1,10 @@
/**
* @param node
* @param point
*/
function intersectNode(node, point) {
// console.info('Intersect Node');
return node.intersect(point);
}
export default intersectNode;

View File

@@ -0,0 +1,70 @@
/* eslint "no-console": off */
import intersectLine from './intersect-line';
export default intersectPolygon;
/**
* Returns the point ({x, y}) at which the point argument intersects with the node argument assuming
* that it has the shape specified by polygon.
*
* @param node
* @param polyPoints
* @param point
*/
function intersectPolygon(node, polyPoints, point) {
var x1 = node.x;
var y1 = node.y;
var intersections = [];
var minX = Number.POSITIVE_INFINITY;
var minY = Number.POSITIVE_INFINITY;
if (typeof polyPoints.forEach === 'function') {
polyPoints.forEach(function (entry) {
minX = Math.min(minX, entry.x);
minY = Math.min(minY, entry.y);
});
} else {
minX = Math.min(minX, polyPoints.x);
minY = Math.min(minY, polyPoints.y);
}
var left = x1 - node.width / 2 - minX;
var top = y1 - node.height / 2 - minY;
for (var i = 0; i < polyPoints.length; i++) {
var p1 = polyPoints[i];
var p2 = polyPoints[i < polyPoints.length - 1 ? i + 1 : 0];
var intersect = intersectLine(
node,
point,
{ x: left + p1.x, y: top + p1.y },
{ x: left + p2.x, y: top + p2.y }
);
if (intersect) {
intersections.push(intersect);
}
}
if (!intersections.length) {
// console.log('NO INTERSECTION FOUND, RETURN NODE CENTER', node);
return node;
}
if (intersections.length > 1) {
// More intersections, find the one nearest to edge end point
intersections.sort(function (p, q) {
var pdx = p.x - point.x;
var pdy = p.y - point.y;
var distp = Math.sqrt(pdx * pdx + pdy * pdy);
var qdx = q.x - point.x;
var qdy = q.y - point.y;
var distq = Math.sqrt(qdx * qdx + qdy * qdy);
return distp < distq ? -1 : distp === distq ? 0 : 1;
});
}
return intersections[0];
}

View File

@@ -0,0 +1,32 @@
const intersectRect = (node, point) => {
var x = node.x;
var y = node.y;
// Rectangle intersection algorithm from:
// https://math.stackexchange.com/questions/108113/find-edge-between-two-boxes
var dx = point.x - x;
var dy = point.y - y;
var w = node.width / 2;
var h = node.height / 2;
var sx, sy;
if (Math.abs(dy) * w > Math.abs(dx) * h) {
// Intersection is top or bottom of rect.
if (dy < 0) {
h = -h;
}
sx = dy === 0 ? 0 : (h * dx) / dy;
sy = h;
} else {
// Intersection is left or right of rect.
if (dx < 0) {
w = -w;
}
sx = w;
sy = dx === 0 ? 0 : (w * dy) / dx;
}
return { x: x + sx, y: y + sy };
};
export default intersectRect;

View File

@@ -0,0 +1,277 @@
/** Setup arrow head and define the marker. The result is appended to the svg. */
import { log } from '../logger';
// Only add the number of markers that the diagram needs
const insertMarkers = (elem, markerArray, type, id) => {
markerArray.forEach((markerName) => {
markers[markerName](elem, type, id);
});
};
const extension = (elem, type, id) => {
log.trace('Making markers for ', id);
elem
.append('defs')
.append('marker')
.attr('id', type + '-extensionStart')
.attr('class', 'marker extension ' + type)
.attr('refX', 0)
.attr('refY', 7)
.attr('markerWidth', 190)
.attr('markerHeight', 240)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M 1,7 L18,13 V 1 Z');
elem
.append('defs')
.append('marker')
.attr('id', type + '-extensionEnd')
.attr('class', 'marker extension ' + type)
.attr('refX', 19)
.attr('refY', 7)
.attr('markerWidth', 20)
.attr('markerHeight', 28)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M 1,1 V 13 L18,7 Z'); // this is actual shape for arrowhead
};
const composition = (elem, type) => {
elem
.append('defs')
.append('marker')
.attr('id', type + '-compositionStart')
.attr('class', 'marker composition ' + type)
.attr('refX', 0)
.attr('refY', 7)
.attr('markerWidth', 190)
.attr('markerHeight', 240)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M 18,7 L9,13 L1,7 L9,1 Z');
elem
.append('defs')
.append('marker')
.attr('id', type + '-compositionEnd')
.attr('class', 'marker composition ' + type)
.attr('refX', 19)
.attr('refY', 7)
.attr('markerWidth', 20)
.attr('markerHeight', 28)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M 18,7 L9,13 L1,7 L9,1 Z');
};
const aggregation = (elem, type) => {
elem
.append('defs')
.append('marker')
.attr('id', type + '-aggregationStart')
.attr('class', 'marker aggregation ' + type)
.attr('refX', 0)
.attr('refY', 7)
.attr('markerWidth', 190)
.attr('markerHeight', 240)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M 18,7 L9,13 L1,7 L9,1 Z');
elem
.append('defs')
.append('marker')
.attr('id', type + '-aggregationEnd')
.attr('class', 'marker aggregation ' + type)
.attr('refX', 19)
.attr('refY', 7)
.attr('markerWidth', 20)
.attr('markerHeight', 28)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M 18,7 L9,13 L1,7 L9,1 Z');
};
const dependency = (elem, type) => {
elem
.append('defs')
.append('marker')
.attr('id', type + '-dependencyStart')
.attr('class', 'marker dependency ' + type)
.attr('refX', 0)
.attr('refY', 7)
.attr('markerWidth', 190)
.attr('markerHeight', 240)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M 5,7 L9,13 L1,7 L9,1 Z');
elem
.append('defs')
.append('marker')
.attr('id', type + '-dependencyEnd')
.attr('class', 'marker dependency ' + type)
.attr('refX', 19)
.attr('refY', 7)
.attr('markerWidth', 20)
.attr('markerHeight', 28)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M 18,7 L9,13 L14,7 L9,1 Z');
};
const lollipop = (elem, type) => {
elem
.append('defs')
.append('marker')
.attr('id', type + '-lollipopStart')
.attr('class', 'marker lollipop ' + type)
.attr('refX', 0)
.attr('refY', 7)
.attr('markerWidth', 190)
.attr('markerHeight', 240)
.attr('orient', 'auto')
.append('circle')
.attr('stroke', 'black')
.attr('fill', 'white')
.attr('cx', 6)
.attr('cy', 7)
.attr('r', 6);
};
const point = (elem, type) => {
elem
.append('marker')
.attr('id', type + '-pointEnd')
.attr('class', 'marker ' + type)
.attr('viewBox', '0 0 10 10')
.attr('refX', 9)
.attr('refY', 5)
.attr('markerUnits', 'userSpaceOnUse')
.attr('markerWidth', 12)
.attr('markerHeight', 12)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M 0 0 L 10 5 L 0 10 z')
.attr('class', 'arrowMarkerPath')
.style('stroke-width', 1)
.style('stroke-dasharray', '1,0');
elem
.append('marker')
.attr('id', type + '-pointStart')
.attr('class', 'marker ' + type)
.attr('viewBox', '0 0 10 10')
.attr('refX', 0)
.attr('refY', 5)
.attr('markerUnits', 'userSpaceOnUse')
.attr('markerWidth', 12)
.attr('markerHeight', 12)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M 0 5 L 10 10 L 10 0 z')
.attr('class', 'arrowMarkerPath')
.style('stroke-width', 1)
.style('stroke-dasharray', '1,0');
};
const circle = (elem, type) => {
elem
.append('marker')
.attr('id', type + '-circleEnd')
.attr('class', 'marker ' + type)
.attr('viewBox', '0 0 10 10')
.attr('refX', 11)
.attr('refY', 5)
.attr('markerUnits', 'userSpaceOnUse')
.attr('markerWidth', 11)
.attr('markerHeight', 11)
.attr('orient', 'auto')
.append('circle')
.attr('cx', '5')
.attr('cy', '5')
.attr('r', '5')
.attr('class', 'arrowMarkerPath')
.style('stroke-width', 1)
.style('stroke-dasharray', '1,0');
elem
.append('marker')
.attr('id', type + '-circleStart')
.attr('class', 'marker ' + type)
.attr('viewBox', '0 0 10 10')
.attr('refX', -1)
.attr('refY', 5)
.attr('markerUnits', 'userSpaceOnUse')
.attr('markerWidth', 11)
.attr('markerHeight', 11)
.attr('orient', 'auto')
.append('circle')
.attr('cx', '5')
.attr('cy', '5')
.attr('r', '5')
.attr('class', 'arrowMarkerPath')
.style('stroke-width', 1)
.style('stroke-dasharray', '1,0');
};
const cross = (elem, type) => {
elem
.append('marker')
.attr('id', type + '-crossEnd')
.attr('class', 'marker cross ' + type)
.attr('viewBox', '0 0 11 11')
.attr('refX', 12)
.attr('refY', 5.2)
.attr('markerUnits', 'userSpaceOnUse')
.attr('markerWidth', 11)
.attr('markerHeight', 11)
.attr('orient', 'auto')
.append('path')
// .attr('stroke', 'black')
.attr('d', 'M 1,1 l 9,9 M 10,1 l -9,9')
.attr('class', 'arrowMarkerPath')
.style('stroke-width', 2)
.style('stroke-dasharray', '1,0');
elem
.append('marker')
.attr('id', type + '-crossStart')
.attr('class', 'marker cross ' + type)
.attr('viewBox', '0 0 11 11')
.attr('refX', -1)
.attr('refY', 5.2)
.attr('markerUnits', 'userSpaceOnUse')
.attr('markerWidth', 11)
.attr('markerHeight', 11)
.attr('orient', 'auto')
.append('path')
// .attr('stroke', 'black')
.attr('d', 'M 1,1 l 9,9 M 10,1 l -9,9')
.attr('class', 'arrowMarkerPath')
.style('stroke-width', 2)
.style('stroke-dasharray', '1,0');
};
const barb = (elem, type) => {
elem
.append('defs')
.append('marker')
.attr('id', type + '-barbEnd')
.attr('refX', 19)
.attr('refY', 7)
.attr('markerWidth', 20)
.attr('markerHeight', 14)
.attr('markerUnits', 'strokeWidth')
.attr('orient', 'auto')
.append('path')
.attr('d', 'M 19,7 L9,13 L14,7 L9,1 Z');
};
// TODO rename the class diagram markers to something shape descriptive and semantic free
const markers = {
extension,
composition,
aggregation,
dependency,
lollipop,
point,
circle,
cross,
barb,
};
export default insertMarkers;

View File

@@ -0,0 +1,462 @@
/** Decorates with functions required by mermaids dagre-wrapper. */
import { log } from '../logger';
import graphlib from 'graphlib';
export let clusterDb = {};
let decendants = {};
let parents = {};
export const clear = () => {
decendants = {};
parents = {};
clusterDb = {};
};
const isDecendant = (id, ancenstorId) => {
// if (id === ancenstorId) return true;
log.trace(
'In isDecendant',
ancenstorId,
' ',
id,
' = ',
decendants[ancenstorId].indexOf(id) >= 0
);
if (decendants[ancenstorId].indexOf(id) >= 0) return true;
return false;
};
const edgeInCluster = (edge, clusterId) => {
log.info('Decendants of ', clusterId, ' is ', decendants[clusterId]);
log.info('Edge is ', edge);
// Edges to/from the cluster is not in the cluster, they are in the parent
if (edge.v === clusterId) return false;
if (edge.w === clusterId) return false;
if (!decendants[clusterId]) {
log.debug('Tilt, ', clusterId, ',not in decendants');
return false;
}
log.info('Here ');
if (decendants[clusterId].indexOf(edge.v) >= 0) return true;
if (isDecendant(edge.v, clusterId)) return true;
if (isDecendant(edge.w, clusterId)) return true;
if (decendants[clusterId].indexOf(edge.w) >= 0) return true;
return false;
};
const copy = (clusterId, graph, newGraph, rootId) => {
log.warn(
'Copying children of ',
clusterId,
'root',
rootId,
'data',
graph.node(clusterId),
rootId
);
const nodes = graph.children(clusterId) || [];
// Include cluster node if it is not the root
if (clusterId !== rootId) {
nodes.push(clusterId);
}
log.warn('Copying (nodes) clusterId', clusterId, 'nodes', nodes);
nodes.forEach((node) => {
if (graph.children(node).length > 0) {
copy(node, graph, newGraph, rootId);
} else {
const data = graph.node(node);
log.info('cp ', node, ' to ', rootId, ' with parent ', clusterId); //,node, data, ' parent is ', clusterId);
newGraph.setNode(node, data);
if (rootId !== graph.parent(node)) {
log.warn('Setting parent', node, graph.parent(node));
newGraph.setParent(node, graph.parent(node));
}
if (clusterId !== rootId && node !== clusterId) {
log.debug('Setting parent', node, clusterId);
newGraph.setParent(node, clusterId);
} else {
log.info('In copy ', clusterId, 'root', rootId, 'data', graph.node(clusterId), rootId);
log.debug(
'Not Setting parent for node=',
node,
'cluster!==rootId',
clusterId !== rootId,
'node!==clusterId',
node !== clusterId
);
}
const edges = graph.edges(node);
log.debug('Copying Edges', edges);
edges.forEach((edge) => {
log.info('Edge', edge);
const data = graph.edge(edge.v, edge.w, edge.name);
log.info('Edge data', data, rootId);
try {
// Do not copy edges in and out of the root cluster, they belong to the parent graph
if (edgeInCluster(edge, rootId)) {
log.info('Copying as ', edge.v, edge.w, data, edge.name);
newGraph.setEdge(edge.v, edge.w, data, edge.name);
log.info('newGraph edges ', newGraph.edges(), newGraph.edge(newGraph.edges()[0]));
} else {
log.info(
'Skipping copy of edge ',
edge.v,
'-->',
edge.w,
' rootId: ',
rootId,
' clusterId:',
clusterId
);
}
} catch (e) {
log.error(e);
}
});
}
log.debug('Removing node', node);
graph.removeNode(node);
});
};
export const extractDecendants = (id, graph) => {
// log.debug('Extracting ', id);
const children = graph.children(id);
let res = [].concat(children);
for (let i = 0; i < children.length; i++) {
parents[children[i]] = id;
res = res.concat(extractDecendants(children[i], graph));
}
return res;
};
/**
* Validates the graph, checking that all parent child relation points to existing nodes and that
* edges between nodes also ia correct. When not correct the function logs the discrepancies.
*
* @param graph
*/
export const validate = (graph) => {
const edges = graph.edges();
log.trace('Edges: ', edges);
for (let i = 0; i < edges.length; i++) {
if (graph.children(edges[i].v).length > 0) {
log.trace('The node ', edges[i].v, ' is part of and edge even though it has children');
return false;
}
if (graph.children(edges[i].w).length > 0) {
log.trace('The node ', edges[i].w, ' is part of and edge even though it has children');
return false;
}
}
return true;
};
/**
* Finds a child that is not a cluster. When faking an edge between a node and a cluster.
*
* @param id
* @param {any} graph
*/
export const findNonClusterChild = (id, graph) => {
// const node = graph.node(id);
log.trace('Searching', id);
// const children = graph.children(id).reverse();
const children = graph.children(id); //.reverse();
log.trace('Searching children of id ', id, children);
if (children.length < 1) {
log.trace('This is a valid node', id);
return id;
}
for (let i = 0; i < children.length; i++) {
const _id = findNonClusterChild(children[i], graph);
if (_id) {
log.trace('Found replacement for', id, ' => ', _id);
return _id;
}
}
};
const getAnchorId = (id) => {
if (!clusterDb[id]) {
return id;
}
// If the cluster has no external connections
if (!clusterDb[id].externalConnections) {
return id;
}
// Return the replacement node
if (clusterDb[id]) {
return clusterDb[id].id;
}
return id;
};
export const adjustClustersAndEdges = (graph, depth) => {
if (!graph || depth > 10) {
log.debug('Opting out, no graph ');
return;
} else {
log.debug('Opting in, graph ');
}
// Go through the nodes and for each cluster found, save a replacement node, this can be used when
// faking a link to a cluster
graph.nodes().forEach(function (id) {
const children = graph.children(id);
if (children.length > 0) {
log.warn(
'Cluster identified',
id,
' Replacement id in edges: ',
findNonClusterChild(id, graph)
);
decendants[id] = extractDecendants(id, graph);
clusterDb[id] = { id: findNonClusterChild(id, graph), clusterData: graph.node(id) };
}
});
// Check incoming and outgoing edges for each cluster
graph.nodes().forEach(function (id) {
const children = graph.children(id);
const edges = graph.edges();
if (children.length > 0) {
log.debug('Cluster identified', id, decendants);
edges.forEach((edge) => {
// log.debug('Edge, decendants: ', edge, decendants[id]);
// Check if any edge leaves the cluster (not the actual cluster, that's a link from the box)
if (edge.v !== id && edge.w !== id) {
// Any edge where either the one of the nodes is decending to the cluster but not the other
// if (decendants[id].indexOf(edge.v) < 0 && decendants[id].indexOf(edge.w) < 0) {
const d1 = isDecendant(edge.v, id);
const d2 = isDecendant(edge.w, id);
// d1 xor d2 - if either d1 is true and d2 is false or the other way around
if (d1 ^ d2) {
log.warn('Edge: ', edge, ' leaves cluster ', id);
log.warn('Decendants of XXX ', id, ': ', decendants[id]);
clusterDb[id].externalConnections = true;
}
}
});
} else {
log.debug('Not a cluster ', id, decendants);
}
});
// For clusters with incoming and/or outgoing edges translate those edges to a real node
// in the cluster in order to fake the edge
graph.edges().forEach(function (e) {
const edge = graph.edge(e);
log.warn('Edge ' + e.v + ' -> ' + e.w + ': ' + JSON.stringify(e));
log.warn('Edge ' + e.v + ' -> ' + e.w + ': ' + JSON.stringify(graph.edge(e)));
let v = e.v;
let w = e.w;
// Check if link is either from or to a cluster
log.warn(
'Fix XXX',
clusterDb,
'ids:',
e.v,
e.w,
'Translateing: ',
clusterDb[e.v],
' --- ',
clusterDb[e.w]
);
if (clusterDb[e.v] && clusterDb[e.w] && clusterDb[e.v] === clusterDb[e.w]) {
log.warn('Fixing and trixing link to self - removing XXX', e.v, e.w, e.name);
log.warn('Fixing and trixing - removing XXX', e.v, e.w, e.name);
v = getAnchorId(e.v);
w = getAnchorId(e.w);
graph.removeEdge(e.v, e.w, e.name);
const specialId = e.w + '---' + e.v;
graph.setNode(specialId, {
domId: specialId,
id: specialId,
labelStyle: '',
labelText: edge.label,
padding: 0,
shape: 'labelRect',
style: '',
});
const edge1 = JSON.parse(JSON.stringify(edge));
const edge2 = JSON.parse(JSON.stringify(edge));
edge1.label = '';
edge1.arrowTypeEnd = 'none';
edge2.label = '';
edge1.fromCluster = e.v;
edge2.toCluster = e.v;
graph.setEdge(v, specialId, edge1, e.name + '-cyclic-special');
graph.setEdge(specialId, w, edge2, e.name + '-cyclic-special');
} else if (clusterDb[e.v] || clusterDb[e.w]) {
log.warn('Fixing and trixing - removing XXX', e.v, e.w, e.name);
v = getAnchorId(e.v);
w = getAnchorId(e.w);
graph.removeEdge(e.v, e.w, e.name);
if (v !== e.v) edge.fromCluster = e.v;
if (w !== e.w) edge.toCluster = e.w;
log.warn('Fix Replacing with XXX', v, w, e.name);
graph.setEdge(v, w, edge, e.name);
}
});
log.warn('Adjusted Graph', graphlib.json.write(graph));
extractor(graph, 0);
log.trace(clusterDb);
// Remove references to extracted cluster
// graph.edges().forEach(edge => {
// if (isDecendant(edge.v, clusterId) || isDecendant(edge.w, clusterId)) {
// graph.removeEdge(edge);
// }
// });
};
export const extractor = (graph, depth) => {
log.warn('extractor - ', depth, graphlib.json.write(graph), graph.children('D'));
if (depth > 10) {
log.error('Bailing out');
return;
}
// For clusters without incoming and/or outgoing edges, create a new cluster-node
// containing the nodes and edges in the custer in a new graph
// for (let i = 0;)
let nodes = graph.nodes();
let hasChildren = false;
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
const children = graph.children(node);
hasChildren = hasChildren || children.length > 0;
}
if (!hasChildren) {
log.debug('Done, no node has children', graph.nodes());
return;
}
// const clusters = Object.keys(clusterDb);
// clusters.forEach(clusterId => {
log.debug('Nodes = ', nodes, depth);
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
log.debug(
'Extracting node',
node,
clusterDb,
clusterDb[node] && !clusterDb[node].externalConnections,
!graph.parent(node),
graph.node(node),
graph.children('D'),
' Depth ',
depth
);
// Note that the node might have been removed after the Object.keys call so better check
// that it still is in the game
if (!clusterDb[node]) {
// Skip if the node is not a cluster
log.debug('Not a cluster', node, depth);
// break;
} else if (
!clusterDb[node].externalConnections &&
// !graph.parent(node) &&
graph.children(node) &&
graph.children(node).length > 0
) {
log.warn(
'Cluster without external connections, without a parent and with children',
node,
depth
);
const graphSettings = graph.graph();
let dir = graphSettings.rankdir === 'TB' ? 'LR' : 'TB';
if (clusterDb[node]) {
if (clusterDb[node].clusterData && clusterDb[node].clusterData.dir) {
dir = clusterDb[node].clusterData.dir;
log.warn('Fixing dir', clusterDb[node].clusterData.dir, dir);
}
}
const clusterGraph = new graphlib.Graph({
multigraph: true,
compound: true,
})
.setGraph({
rankdir: dir, // Todo: set proper spacing
nodesep: 50,
ranksep: 50,
marginx: 8,
marginy: 8,
})
.setDefaultEdgeLabel(function () {
return {};
});
log.warn('Old graph before copy', graphlib.json.write(graph));
copy(node, graph, clusterGraph, node);
graph.setNode(node, {
clusterNode: true,
id: node,
clusterData: clusterDb[node].clusterData,
labelText: clusterDb[node].labelText,
graph: clusterGraph,
});
log.warn('New graph after copy node: (', node, ')', graphlib.json.write(clusterGraph));
log.debug('Old graph after copy', graphlib.json.write(graph));
} else {
log.warn(
'Cluster ** ',
node,
' **not meeting the criteria !externalConnections:',
!clusterDb[node].externalConnections,
' no parent: ',
!graph.parent(node),
' children ',
graph.children(node) && graph.children(node).length > 0,
graph.children('D'),
depth
);
log.debug(clusterDb);
}
}
nodes = graph.nodes();
log.warn('New list of nodes', nodes);
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
const data = graph.node(node);
log.warn(' Now next level', node, data);
if (data.clusterNode) {
extractor(data.graph, depth + 1);
}
}
};
const sorter = (graph, nodes) => {
if (nodes.length === 0) return [];
let result = Object.assign(nodes);
nodes.forEach((node) => {
const children = graph.children(node);
const sorted = sorter(graph, children);
result = result.concat(sorted);
});
return result;
};
export const sortNodesByHierarchy = (graph) => sorter(graph, graph.children());

View File

@@ -0,0 +1,508 @@
import graphlib from 'graphlib';
import dagre from 'dagre';
import {
validate,
adjustClustersAndEdges,
extractDecendants,
sortNodesByHierarchy,
} from './mermaid-graphlib';
import { setLogLevel, log } from '../logger';
describe('Graphlib decorations', () => {
let g;
beforeEach(function () {
setLogLevel(1);
g = new graphlib.Graph({
multigraph: true,
compound: true,
});
g.setGraph({
rankdir: 'TB',
nodesep: 10,
ranksep: 10,
marginx: 8,
marginy: 8,
});
g.setDefaultEdgeLabel(function () {
return {};
});
});
describe('validate', function () {
it('Validate should detect edges between clusters', function () {
/*
subgraph C1
a --> b
end
subgraph C2
c
end
C1 --> C2
*/
g.setNode('a', { data: 1 });
g.setNode('b', { data: 2 });
g.setNode('c', { data: 3 });
g.setParent('a', 'C1');
g.setParent('b', 'C1');
g.setParent('c', 'C2');
g.setEdge('a', 'b');
g.setEdge('C1', 'C2');
expect(validate(g)).toBe(false);
});
it('Validate should not detect edges between clusters after adjustment', function () {
/*
subgraph C1
a --> b
end
subgraph C2
c
end
C1 --> C2
*/
g.setNode('a', {});
g.setNode('b', {});
g.setNode('c', {});
g.setParent('a', 'C1');
g.setParent('b', 'C1');
g.setParent('c', 'C2');
g.setEdge('a', 'b');
g.setEdge('C1', 'C2');
adjustClustersAndEdges(g);
log.info(g.edges());
expect(validate(g)).toBe(true);
});
it('Validate should detect edges between clusters and transform clusters GLB4', function () {
/*
a --> b
subgraph C1
subgraph C2
a
end
b
end
C1 --> c
*/
g.setNode('a', { data: 1 });
g.setNode('b', { data: 2 });
g.setNode('c', { data: 3 });
g.setNode('C1', { data: 4 });
g.setNode('C2', { data: 5 });
g.setParent('a', 'C2');
g.setParent('b', 'C1');
g.setParent('C2', 'C1');
g.setEdge('a', 'b', { name: 'C1-internal-link' });
g.setEdge('C1', 'c', { name: 'C1-external-link' });
adjustClustersAndEdges(g);
log.info(g.nodes());
expect(g.nodes().length).toBe(2);
expect(validate(g)).toBe(true);
});
it('Validate should detect edges between clusters and transform clusters GLB5', function () {
/*
a --> b
subgraph C1
a
end
subgraph C2
b
end
C1 -->
*/
g.setNode('a', { data: 1 });
g.setNode('b', { data: 2 });
g.setParent('a', 'C1');
g.setParent('b', 'C2');
// g.setEdge('a', 'b', { name: 'C1-internal-link' });
g.setEdge('C1', 'C2', { name: 'C1-external-link' });
log.info(g.nodes());
adjustClustersAndEdges(g);
log.info(g.nodes());
expect(g.nodes().length).toBe(2);
expect(validate(g)).toBe(true);
});
it('adjustClustersAndEdges GLB6', function () {
/*
subgraph C1
a
end
C1 --> b
*/
g.setNode('a', { data: 1 });
g.setNode('b', { data: 2 });
g.setNode('C1', { data: 3 });
g.setParent('a', 'C1');
g.setEdge('C1', 'b', { data: 'link1' }, '1');
// log.info(g.edges())
adjustClustersAndEdges(g);
log.info(g.edges());
expect(g.nodes()).toEqual(['b', 'C1']);
expect(g.edges().length).toBe(1);
expect(validate(g)).toBe(true);
expect(g.node('C1').clusterNode).toBe(true);
const C1Graph = g.node('C1').graph;
expect(C1Graph.nodes()).toEqual(['a']);
});
it('adjustClustersAndEdges GLB7', function () {
/*
subgraph C1
a
end
C1 --> b
C1 --> c
*/
g.setNode('a', { data: 1 });
g.setNode('b', { data: 2 });
g.setNode('c', { data: 3 });
g.setParent('a', 'C1');
g.setNode('C1', { data: 4 });
g.setEdge('C1', 'b', { data: 'link1' }, '1');
g.setEdge('C1', 'c', { data: 'link2' }, '2');
log.info(g.node('C1'));
adjustClustersAndEdges(g);
log.info(g.edges());
expect(g.nodes()).toEqual(['b', 'c', 'C1']);
expect(g.nodes().length).toBe(3);
expect(g.edges().length).toBe(2);
expect(g.edges().length).toBe(2);
const edgeData = g.edge(g.edges()[1]);
expect(edgeData.data).toBe('link2');
expect(validate(g)).toBe(true);
const C1Graph = g.node('C1').graph;
expect(C1Graph.nodes()).toEqual(['a']);
});
it('adjustClustersAndEdges GLB8', function () {
/*
subgraph A
a
end
subgraph B
b
end
subgraph C
c
end
A --> B
A --> C
*/
g.setNode('a', { data: 1 });
g.setNode('b', { data: 2 });
g.setNode('c', { data: 3 });
g.setParent('a', 'A');
g.setParent('b', 'B');
g.setParent('c', 'C');
g.setEdge('A', 'B', { data: 'link1' }, '1');
g.setEdge('A', 'C', { data: 'link2' }, '2');
// log.info(g.edges())
adjustClustersAndEdges(g);
expect(g.nodes()).toEqual(['A', 'B', 'C']);
expect(g.edges().length).toBe(2);
expect(g.edges().length).toBe(2);
const edgeData = g.edge(g.edges()[1]);
expect(edgeData.data).toBe('link2');
expect(validate(g)).toBe(true);
const CGraph = g.node('C').graph;
expect(CGraph.nodes()).toEqual(['c']);
});
it('adjustClustersAndEdges the extracted graphs shall contain the correct data GLB10', function () {
/*
subgraph C
subgraph D
d
end
end
*/
g.setNode('C', { data: 1 });
g.setNode('D', { data: 2 });
g.setNode('d', { data: 3 });
g.setParent('d', 'D');
g.setParent('D', 'C');
// log.info('Graph before', g.node('D'))
// log.info('Graph before', graphlib.json.write(g))
adjustClustersAndEdges(g);
// log.info('Graph after', graphlib.json.write(g), g.node('C').graph)
const CGraph = g.node('C').graph;
const DGraph = CGraph.node('D').graph;
expect(CGraph.nodes()).toEqual(['D']);
expect(DGraph.nodes()).toEqual(['d']);
expect(g.nodes()).toEqual(['C']);
expect(g.nodes().length).toBe(1);
});
it('adjustClustersAndEdges the extracted graphs shall contain the correct data GLB11', function () {
/*
subgraph A
a
end
subgraph B
b
end
subgraph C
subgraph D
d
end
end
A --> B
A --> C
*/
g.setNode('C', { data: 1 });
g.setNode('D', { data: 2 });
g.setNode('d', { data: 3 });
g.setNode('B', { data: 4 });
g.setNode('b', { data: 5 });
g.setNode('A', { data: 6 });
g.setNode('a', { data: 7 });
g.setParent('a', 'A');
g.setParent('b', 'B');
g.setParent('d', 'D');
g.setParent('D', 'C');
g.setEdge('A', 'B', { data: 'link1' }, '1');
g.setEdge('A', 'C', { data: 'link2' }, '2');
log.info('Graph before', g.node('D'));
log.info('Graph before', graphlib.json.write(g));
adjustClustersAndEdges(g);
log.trace('Graph after', graphlib.json.write(g));
expect(g.nodes()).toEqual(['C', 'B', 'A']);
expect(g.nodes().length).toBe(3);
expect(g.edges().length).toBe(2);
const AGraph = g.node('A').graph;
const BGraph = g.node('B').graph;
const CGraph = g.node('C').graph;
// log.info(CGraph.nodes());
const DGraph = CGraph.node('D').graph;
// log.info('DG', CGraph.children('D'));
log.info('A', AGraph.nodes());
expect(AGraph.nodes().length).toBe(1);
expect(AGraph.nodes()).toEqual(['a']);
log.trace('Nodes', BGraph.nodes());
expect(BGraph.nodes().length).toBe(1);
expect(BGraph.nodes()).toEqual(['b']);
expect(CGraph.nodes()).toEqual(['D']);
expect(CGraph.nodes().length).toEqual(1);
expect(AGraph.edges().length).toBe(0);
expect(BGraph.edges().length).toBe(0);
expect(CGraph.edges().length).toBe(0);
expect(DGraph.nodes()).toEqual(['d']);
expect(DGraph.edges().length).toBe(0);
// expect(CGraph.node('D')).toEqual({ data: 2 });
expect(g.edges().length).toBe(2);
// expect(g.edges().length).toBe(2);
// const edgeData = g.edge(g.edges()[1]);
// expect(edgeData.data).toBe('link2');
// expect(validate(g)).toBe(true);
});
it('adjustClustersAndEdges the extracted graphs shall contain the correct links GLB20', function () {
/*
a --> b
subgraph b [Test]
c --> d -->e
end
*/
g.setNode('a', { data: 1 });
g.setNode('b', { data: 2 });
g.setNode('c', { data: 3 });
g.setNode('d', { data: 3 });
g.setNode('e', { data: 3 });
g.setParent('c', 'b');
g.setParent('d', 'b');
g.setParent('e', 'b');
g.setEdge('a', 'b', { data: 'link1' }, '1');
g.setEdge('c', 'd', { data: 'link2' }, '2');
g.setEdge('d', 'e', { data: 'link2' }, '2');
log.info('Graph before', graphlib.json.write(g));
adjustClustersAndEdges(g);
const bGraph = g.node('b').graph;
// log.trace('Graph after', graphlib.json.write(g))
log.info('Graph after', graphlib.json.write(bGraph));
expect(bGraph.nodes().length).toBe(3);
expect(bGraph.edges().length).toBe(2);
});
it('adjustClustersAndEdges the extracted graphs shall contain the correct links GLB21', function () {
/*
state a {
state b {
state c {
e
}
}
}
*/
g.setNode('a', { data: 1 });
g.setNode('b', { data: 2 });
g.setNode('c', { data: 3 });
g.setNode('e', { data: 3 });
g.setParent('b', 'a');
g.setParent('c', 'b');
g.setParent('e', 'c');
log.info('Graph before', graphlib.json.write(g));
adjustClustersAndEdges(g);
const aGraph = g.node('a').graph;
const bGraph = aGraph.node('b').graph;
log.info('Graph after', graphlib.json.write(aGraph));
const cGraph = bGraph.node('c').graph;
// log.trace('Graph after', graphlib.json.write(g))
expect(aGraph.nodes().length).toBe(1);
expect(bGraph.nodes().length).toBe(1);
expect(cGraph.nodes().length).toBe(1);
expect(bGraph.edges().length).toBe(0);
});
});
it('adjustClustersAndEdges should handle nesting GLB77', function () {
/*
flowchart TB
subgraph A
b-->B
a-->c
end
subgraph B
c
end
*/
const exportedGraph = JSON.parse(
'{"options":{"directed":true,"multigraph":true,"compound":true},"nodes":[{"v":"A","value":{"labelStyle":"","shape":"rect","labelText":"A","rx":0,"ry":0,"class":"default","style":"","id":"A","width":500,"type":"group","padding":15}},{"v":"B","value":{"labelStyle":"","shape":"rect","labelText":"B","rx":0,"ry":0,"class":"default","style":"","id":"B","width":500,"type":"group","padding":15},"parent":"A"},{"v":"b","value":{"labelStyle":"","shape":"rect","labelText":"b","rx":0,"ry":0,"class":"default","style":"","id":"b","padding":15},"parent":"A"},{"v":"c","value":{"labelStyle":"","shape":"rect","labelText":"c","rx":0,"ry":0,"class":"default","style":"","id":"c","padding":15},"parent":"B"},{"v":"a","value":{"labelStyle":"","shape":"rect","labelText":"a","rx":0,"ry":0,"class":"default","style":"","id":"a","padding":15},"parent":"A"}],"edges":[{"v":"b","w":"B","name":"1","value":{"minlen":1,"arrowhead":"normal","arrowTypeStart":"arrow_open","arrowTypeEnd":"arrow_point","thickness":"normal","pattern":"solid","style":"fill:none","labelStyle":"","arrowheadStyle":"fill: #333","labelpos":"c","labelType":"text","label":"","id":"L-b-B","classes":"flowchart-link LS-b LE-B"}},{"v":"a","w":"c","name":"2","value":{"minlen":1,"arrowhead":"normal","arrowTypeStart":"arrow_open","arrowTypeEnd":"arrow_point","thickness":"normal","pattern":"solid","style":"fill:none","labelStyle":"","arrowheadStyle":"fill: #333","labelpos":"c","labelType":"text","label":"","id":"L-a-c","classes":"flowchart-link LS-a LE-c"}}],"value":{"rankdir":"TB","nodesep":50,"ranksep":50,"marginx":8,"marginy":8}}'
);
const gr = graphlib.json.read(exportedGraph);
log.info('Graph before', graphlib.json.write(gr));
adjustClustersAndEdges(gr);
const aGraph = gr.node('A').graph;
const bGraph = aGraph.node('B').graph;
log.info('Graph after', graphlib.json.write(aGraph));
// log.trace('Graph after', graphlib.json.write(g))
expect(aGraph.parent('c')).toBe('B');
expect(aGraph.parent('B')).toBe(undefined);
});
});
describe('extractDecendants', function () {
let g;
beforeEach(function () {
setLogLevel(1);
g = new graphlib.Graph({
multigraph: true,
compound: true,
});
g.setGraph({
rankdir: 'TB',
nodesep: 10,
ranksep: 10,
marginx: 8,
marginy: 8,
});
g.setDefaultEdgeLabel(function () {
return {};
});
});
it('Simple case of one level decendants GLB9', function () {
/*
subgraph A
a
end
subgraph B
b
end
subgraph C
c
end
A --> B
A --> C
*/
g.setNode('a', { data: 1 });
g.setNode('b', { data: 2 });
g.setNode('c', { data: 3 });
g.setParent('a', 'A');
g.setParent('b', 'B');
g.setParent('c', 'C');
g.setEdge('A', 'B', { data: 'link1' }, '1');
g.setEdge('A', 'C', { data: 'link2' }, '2');
// log.info(g.edges())
const d1 = extractDecendants('A', g);
const d2 = extractDecendants('B', g);
const d3 = extractDecendants('C', g);
expect(d1).toEqual(['a']);
expect(d2).toEqual(['b']);
expect(d3).toEqual(['c']);
});
});
describe('sortNodesByHierarchy', function () {
let g;
beforeEach(function () {
setLogLevel(1);
g = new graphlib.Graph({
multigraph: true,
compound: true,
});
g.setGraph({
rankdir: 'TB',
nodesep: 10,
ranksep: 10,
marginx: 8,
marginy: 8,
});
g.setDefaultEdgeLabel(function () {
return {};
});
});
it('should sort proper en nodes are in reverse order', function () {
/*
a -->b
subgraph B
b
end
subgraph A
B
end
*/
g.setNode('a', { data: 1 });
g.setNode('b', { data: 2 });
g.setParent('b', 'B');
g.setParent('B', 'A');
g.setEdge('a', 'b', '1');
expect(sortNodesByHierarchy(g)).toEqual(['a', 'A', 'B', 'b']);
});
it('should sort proper en nodes are in correct order', function () {
/*
a -->b
subgraph B
b
end
subgraph A
B
end
*/
g.setNode('a', { data: 1 });
g.setParent('B', 'A');
g.setParent('b', 'B');
g.setNode('b', { data: 2 });
g.setEdge('a', 'b', '1');
expect(sortNodesByHierarchy(g)).toEqual(['a', 'A', 'B', 'b']);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,52 @@
/** Setup arrow head and define the marker. The result is appended to the svg. */
// import { log } from '../logger';
// Only add the number of markers that the diagram needs
const insertPatterns = (elem, patternArray, type, id) => {
patternArray.forEach((patternName) => {
patterns[patternName](elem, type, id);
});
};
{
/* <svg height="10" width="10" xmlns="http://www.w3.org/2000/svg" version="1.1">
{' '}
<defs>
{' '}
<pattern id="circles-1" patternUnits="userSpaceOnUse" width="10" height="10">
{' '}
<image
xlink:href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxMCcgaGVpZ2h0PScxMCc+CiAgPHJlY3Qgd2lkdGg9JzEwJyBoZWlnaHQ9JzEwJyBmaWxsPSJ3aGl0ZSIgLz4KICA8Y2lyY2xlIGN4PSIxIiBjeT0iMSIgcj0iMSIgZmlsbD0iYmxhY2siLz4KPC9zdmc+"
x="0"
y="0"
width="10"
height="10"
>
{' '}
</image>{' '}
</pattern>{' '}
</defs>{' '}
</svg>; */
}
const dots = (elem, type) => {
elem
.append('defs')
.append('marker')
.attr('id', type + '-barbEnd')
.attr('refX', 19)
.attr('refY', 7)
.attr('markerWidth', 20)
.attr('markerHeight', 14)
.attr('markerUnits', 0)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M 19,7 L9,13 L14,7 L9,1 Z');
};
// TODO rename the class diagram markers to something shape descriptive and semantic free
const patterns = {
dots,
};
export default insertPatterns;

View File

@@ -0,0 +1,29 @@
import { updateNodeBounds, labelHelper } from './util';
import { log } from '../../logger';
import intersect from '../intersect/index.js';
const note = (parent, node) => {
const { shapeSvg, bbox, halfPadding } = labelHelper(parent, node, 'node ' + node.classes, true);
log.info('Classes = ', node.classes);
// add the rect
const rect = shapeSvg.insert('rect', ':first-child');
rect
.attr('rx', node.rx)
.attr('ry', node.ry)
.attr('x', -bbox.width / 2 - halfPadding)
.attr('y', -bbox.height / 2 - halfPadding)
.attr('width', bbox.width + node.padding)
.attr('height', bbox.height + node.padding);
updateNodeBounds(node, rect);
node.intersect = function (point) {
return intersect.rect(node, point);
};
return shapeSvg;
};
export default note;

View File

@@ -0,0 +1,79 @@
import createLabel from '../createLabel';
import { getConfig } from '../../config';
import { decodeEntities } from '../../mermaidAPI';
import { select } from 'd3';
import { evaluate, sanitizeText } from '../../diagrams/common/common';
export const labelHelper = (parent, node, _classes, isNode) => {
let classes;
if (!_classes) {
classes = 'node default';
} else {
classes = _classes;
}
// Add outer g element
const shapeSvg = parent
.insert('g')
.attr('class', classes)
.attr('id', node.domId || node.id);
// Create the label and insert it after the rect
const label = shapeSvg.insert('g').attr('class', 'label').attr('style', node.labelStyle);
const labelText = typeof node.labelText === 'string' ? node.labelText : node.labelText[0];
const text = label
.node()
.appendChild(
createLabel(
sanitizeText(decodeEntities(labelText), getConfig()),
node.labelStyle,
false,
isNode
)
);
// Get the size of the label
let bbox = text.getBBox();
if (evaluate(getConfig().flowchart.htmlLabels)) {
const div = text.children[0];
const dv = select(text);
bbox = div.getBoundingClientRect();
dv.attr('width', bbox.width);
dv.attr('height', bbox.height);
}
const halfPadding = node.padding / 2;
// Center the label
label.attr('transform', 'translate(' + -bbox.width / 2 + ', ' + -bbox.height / 2 + ')');
return { shapeSvg, bbox, halfPadding, label };
};
export const updateNodeBounds = (node, element) => {
const bbox = element.node().getBBox();
node.width = bbox.width;
node.height = bbox.height;
};
/**
* @param parent
* @param w
* @param h
* @param points
*/
export function insertPolygonShape(parent, w, h, points) {
return parent
.insert('polygon', ':first-child')
.attr(
'points',
points
.map(function (d) {
return d.x + ',' + d.y;
})
.join(' ')
)
.attr('class', 'label-container')
.attr('transform', 'translate(' + -w / 2 + ',' + h / 2 + ')');
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,49 @@
import { MermaidConfig } from '../config.type';
export type DiagramDetector = (text: string, config?: MermaidConfig) => boolean;
const directive =
/[%]{2}[{]\s*(?:(?:(\w+)\s*:|(\w+))\s*(?:(?:(\w+))|((?:(?![}][%]{2}).|\r?\n)*))?\s*)(?:[}][%]{2})?/gi;
const anyComment = /\s*%%.*\n/gm;
const detectors: Record<string, DiagramDetector> = {};
/**
* @function detectType Detects the type of the graph text. Takes into consideration the possible
* existence of an %%init directive
*
* ```mermaid
* %%{initialize: {"startOnLoad": true, logLevel: "fatal" }}%%
* graph LR
* a-->b
* b-->c
* c-->d
* d-->e
* e-->f
* f-->g
* g-->h
* ```
* @param {string} text The text defining the graph
* @param {{
* class: { defaultRenderer: string } | undefined;
* state: { defaultRenderer: string } | undefined;
* flowchart: { defaultRenderer: string } | undefined;
* }} [config]
* @returns {string} A graph definition key
*/
export const detectType = function (text: string, config?: MermaidConfig): string {
text = text.replace(directive, '').replace(anyComment, '\n');
for (const [key, detector] of Object.entries(detectors)) {
if (detector(text, config)) {
return key;
}
}
// TODO: #3391
// throw new Error(`No diagram type detected for text: ${text}`);
return 'flowchart';
};
export const addDetector = (key: string, detector: DiagramDetector) => {
detectors[key] = detector;
};

View File

@@ -0,0 +1,348 @@
import { registerDiagram } from './diagramAPI';
// // @ts-ignore: TODO Fix ts errors
// import mindmapParser from '../diagrams/mindmap/parser/mindmap';
// import * as mindmapDb from '../diagrams/mindmap/mindmapDb';
// import { mindmapDetector } from '../diagrams/mindmap/mindmapDetector';
// import mindmapRenderer from '../diagrams/mindmap/mindmapRenderer';
// import mindmapStyles from '../diagrams/mindmap/styles';
// @ts-ignore: TODO Fix ts errors
import gitGraphParser from '../diagrams/git/parser/gitGraph';
import { gitGraphDetector } from '../diagrams/git/gitGraphDetector';
import gitGraphDb from '../diagrams/git/gitGraphAst';
import gitGraphRenderer from '../diagrams/git/gitGraphRenderer';
import gitGraphStyles from '../diagrams/git/styles';
// @ts-ignore: TODO Fix ts errors
import c4Parser from '../diagrams/c4/parser/c4Diagram';
import { c4Detector } from '../diagrams/c4/c4Detector';
import c4Db from '../diagrams/c4/c4Db';
import c4Renderer from '../diagrams/c4/c4Renderer';
import c4Styles from '../diagrams/c4/styles';
// @ts-ignore: TODO Fix ts errors
import classParser from '../diagrams/class/parser/classDiagram';
import { classDetector } from '../diagrams/class/classDetector';
import { classDetectorV2 } from '../diagrams/class/classDetector-V2';
import classDb from '../diagrams/class/classDb';
import classRenderer from '../diagrams/class/classRenderer';
import classRendererV2 from '../diagrams/class/classRenderer-v2';
import classStyles from '../diagrams/class/styles';
// @ts-ignore: TODO Fix ts errors
import erParser from '../diagrams/er/parser/erDiagram';
import { erDetector } from '../diagrams/er/erDetector';
import erDb from '../diagrams/er/erDb';
import erRenderer from '../diagrams/er/erRenderer';
import erStyles from '../diagrams/er/styles';
// @ts-ignore: TODO Fix ts errors
import flowParser from '../diagrams/flowchart/parser/flow';
import { flowDetector } from '../diagrams/flowchart/flowDetector';
import { flowDetectorV2 } from '../diagrams/flowchart/flowDetector-v2';
import flowDb from '../diagrams/flowchart/flowDb';
import flowRenderer from '../diagrams/flowchart/flowRenderer';
import flowRendererV2 from '../diagrams/flowchart/flowRenderer-v2';
import flowStyles from '../diagrams/flowchart/styles';
// @ts-ignore: TODO Fix ts errors
import ganttParser from '../diagrams/gantt/parser/gantt';
import { ganttDetector } from '../diagrams/gantt/ganttDetector';
import ganttDb from '../diagrams/gantt/ganttDb';
import ganttRenderer from '../diagrams/gantt/ganttRenderer';
import ganttStyles from '../diagrams/gantt/styles';
// @ts-ignore: TODO Fix ts errors
import infoParser from '../diagrams/info/parser/info';
import infoDb from '../diagrams/info/infoDb';
import infoRenderer from '../diagrams/info/infoRenderer';
import { infoDetector } from '../diagrams/info/infoDetector';
import infoStyles from '../diagrams/info/styles';
// @ts-ignore: TODO Fix ts errors
import pieParser from '../diagrams/pie/parser/pie';
import { pieDetector } from '../diagrams/pie/pieDetector';
import pieDb from '../diagrams/pie/pieDb';
import pieRenderer from '../diagrams/pie/pieRenderer';
import pieStyles from '../diagrams/pie/styles';
// @ts-ignore: TODO Fix ts errors
import requirementParser from '../diagrams/requirement/parser/requirementDiagram';
import { requirementDetector } from '../diagrams/requirement/requirementDetector';
import requirementDb from '../diagrams/requirement/requirementDb';
import requirementRenderer from '../diagrams/requirement/requirementRenderer';
import requirementStyles from '../diagrams/requirement/styles';
// @ts-ignore: TODO Fix ts errors
import sequenceParser from '../diagrams/sequence/parser/sequenceDiagram';
import { sequenceDetector } from '../diagrams/sequence/sequenceDetector';
import sequenceDb from '../diagrams/sequence/sequenceDb';
import sequenceRenderer from '../diagrams/sequence/sequenceRenderer';
import sequenceStyles from '../diagrams/sequence/styles';
// @ts-ignore: TODO Fix ts errors
import stateParser from '../diagrams/state/parser/stateDiagram';
import { stateDetector } from '../diagrams/state/stateDetector';
import { stateDetectorV2 } from '../diagrams/state/stateDetector-V2';
import stateDb from '../diagrams/state/stateDb';
import stateRenderer from '../diagrams/state/stateRenderer';
import stateRendererV2 from '../diagrams/state/stateRenderer-v2';
import stateStyles from '../diagrams/state/styles';
// @ts-ignore: TODO Fix ts errors
import journeyParser from '../diagrams/user-journey/parser/journey';
import { journeyDetector } from '../diagrams/user-journey/journeyDetector';
import journeyDb from '../diagrams/user-journey/journeyDb';
import journeyRenderer from '../diagrams/user-journey/journeyRenderer';
import journeyStyles from '../diagrams/user-journey/styles';
import { setConfig } from '../config';
import errorRenderer from '../diagrams/error/errorRenderer';
import errorStyles from '../diagrams/error/styles';
export const addDiagrams = () => {
registerDiagram(
'error',
// Special diagram with error messages but setup as a regular diagram
{
db: {
clear: () => {
// Quite ok, clear needs to be there for error to work as a regular diagram
},
},
styles: errorStyles,
renderer: errorRenderer,
parser: {
parser: { yy: {} },
parse: () => {
// no op
},
},
init: () => {
// no op
},
},
(text) => text.toLowerCase().trim() === 'error'
);
registerDiagram(
'c4',
{
parser: c4Parser,
db: c4Db,
renderer: c4Renderer,
styles: c4Styles,
init: (cnf) => {
c4Renderer.setConf(cnf.c4);
},
},
c4Detector
);
registerDiagram(
'class',
{
parser: classParser,
db: classDb,
renderer: classRenderer,
styles: classStyles,
init: (cnf) => {
if (!cnf.class) {
cnf.class = {};
}
cnf.class.arrowMarkerAbsolute = cnf.arrowMarkerAbsolute;
classDb.clear();
},
},
classDetector
);
registerDiagram(
'classDiagram',
{
parser: classParser,
db: classDb,
renderer: classRendererV2,
styles: classStyles,
init: (cnf) => {
if (!cnf.class) {
cnf.class = {};
}
cnf.class.arrowMarkerAbsolute = cnf.arrowMarkerAbsolute;
classDb.clear();
},
},
classDetectorV2
);
registerDiagram(
'er',
{
parser: erParser,
db: erDb,
renderer: erRenderer,
styles: erStyles,
},
erDetector
);
registerDiagram(
'gantt',
{
parser: ganttParser,
db: ganttDb,
renderer: ganttRenderer,
styles: ganttStyles,
},
ganttDetector
);
registerDiagram(
'info',
{
parser: infoParser,
db: infoDb,
renderer: infoRenderer,
styles: infoStyles,
},
infoDetector
);
registerDiagram(
'pie',
{
parser: pieParser,
db: pieDb,
renderer: pieRenderer,
styles: pieStyles,
},
pieDetector
);
registerDiagram(
'requirement',
{
parser: requirementParser,
db: requirementDb,
renderer: requirementRenderer,
styles: requirementStyles,
},
requirementDetector
);
registerDiagram(
'sequence',
{
parser: sequenceParser,
db: sequenceDb,
renderer: sequenceRenderer,
styles: sequenceStyles,
init: (cnf) => {
if (!cnf.sequence) {
cnf.sequence = {};
}
cnf.sequence.arrowMarkerAbsolute = cnf.arrowMarkerAbsolute;
if ('sequenceDiagram' in cnf) {
throw new Error(
'`mermaid config.sequenceDiagram` has been renamed to `config.sequence`. Please update your mermaid config.'
);
}
sequenceDb.setWrap(cnf.wrap);
sequenceRenderer.setConf(cnf.sequence);
},
},
sequenceDetector
);
registerDiagram(
'state',
{
parser: stateParser,
db: stateDb,
renderer: stateRenderer,
styles: stateStyles,
init: (cnf) => {
if (!cnf.state) {
cnf.state = {};
}
cnf.state.arrowMarkerAbsolute = cnf.arrowMarkerAbsolute;
stateDb.clear();
},
},
stateDetector
);
registerDiagram(
'stateDiagram',
{
parser: stateParser,
db: stateDb,
renderer: stateRendererV2,
styles: stateStyles,
init: (cnf) => {
if (!cnf.state) {
cnf.state = {};
}
cnf.state.arrowMarkerAbsolute = cnf.arrowMarkerAbsolute;
stateDb.clear();
},
},
stateDetectorV2
);
registerDiagram(
'journey',
{
parser: journeyParser,
db: journeyDb,
renderer: journeyRenderer,
styles: journeyStyles,
init: (cnf) => {
journeyRenderer.setConf(cnf.journey);
journeyDb.clear();
},
},
journeyDetector
);
registerDiagram(
'flowchart',
{
parser: flowParser,
db: flowDb,
renderer: flowRendererV2,
styles: flowStyles,
init: (cnf) => {
if (!cnf.flowchart) {
cnf.flowchart = {};
}
// TODO, broken as of 2022-09-14 (13809b50251845475e6dca65cc395761be38fbd2)
cnf.flowchart.arrowMarkerAbsolute = cnf.arrowMarkerAbsolute;
flowRenderer.setConf(cnf.flowchart);
flowDb.clear();
flowDb.setGen('gen-1');
},
},
flowDetector
);
registerDiagram(
'flowchart-v2',
{
parser: flowParser,
db: flowDb,
renderer: flowRendererV2,
styles: flowStyles,
init: (cnf) => {
if (!cnf.flowchart) {
cnf.flowchart = {};
}
cnf.flowchart.arrowMarkerAbsolute = cnf.arrowMarkerAbsolute;
// flowchart-v2 uses dagre-wrapper, which doesn't have access to flowchart cnf
setConfig({ flowchart: { arrowMarkerAbsolute: cnf.arrowMarkerAbsolute } });
flowRendererV2.setConf(cnf.flowchart);
flowDb.clear();
flowDb.setGen('gen-2');
},
},
flowDetectorV2
);
registerDiagram(
'gitGraph',
{ parser: gitGraphParser, db: gitGraphDb, renderer: gitGraphRenderer, styles: gitGraphStyles },
gitGraphDetector
);
// registerDiagram(
// 'mindmap',
// { parser: mindmapParser, db: mindmapDb, renderer: mindmapRenderer, styles: mindmapStyles },
// mindmapDetector
// );
};

View File

@@ -0,0 +1,32 @@
import { detectType } from './detectType';
import { getDiagram, registerDiagram } from './diagramAPI';
import { addDiagrams } from './diagram-orchestration';
addDiagrams();
describe('DiagramAPI', () => {
it('should return default diagrams', () => {
expect(getDiagram('sequence')).not.toBeNull();
});
it('should throw error if diagram is not defined', () => {
expect(() => getDiagram('loki')).toThrow();
});
it('should handle diagram registrations', () => {
expect(() => getDiagram('loki')).toThrow();
expect(() => detectType('loki diagram')).not.toThrow(); // TODO: #3391
registerDiagram(
'loki',
{
db: {},
parser: {},
renderer: {},
styles: {},
},
(text: string) => text.includes('loki')
);
expect(getDiagram('loki')).not.toBeNull();
expect(detectType('loki diagram')).toBe('loki');
});
});

View File

@@ -0,0 +1,49 @@
import { addDetector, DiagramDetector as _DiagramDetector } from './detectType';
import { log as _log, setLogLevel as _setLogLevel } from '../logger';
import { getConfig as _getConfig } from '../config';
import { sanitizeText as _sanitizeText } from '../diagrams/common/common';
import { MermaidConfig } from '../config.type';
import { setupGraphViewbox as _setupGraphViewbox } from '../setupGraphViewbox';
import { addStylesForDiagram } from '../styles';
/*
Packaging and exposing resources for externa diagrams so that they can import
diagramAPI and have access to selct parts of mermaid common code reqiored to
create diagrams worling like the internal diagrams.
*/
export const log = _log;
export const setLogLevel = _setLogLevel;
export type DiagramDetector = _DiagramDetector;
export const getConfig = _getConfig;
export const sanitizeText = (text: string) => _sanitizeText(text, getConfig());
export const setupGraphViewbox = _setupGraphViewbox;
export interface DiagramDefinition {
db: any;
renderer: any;
parser: any;
styles: any;
init?: (config: MermaidConfig) => void;
}
const diagrams: Record<string, DiagramDefinition> = {};
export const registerDiagram = (
id: string,
diagram: DiagramDefinition,
detector: DiagramDetector
) => {
if (diagrams[id]) {
log.warn(`Diagram ${id} already registered.`);
}
diagrams[id] = diagram;
addDetector(id, detector);
addStylesForDiagram(id, diagram.styles);
};
export const getDiagram = (name: string): DiagramDefinition => {
if (name in diagrams) {
return diagrams[name];
}
throw new Error(`Diagram ${name} not found.`);
};

View File

@@ -0,0 +1,227 @@
export const lineBreakRegex = /<br\s*\/?>/gi;
/**
* Caches results of functions based on input
*
* @param {Function} fn Function to run
* @param {Function} resolver Function that resolves to an ID given arguments the `fn` takes
* @returns {Function} An optimized caching function
*/
const memoize = (fn, resolver) => {
let cache = {};
return (...args) => {
let n = resolver ? resolver.apply(this, args) : args[0];
if (n in cache) {
return cache[n];
} else {
let result = fn(...args);
cache[n] = result;
return result;
}
};
};
/**
* This calculates the width of the given text, font size and family.
*
* @param {any} text - The text to calculate the width of
* @param {any} config - The config for fontSize, fontFamily, and fontWeight all impacting the resulting size
* @returns {any} - The width for the given text
*/
export const calculateTextWidth = function (text, config) {
config = Object.assign({ fontSize: 12, fontWeight: 400, fontFamily: 'Arial' }, config);
return calculateTextDimensions(text, config).width;
};
export const getTextObj = function () {
return {
x: 0,
y: 0,
fill: undefined,
anchor: 'start',
style: '#666',
width: 100,
height: 100,
textMargin: 0,
rx: 0,
ry: 0,
valign: undefined,
};
};
/**
* Adds text to an element
*
* @param {SVGElement} elem Element to add text to
* @param {{
* text: string;
* x: number;
* y: number;
* anchor: 'start' | 'middle' | 'end';
* fontFamily: string;
* fontSize: string | number;
* fontWeight: string | number;
* fill: string;
* class: string | undefined;
* textMargin: number;
* }} textData
* @returns {SVGTextElement} Text element with given styling and content
*/
export const drawSimpleText = function (elem, textData) {
// Remove and ignore br:s
const nText = textData.text.replace(lineBreakRegex, ' ');
const textElem = elem.append('text');
textElem.attr('x', textData.x);
textElem.attr('y', textData.y);
textElem.style('text-anchor', textData.anchor);
textElem.style('font-family', textData.fontFamily);
textElem.style('font-size', textData.fontSize);
textElem.style('font-weight', textData.fontWeight);
textElem.attr('fill', textData.fill);
if (typeof textData.class !== 'undefined') {
textElem.attr('class', textData.class);
}
const span = textElem.append('tspan');
span.attr('x', textData.x + textData.textMargin * 2);
span.attr('fill', textData.fill);
span.text(nText);
return textElem;
};
/**
* This calculates the dimensions of the given text, font size, font family, font weight, and margins.
*
* @param {any} text - The text to calculate the width of
* @param {any} config - The config for fontSize, fontFamily, fontWeight, and margin all impacting
* the resulting size
* @returns - The width for the given text
*/
export const calculateTextDimensions = memoize(
function (text, config) {
config = Object.assign({ fontSize: 12, fontWeight: 400, fontFamily: 'Arial' }, config);
const { fontSize, fontFamily, fontWeight } = config;
if (!text) {
return { width: 0, height: 0 };
}
// We can't really know if the user supplied font family will render on the user agent;
// thus, we'll take the max width between the user supplied font family, and a default
// of sans-serif.
const fontFamilies = ['sans-serif', fontFamily];
const lines = text.split(common.lineBreakRegex);
let dims = [];
const body = select('body');
// We don't want to leak DOM elements - if a removal operation isn't available
// for any reason, do not continue.
if (!body.remove) {
return { width: 0, height: 0, lineHeight: 0 };
}
const g = body.append('svg');
for (let fontFamily of fontFamilies) {
let cheight = 0;
let dim = { width: 0, height: 0, lineHeight: 0 };
for (let line of lines) {
const textObj = getTextObj();
textObj.text = line;
const textElem = drawSimpleText(g, textObj)
.style('font-size', fontSize)
.style('font-weight', fontWeight)
.style('font-family', fontFamily);
let bBox = (textElem._groups || textElem)[0][0].getBBox();
dim.width = Math.round(Math.max(dim.width, bBox.width));
cheight = Math.round(bBox.height);
dim.height += cheight;
dim.lineHeight = Math.round(Math.max(dim.lineHeight, cheight));
}
dims.push(dim);
}
g.remove();
let index =
isNaN(dims[1].height) ||
isNaN(dims[1].width) ||
isNaN(dims[1].lineHeight) ||
(dims[0].height > dims[1].height &&
dims[0].width > dims[1].width &&
dims[0].lineHeight > dims[1].lineHeight)
? 0
: 1;
return dims[index];
},
(text, config) => `${text}-${config.fontSize}-${config.fontWeight}-${config.fontFamily}`
);
const breakString = memoize(
(word, maxWidth, hyphenCharacter = '-', config) => {
config = Object.assign(
{ fontSize: 12, fontWeight: 400, fontFamily: 'Arial', margin: 0 },
config
);
const characters = word.split('');
const lines = [];
let currentLine = '';
characters.forEach((character, index) => {
const nextLine = `${currentLine}${character}`;
const lineWidth = calculateTextWidth(nextLine, config);
if (lineWidth >= maxWidth) {
const currentCharacter = index + 1;
const isLastLine = characters.length === currentCharacter;
const hyphenatedNextLine = `${nextLine}${hyphenCharacter}`;
lines.push(isLastLine ? nextLine : hyphenatedNextLine);
currentLine = '';
} else {
currentLine = nextLine;
}
});
return { hyphenatedStrings: lines, remainingWord: currentLine };
},
(word, maxWidth, hyphenCharacter = '-', config) =>
`${word}-${maxWidth}-${hyphenCharacter}-${config.fontSize}-${config.fontWeight}-${config.fontFamily}`
);
export const wrapLabel = memoize(
(label, maxWidth, config) => {
if (!label) {
return label;
}
config = Object.assign(
{ fontSize: 12, fontWeight: 400, fontFamily: 'Arial', joinWith: '<br/>' },
config
);
if (lineBreakRegex.test(label)) {
return label;
}
const words = label.split(' ');
const completedLines = [];
let nextLine = '';
words.forEach((word, index) => {
const wordLength = calculateTextWidth(`${word} `, config);
const nextLineLength = calculateTextWidth(nextLine, config);
if (wordLength > maxWidth) {
const { hyphenatedStrings, remainingWord } = breakString(word, maxWidth, '-', config);
completedLines.push(nextLine, ...hyphenatedStrings);
nextLine = remainingWord;
} else if (nextLineLength + wordLength >= maxWidth) {
completedLines.push(nextLine);
nextLine = word;
} else {
nextLine = [nextLine, word].filter(Boolean).join(' ');
}
const currentWord = index + 1;
const isLastWord = currentWord === words.length;
if (isLastWord) {
completedLines.push(nextLine);
}
});
return completedLines.filter((line) => line !== '').join(config.joinWith);
},
(label, maxWidth, config) =>
`${label}-${maxWidth}-${config.fontSize}-${config.fontWeight}-${config.fontFamily}-${config.joinWith}`
);

View File

@@ -0,0 +1,806 @@
import mermaidAPI from '../../mermaidAPI';
import * as configApi from '../../config';
import { sanitizeText } from '../common/common';
import { setAccTitle, getAccTitle, getAccDescription, setAccDescription } from '../../commonDb';
let c4ShapeArray = [];
let boundaryParseStack = [''];
let currentBoundaryParse = 'global';
let parentBoundaryParse = '';
let boundarys = [
{
alias: 'global',
label: { text: 'global' },
type: { text: 'global' },
tags: null,
link: null,
parentBoundary: '',
},
];
let rels = [];
let title = '';
let wrapEnabled = false;
let c4ShapeInRow = 4;
let c4BoundaryInRow = 2;
var c4Type;
export const getC4Type = function () {
return c4Type;
};
export const setC4Type = function (c4TypeParam) {
let sanitizedText = sanitizeText(c4TypeParam, configApi.getConfig());
c4Type = sanitizedText;
};
export const parseDirective = function (statement, context, type) {
mermaidAPI.parseDirective(this, statement, context, type);
};
//type, from, to, label, ?techn, ?descr, ?sprite, ?tags, $link
export const addRel = function (type, from, to, label, techn, descr, sprite, tags, link) {
// Don't allow label nulling
if (
type === undefined ||
type === null ||
from === undefined ||
from === null ||
to === undefined ||
to === null ||
label === undefined ||
label === null
)
return;
let rel = {};
const old = rels.find((rel) => rel.from === from && rel.to === to);
if (old) {
rel = old;
} else {
rels.push(rel);
}
rel.type = type;
rel.from = from;
rel.to = to;
rel.label = { text: label };
if (techn === undefined || techn === null) {
rel.techn = { text: '' };
} else {
if (typeof techn === 'object') {
let [key, value] = Object.entries(techn)[0];
rel[key] = { text: value };
} else {
rel.techn = { text: techn };
}
}
if (descr === undefined || descr === null) {
rel.descr = { text: '' };
} else {
if (typeof descr === 'object') {
let [key, value] = Object.entries(descr)[0];
rel[key] = { text: value };
} else {
rel.descr = { text: descr };
}
}
if (typeof sprite === 'object') {
let [key, value] = Object.entries(sprite)[0];
rel[key] = value;
} else {
rel.sprite = sprite;
}
if (typeof tags === 'object') {
let [key, value] = Object.entries(tags)[0];
rel[key] = value;
} else {
rel.tags = tags;
}
if (typeof link === 'object') {
let [key, value] = Object.entries(link)[0];
rel[key] = value;
} else {
rel.link = link;
}
rel.wrap = autoWrap();
};
//type, alias, label, ?descr, ?sprite, ?tags, $link
export const addPersonOrSystem = function (typeC4Shape, alias, label, descr, sprite, tags, link) {
// Don't allow label nulling
if (alias === null || label === null) return;
let personOrSystem = {};
const old = c4ShapeArray.find((personOrSystem) => personOrSystem.alias === alias);
if (old && alias === old.alias) {
personOrSystem = old;
} else {
personOrSystem.alias = alias;
c4ShapeArray.push(personOrSystem);
}
// Don't allow null labels, either
if (label === undefined || label === null) {
personOrSystem.label = { text: '' };
} else {
personOrSystem.label = { text: label };
}
if (descr === undefined || descr === null) {
personOrSystem.descr = { text: '' };
} else {
if (typeof descr === 'object') {
let [key, value] = Object.entries(descr)[0];
personOrSystem[key] = { text: value };
} else {
personOrSystem.descr = { text: descr };
}
}
if (typeof sprite === 'object') {
let [key, value] = Object.entries(sprite)[0];
personOrSystem[key] = value;
} else {
personOrSystem.sprite = sprite;
}
if (typeof tags === 'object') {
let [key, value] = Object.entries(tags)[0];
personOrSystem[key] = value;
} else {
personOrSystem.tags = tags;
}
if (typeof link === 'object') {
let [key, value] = Object.entries(link)[0];
personOrSystem[key] = value;
} else {
personOrSystem.link = link;
}
personOrSystem.typeC4Shape = { text: typeC4Shape };
personOrSystem.parentBoundary = currentBoundaryParse;
personOrSystem.wrap = autoWrap();
};
//type, alias, label, ?techn, ?descr ?sprite, ?tags, $link
export const addContainer = function (typeC4Shape, alias, label, techn, descr, sprite, tags, link) {
// Don't allow label nulling
if (alias === null || label === null) return;
let container = {};
const old = c4ShapeArray.find((container) => container.alias === alias);
if (old && alias === old.alias) {
container = old;
} else {
container.alias = alias;
c4ShapeArray.push(container);
}
// Don't allow null labels, either
if (label === undefined || label === null) {
container.label = { text: '' };
} else {
container.label = { text: label };
}
if (techn === undefined || techn === null) {
container.techn = { text: '' };
} else {
if (typeof techn === 'object') {
let [key, value] = Object.entries(techn)[0];
container[key] = { text: value };
} else {
container.techn = { text: techn };
}
}
if (descr === undefined || descr === null) {
container.descr = { text: '' };
} else {
if (typeof descr === 'object') {
let [key, value] = Object.entries(descr)[0];
container[key] = { text: value };
} else {
container.descr = { text: descr };
}
}
if (typeof sprite === 'object') {
let [key, value] = Object.entries(sprite)[0];
container[key] = value;
} else {
container.sprite = sprite;
}
if (typeof tags === 'object') {
let [key, value] = Object.entries(tags)[0];
container[key] = value;
} else {
container.tags = tags;
}
if (typeof link === 'object') {
let [key, value] = Object.entries(link)[0];
container[key] = value;
} else {
container.link = link;
}
container.wrap = autoWrap();
container.typeC4Shape = { text: typeC4Shape };
container.parentBoundary = currentBoundaryParse;
};
//type, alias, label, ?techn, ?descr ?sprite, ?tags, $link
export const addComponent = function (typeC4Shape, alias, label, techn, descr, sprite, tags, link) {
// Don't allow label nulling
if (alias === null || label === null) return;
let component = {};
const old = c4ShapeArray.find((component) => component.alias === alias);
if (old && alias === old.alias) {
component = old;
} else {
component.alias = alias;
c4ShapeArray.push(component);
}
// Don't allow null labels, either
if (label === undefined || label === null) {
component.label = { text: '' };
} else {
component.label = { text: label };
}
if (techn === undefined || techn === null) {
component.techn = { text: '' };
} else {
if (typeof techn === 'object') {
let [key, value] = Object.entries(techn)[0];
component[key] = { text: value };
} else {
component.techn = { text: techn };
}
}
if (descr === undefined || descr === null) {
component.descr = { text: '' };
} else {
if (typeof descr === 'object') {
let [key, value] = Object.entries(descr)[0];
component[key] = { text: value };
} else {
component.descr = { text: descr };
}
}
if (typeof sprite === 'object') {
let [key, value] = Object.entries(sprite)[0];
component[key] = value;
} else {
component.sprite = sprite;
}
if (typeof tags === 'object') {
let [key, value] = Object.entries(tags)[0];
component[key] = value;
} else {
component.tags = tags;
}
if (typeof link === 'object') {
let [key, value] = Object.entries(link)[0];
component[key] = value;
} else {
component.link = link;
}
component.wrap = autoWrap();
component.typeC4Shape = { text: typeC4Shape };
component.parentBoundary = currentBoundaryParse;
};
//alias, label, ?type, ?tags, $link
export const addPersonOrSystemBoundary = function (alias, label, type, tags, link) {
// if (parentBoundary === null) return;
// Don't allow label nulling
if (alias === null || label === null) return;
let boundary = {};
const old = boundarys.find((boundary) => boundary.alias === alias);
if (old && alias === old.alias) {
boundary = old;
} else {
boundary.alias = alias;
boundarys.push(boundary);
}
// Don't allow null labels, either
if (label === undefined || label === null) {
boundary.label = { text: '' };
} else {
boundary.label = { text: label };
}
if (type === undefined || type === null) {
boundary.type = { text: 'system' };
} else {
if (typeof type === 'object') {
let [key, value] = Object.entries(type)[0];
boundary[key] = { text: value };
} else {
boundary.type = { text: type };
}
}
if (typeof tags === 'object') {
let [key, value] = Object.entries(tags)[0];
boundary[key] = value;
} else {
boundary.tags = tags;
}
if (typeof link === 'object') {
let [key, value] = Object.entries(link)[0];
boundary[key] = value;
} else {
boundary.link = link;
}
boundary.parentBoundary = currentBoundaryParse;
boundary.wrap = autoWrap();
parentBoundaryParse = currentBoundaryParse;
currentBoundaryParse = alias;
boundaryParseStack.push(parentBoundaryParse);
};
//alias, label, ?type, ?tags, $link
export const addContainerBoundary = function (alias, label, type, tags, link) {
// if (parentBoundary === null) return;
// Don't allow label nulling
if (alias === null || label === null) return;
let boundary = {};
const old = boundarys.find((boundary) => boundary.alias === alias);
if (old && alias === old.alias) {
boundary = old;
} else {
boundary.alias = alias;
boundarys.push(boundary);
}
// Don't allow null labels, either
if (label === undefined || label === null) {
boundary.label = { text: '' };
} else {
boundary.label = { text: label };
}
if (type === undefined || type === null) {
boundary.type = { text: 'container' };
} else {
if (typeof type === 'object') {
let [key, value] = Object.entries(type)[0];
boundary[key] = { text: value };
} else {
boundary.type = { text: type };
}
}
if (typeof tags === 'object') {
let [key, value] = Object.entries(tags)[0];
boundary[key] = value;
} else {
boundary.tags = tags;
}
if (typeof link === 'object') {
let [key, value] = Object.entries(link)[0];
boundary[key] = value;
} else {
boundary.link = link;
}
boundary.parentBoundary = currentBoundaryParse;
boundary.wrap = autoWrap();
parentBoundaryParse = currentBoundaryParse;
currentBoundaryParse = alias;
boundaryParseStack.push(parentBoundaryParse);
};
//alias, label, ?type, ?descr, ?sprite, ?tags, $link
export const addDeploymentNode = function (
nodeType,
alias,
label,
type,
descr,
sprite,
tags,
link
) {
// if (parentBoundary === null) return;
// Don't allow label nulling
if (alias === null || label === null) return;
let boundary = {};
const old = boundarys.find((boundary) => boundary.alias === alias);
if (old && alias === old.alias) {
boundary = old;
} else {
boundary.alias = alias;
boundarys.push(boundary);
}
// Don't allow null labels, either
if (label === undefined || label === null) {
boundary.label = { text: '' };
} else {
boundary.label = { text: label };
}
if (type === undefined || type === null) {
boundary.type = { text: 'node' };
} else {
if (typeof type === 'object') {
let [key, value] = Object.entries(type)[0];
boundary[key] = { text: value };
} else {
boundary.type = { text: type };
}
}
if (descr === undefined || descr === null) {
boundary.descr = { text: '' };
} else {
if (typeof descr === 'object') {
let [key, value] = Object.entries(descr)[0];
boundary[key] = { text: value };
} else {
boundary.descr = { text: descr };
}
}
if (typeof tags === 'object') {
let [key, value] = Object.entries(tags)[0];
boundary[key] = value;
} else {
boundary.tags = tags;
}
if (typeof link === 'object') {
let [key, value] = Object.entries(link)[0];
boundary[key] = value;
} else {
boundary.link = link;
}
boundary.nodeType = nodeType;
boundary.parentBoundary = currentBoundaryParse;
boundary.wrap = autoWrap();
parentBoundaryParse = currentBoundaryParse;
currentBoundaryParse = alias;
boundaryParseStack.push(parentBoundaryParse);
};
export const popBoundaryParseStack = function () {
currentBoundaryParse = parentBoundaryParse;
boundaryParseStack.pop();
parentBoundaryParse = boundaryParseStack.pop();
boundaryParseStack.push(parentBoundaryParse);
};
//elementName, ?bgColor, ?fontColor, ?borderColor, ?shadowing, ?shape, ?sprite, ?techn, ?legendText, ?legendSprite
export const updateElStyle = function (
typeC4Shape,
elementName,
bgColor,
fontColor,
borderColor,
shadowing,
shape,
sprite,
techn,
legendText,
legendSprite
) {
let old = c4ShapeArray.find((element) => element.alias === elementName);
if (old === undefined) {
old = boundarys.find((element) => element.alias === elementName);
if (old === undefined) {
return;
}
}
if (bgColor !== undefined && bgColor !== null) {
if (typeof bgColor === 'object') {
let [key, value] = Object.entries(bgColor)[0];
old[key] = value;
} else {
old.bgColor = bgColor;
}
}
if (fontColor !== undefined && fontColor !== null) {
if (typeof fontColor === 'object') {
let [key, value] = Object.entries(fontColor)[0];
old[key] = value;
} else {
old.fontColor = fontColor;
}
}
if (borderColor !== undefined && borderColor !== null) {
if (typeof borderColor === 'object') {
let [key, value] = Object.entries(borderColor)[0];
old[key] = value;
} else {
old.borderColor = borderColor;
}
}
if (shadowing !== undefined && shadowing !== null) {
if (typeof shadowing === 'object') {
let [key, value] = Object.entries(shadowing)[0];
old[key] = value;
} else {
old.shadowing = shadowing;
}
}
if (shape !== undefined && shape !== null) {
if (typeof shape === 'object') {
let [key, value] = Object.entries(shape)[0];
old[key] = value;
} else {
old.shape = shape;
}
}
if (sprite !== undefined && sprite !== null) {
if (typeof sprite === 'object') {
let [key, value] = Object.entries(sprite)[0];
old[key] = value;
} else {
old.sprite = sprite;
}
}
if (techn !== undefined && techn !== null) {
if (typeof techn === 'object') {
let [key, value] = Object.entries(techn)[0];
old[key] = value;
} else {
old.techn = techn;
}
}
if (legendText !== undefined && legendText !== null) {
if (typeof legendText === 'object') {
let [key, value] = Object.entries(legendText)[0];
old[key] = value;
} else {
old.legendText = legendText;
}
}
if (legendSprite !== undefined && legendSprite !== null) {
if (typeof legendSprite === 'object') {
let [key, value] = Object.entries(legendSprite)[0];
old[key] = value;
} else {
old.legendSprite = legendSprite;
}
}
};
//textColor, lineColor, ?offsetX, ?offsetY
export const updateRelStyle = function (
typeC4Shape,
from,
to,
textColor,
lineColor,
offsetX,
offsetY
) {
const old = rels.find((rel) => rel.from === from && rel.to === to);
if (old === undefined) {
return;
}
if (textColor !== undefined && textColor !== null) {
if (typeof textColor === 'object') {
let [key, value] = Object.entries(textColor)[0];
old[key] = value;
} else {
old.textColor = textColor;
}
}
if (lineColor !== undefined && lineColor !== null) {
if (typeof lineColor === 'object') {
let [key, value] = Object.entries(lineColor)[0];
old[key] = value;
} else {
old.lineColor = lineColor;
}
}
if (offsetX !== undefined && offsetX !== null) {
if (typeof offsetX === 'object') {
let [key, value] = Object.entries(offsetX)[0];
old[key] = parseInt(value);
} else {
old.offsetX = parseInt(offsetX);
}
}
if (offsetY !== undefined && offsetY !== null) {
if (typeof offsetY === 'object') {
let [key, value] = Object.entries(offsetY)[0];
old[key] = parseInt(value);
} else {
old.offsetY = parseInt(offsetY);
}
}
};
//?c4ShapeInRow, ?c4BoundaryInRow
export const updateLayoutConfig = function (typeC4Shape, c4ShapeInRowParam, c4BoundaryInRowParam) {
let c4ShapeInRowValue = c4ShapeInRow;
let c4BoundaryInRowValue = c4BoundaryInRow;
if (typeof c4ShapeInRowParam === 'object') {
const value = Object.values(c4ShapeInRowParam)[0];
c4ShapeInRowValue = parseInt(value);
} else {
c4ShapeInRowValue = parseInt(c4ShapeInRowParam);
}
if (typeof c4BoundaryInRowParam === 'object') {
const value = Object.values(c4BoundaryInRowParam)[0];
c4BoundaryInRowValue = parseInt(value);
} else {
c4BoundaryInRowValue = parseInt(c4BoundaryInRowParam);
}
if (c4ShapeInRowValue >= 1) c4ShapeInRow = c4ShapeInRowValue;
if (c4BoundaryInRowValue >= 1) c4BoundaryInRow = c4BoundaryInRowValue;
};
export const getC4ShapeInRow = function () {
return c4ShapeInRow;
};
export const getC4BoundaryInRow = function () {
return c4BoundaryInRow;
};
export const getCurrentBoundaryParse = function () {
return currentBoundaryParse;
};
export const getParentBoundaryParse = function () {
return parentBoundaryParse;
};
export const getC4ShapeArray = function (parentBoundary) {
if (parentBoundary === undefined || parentBoundary === null) return c4ShapeArray;
else
return c4ShapeArray.filter((personOrSystem) => {
return personOrSystem.parentBoundary === parentBoundary;
});
};
export const getC4Shape = function (alias) {
return c4ShapeArray.find((personOrSystem) => personOrSystem.alias === alias);
};
export const getC4ShapeKeys = function (parentBoundary) {
return Object.keys(getC4ShapeArray(parentBoundary));
};
export const getBoundarys = function (parentBoundary) {
if (parentBoundary === undefined || parentBoundary === null) return boundarys;
else return boundarys.filter((boundary) => boundary.parentBoundary === parentBoundary);
};
export const getRels = function () {
return rels;
};
export const getTitle = function () {
return title;
};
export const setWrap = function (wrapSetting) {
wrapEnabled = wrapSetting;
};
export const autoWrap = function () {
return wrapEnabled;
};
export const clear = function () {
c4ShapeArray = [];
boundarys = [
{
alias: 'global',
label: { text: 'global' },
type: { text: 'global' },
tags: null,
link: null,
parentBoundary: '',
},
];
parentBoundaryParse = '';
currentBoundaryParse = 'global';
boundaryParseStack = [''];
rels = [];
boundaryParseStack = [''];
title = '';
wrapEnabled = false;
c4ShapeInRow = 4;
c4BoundaryInRow = 2;
};
export const LINETYPE = {
SOLID: 0,
DOTTED: 1,
NOTE: 2,
SOLID_CROSS: 3,
DOTTED_CROSS: 4,
SOLID_OPEN: 5,
DOTTED_OPEN: 6,
LOOP_START: 10,
LOOP_END: 11,
ALT_START: 12,
ALT_ELSE: 13,
ALT_END: 14,
OPT_START: 15,
OPT_END: 16,
ACTIVE_START: 17,
ACTIVE_END: 18,
PAR_START: 19,
PAR_AND: 20,
PAR_END: 21,
RECT_START: 22,
RECT_END: 23,
SOLID_POINT: 24,
DOTTED_POINT: 25,
};
export const ARROWTYPE = {
FILLED: 0,
OPEN: 1,
};
export const PLACEMENT = {
LEFTOF: 0,
RIGHTOF: 1,
OVER: 2,
};
export const setTitle = function (txt) {
let sanitizedText = sanitizeText(txt, configApi.getConfig());
title = sanitizedText;
};
export default {
addPersonOrSystem,
addPersonOrSystemBoundary,
addContainer,
addContainerBoundary,
addComponent,
addDeploymentNode,
popBoundaryParseStack,
addRel,
updateElStyle,
updateRelStyle,
updateLayoutConfig,
autoWrap,
setWrap,
getC4ShapeArray,
getC4Shape,
getC4ShapeKeys,
getBoundarys,
getCurrentBoundaryParse,
getParentBoundaryParse,
getRels,
getTitle,
getC4Type,
getC4ShapeInRow,
getC4BoundaryInRow,
setAccTitle,
getAccTitle,
getAccDescription,
setAccDescription,
parseDirective,
getConfig: () => configApi.getConfig().c4,
clear,
LINETYPE,
ARROWTYPE,
PLACEMENT,
setTitle,
setC4Type,
// apply,
};

View File

@@ -0,0 +1,5 @@
import type { DiagramDetector } from '../../diagram-api/detectType';
export const c4Detector: DiagramDetector = (txt) => {
return txt.match(/^\s*C4Context|C4Container|C4Component|C4Dynamic|C4Deployment/) !== null;
};

View File

@@ -0,0 +1,680 @@
import { select } from 'd3';
import svgDraw from './svgDraw';
import { log } from '../../logger';
import { parser } from './parser/c4Diagram';
import common from '../common/common';
import c4Db from './c4Db';
import * as configApi from '../../config';
import assignWithDepth from '../../assignWithDepth';
import { wrapLabel, calculateTextWidth, calculateTextHeight } from '../../utils';
import { configureSvgSize } from '../../setupGraphViewbox';
import addSVGAccessibilityFields from '../../accessibility';
let globalBoundaryMaxX = 0,
globalBoundaryMaxY = 0;
let c4ShapeInRow = 4;
let c4BoundaryInRow = 2;
parser.yy = c4Db;
let conf = {};
class Bounds {
constructor(diagObj) {
this.name = '';
this.data = {};
this.data.startx = undefined;
this.data.stopx = undefined;
this.data.starty = undefined;
this.data.stopy = undefined;
this.data.widthLimit = undefined;
this.nextData = {};
this.nextData.startx = undefined;
this.nextData.stopx = undefined;
this.nextData.starty = undefined;
this.nextData.stopy = undefined;
this.nextData.cnt = 0;
setConf(diagObj.db.getConfig());
}
setData(startx, stopx, starty, stopy) {
this.nextData.startx = this.data.startx = startx;
this.nextData.stopx = this.data.stopx = stopx;
this.nextData.starty = this.data.starty = starty;
this.nextData.stopy = this.data.stopy = stopy;
}
updateVal(obj, key, val, fun) {
if (typeof obj[key] === 'undefined') {
obj[key] = val;
} else {
obj[key] = fun(val, obj[key]);
}
}
insert(c4Shape) {
this.nextData.cnt = this.nextData.cnt + 1;
let _startx =
this.nextData.startx === this.nextData.stopx
? this.nextData.stopx + c4Shape.margin
: this.nextData.stopx + c4Shape.margin * 2;
let _stopx = _startx + c4Shape.width;
let _starty = this.nextData.starty + c4Shape.margin * 2;
let _stopy = _starty + c4Shape.height;
if (
_startx >= this.data.widthLimit ||
_stopx >= this.data.widthLimit ||
this.nextData.cnt > c4ShapeInRow
) {
_startx = this.nextData.startx + c4Shape.margin + conf.nextLinePaddingX;
_starty = this.nextData.stopy + c4Shape.margin * 2;
this.nextData.stopx = _stopx = _startx + c4Shape.width;
this.nextData.starty = this.nextData.stopy;
this.nextData.stopy = _stopy = _starty + c4Shape.height;
this.nextData.cnt = 1;
}
c4Shape.x = _startx;
c4Shape.y = _starty;
this.updateVal(this.data, 'startx', _startx, Math.min);
this.updateVal(this.data, 'starty', _starty, Math.min);
this.updateVal(this.data, 'stopx', _stopx, Math.max);
this.updateVal(this.data, 'stopy', _stopy, Math.max);
this.updateVal(this.nextData, 'startx', _startx, Math.min);
this.updateVal(this.nextData, 'starty', _starty, Math.min);
this.updateVal(this.nextData, 'stopx', _stopx, Math.max);
this.updateVal(this.nextData, 'stopy', _stopy, Math.max);
}
init(diagObj) {
this.name = '';
this.data = {
startx: undefined,
stopx: undefined,
starty: undefined,
stopy: undefined,
widthLimit: undefined,
};
this.nextData = {
startx: undefined,
stopx: undefined,
starty: undefined,
stopy: undefined,
cnt: 0,
};
setConf(diagObj.db.getConfig());
}
bumpLastMargin(margin) {
this.data.stopx += margin;
this.data.stopy += margin;
}
}
export const setConf = function (cnf) {
assignWithDepth(conf, cnf);
if (cnf.fontFamily) {
conf.personFontFamily = conf.systemFontFamily = conf.messageFontFamily = cnf.fontFamily;
}
if (cnf.fontSize) {
conf.personFontSize = conf.systemFontSize = conf.messageFontSize = cnf.fontSize;
}
if (cnf.fontWeight) {
conf.personFontWeight = conf.systemFontWeight = conf.messageFontWeight = cnf.fontWeight;
}
};
const c4ShapeFont = (cnf, typeC4Shape) => {
return {
fontFamily: cnf[typeC4Shape + 'FontFamily'],
fontSize: cnf[typeC4Shape + 'FontSize'],
fontWeight: cnf[typeC4Shape + 'FontWeight'],
};
};
const boundaryFont = (cnf) => {
return {
fontFamily: cnf.boundaryFontFamily,
fontSize: cnf.boundaryFontSize,
fontWeight: cnf.boundaryFontWeight,
};
};
const messageFont = (cnf) => {
return {
fontFamily: cnf.messageFontFamily,
fontSize: cnf.messageFontSize,
fontWeight: cnf.messageFontWeight,
};
};
/**
* @param textType
* @param c4Shape
* @param c4ShapeTextWrap
* @param textConf
* @param textLimitWidth
*/
function calcC4ShapeTextWH(textType, c4Shape, c4ShapeTextWrap, textConf, textLimitWidth) {
if (!c4Shape[textType].width) {
if (c4ShapeTextWrap) {
c4Shape[textType].text = wrapLabel(c4Shape[textType].text, textLimitWidth, textConf);
c4Shape[textType].textLines = c4Shape[textType].text.split(common.lineBreakRegex).length;
// c4Shape[textType].width = calculateTextWidth(c4Shape[textType].text, textConf);
c4Shape[textType].width = textLimitWidth;
// c4Shape[textType].height = c4Shape[textType].textLines * textConf.fontSize;
c4Shape[textType].height = calculateTextHeight(c4Shape[textType].text, textConf);
} else {
let lines = c4Shape[textType].text.split(common.lineBreakRegex);
c4Shape[textType].textLines = lines.length;
let lineHeight = 0;
c4Shape[textType].height = 0;
c4Shape[textType].width = 0;
for (let i = 0; i < lines.length; i++) {
c4Shape[textType].width = Math.max(
calculateTextWidth(lines[i], textConf),
c4Shape[textType].width
);
lineHeight = calculateTextHeight(lines[i], textConf);
c4Shape[textType].height = c4Shape[textType].height + lineHeight;
}
// c4Shapes[textType].height = c4Shapes[textType].textLines * textConf.fontSize;
}
}
}
export const drawBoundary = function (diagram, boundary, bounds) {
boundary.x = bounds.data.startx;
boundary.y = bounds.data.starty;
boundary.width = bounds.data.stopx - bounds.data.startx;
boundary.height = bounds.data.stopy - bounds.data.starty;
boundary.label.y = conf.c4ShapeMargin - 35;
let boundaryTextWrap = boundary.wrap && conf.wrap;
let boundaryLabelConf = boundaryFont(conf);
boundaryLabelConf.fontSize = boundaryLabelConf.fontSize + 2;
boundaryLabelConf.fontWeight = 'bold';
let textLimitWidth = calculateTextWidth(boundary.label.text, boundaryLabelConf);
calcC4ShapeTextWH('label', boundary, boundaryTextWrap, boundaryLabelConf, textLimitWidth);
svgDraw.drawBoundary(diagram, boundary, conf);
};
export const drawC4ShapeArray = function (currentBounds, diagram, c4ShapeArray, c4ShapeKeys) {
// Upper Y is relative point
let Y = 0;
// Draw the c4ShapeArray
for (let i = 0; i < c4ShapeKeys.length; i++) {
Y = 0;
const c4Shape = c4ShapeArray[c4ShapeKeys[i]];
// calc c4 shape type width and height
let c4ShapeTypeConf = c4ShapeFont(conf, c4Shape.typeC4Shape.text);
c4ShapeTypeConf.fontSize = c4ShapeTypeConf.fontSize - 2;
c4Shape.typeC4Shape.width = calculateTextWidth(
'<<' + c4Shape.typeC4Shape.text + '>>',
c4ShapeTypeConf
);
c4Shape.typeC4Shape.height = c4ShapeTypeConf.fontSize + 2;
c4Shape.typeC4Shape.Y = conf.c4ShapePadding;
Y = c4Shape.typeC4Shape.Y + c4Shape.typeC4Shape.height - 4;
// set image width and height c4Shape.x + c4Shape.width / 2 - 24, c4Shape.y + 28
// let imageWidth = 0,
// imageHeight = 0,
// imageY = 0;
//
c4Shape.image = { width: 0, height: 0, Y: 0 };
switch (c4Shape.typeC4Shape.text) {
case 'person':
case 'external_person':
c4Shape.image.width = 48;
c4Shape.image.height = 48;
c4Shape.image.Y = Y;
Y = c4Shape.image.Y + c4Shape.image.height;
break;
}
if (c4Shape.sprite) {
c4Shape.image.width = 48;
c4Shape.image.height = 48;
c4Shape.image.Y = Y;
Y = c4Shape.image.Y + c4Shape.image.height;
}
// Y = conf.c4ShapePadding + c4Shape.image.height;
let c4ShapeTextWrap = c4Shape.wrap && conf.wrap;
let textLimitWidth = conf.width - conf.c4ShapePadding * 2;
let c4ShapeLabelConf = c4ShapeFont(conf, c4Shape.typeC4Shape.text);
c4ShapeLabelConf.fontSize = c4ShapeLabelConf.fontSize + 2;
c4ShapeLabelConf.fontWeight = 'bold';
calcC4ShapeTextWH('label', c4Shape, c4ShapeTextWrap, c4ShapeLabelConf, textLimitWidth);
c4Shape['label'].Y = Y + 8;
Y = c4Shape['label'].Y + c4Shape['label'].height;
if (c4Shape.type && c4Shape.type.text !== '') {
c4Shape.type.text = '[' + c4Shape.type.text + ']';
let c4ShapeTypeConf = c4ShapeFont(conf, c4Shape.typeC4Shape.text);
calcC4ShapeTextWH('type', c4Shape, c4ShapeTextWrap, c4ShapeTypeConf, textLimitWidth);
c4Shape['type'].Y = Y + 5;
Y = c4Shape['type'].Y + c4Shape['type'].height;
} else if (c4Shape.techn && c4Shape.techn.text !== '') {
c4Shape.techn.text = '[' + c4Shape.techn.text + ']';
let c4ShapeTechnConf = c4ShapeFont(conf, c4Shape.techn.text);
calcC4ShapeTextWH('techn', c4Shape, c4ShapeTextWrap, c4ShapeTechnConf, textLimitWidth);
c4Shape['techn'].Y = Y + 5;
Y = c4Shape['techn'].Y + c4Shape['techn'].height;
}
let rectHeight = Y;
let rectWidth = c4Shape.label.width;
if (c4Shape.descr && c4Shape.descr.text !== '') {
let c4ShapeDescrConf = c4ShapeFont(conf, c4Shape.typeC4Shape.text);
calcC4ShapeTextWH('descr', c4Shape, c4ShapeTextWrap, c4ShapeDescrConf, textLimitWidth);
c4Shape['descr'].Y = Y + 20;
Y = c4Shape['descr'].Y + c4Shape['descr'].height;
rectWidth = Math.max(c4Shape.label.width, c4Shape.descr.width);
rectHeight = Y - c4Shape['descr'].textLines * 5;
}
rectWidth = rectWidth + conf.c4ShapePadding;
// let rectHeight =
c4Shape.width = Math.max(c4Shape.width || conf.width, rectWidth, conf.width);
c4Shape.height = Math.max(c4Shape.height || conf.height, rectHeight, conf.height);
c4Shape.margin = c4Shape.margin || conf.c4ShapeMargin;
currentBounds.insert(c4Shape);
svgDraw.drawC4Shape(diagram, c4Shape, conf);
}
currentBounds.bumpLastMargin(conf.c4ShapeMargin);
};
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
}
/* * *
* Get the intersection of the line between the center point of a rectangle and a point outside the rectangle.
* Algorithm idea.
* Using a point outside the rectangle as the coordinate origin, the graph is divided into four quadrants, and each quadrant is divided into two cases, with separate treatment on the coordinate axes
* 1. The case of coordinate axes.
* 1. The case of the negative x-axis
* 2. The case of the positive x-axis
* 3. The case of the positive y-axis
* 4. The negative y-axis case
* 2. Quadrant cases.
* 2.1. first quadrant: the case where the line intersects the left side of the rectangle; the case where it intersects the lower side of the rectangle
* 2.2. second quadrant: the case where the line intersects the right side of the rectangle; the case where it intersects the lower edge of the rectangle
* 2.3. third quadrant: the case where the line intersects the right side of the rectangle; the case where it intersects the upper edge of the rectangle
* 2.4. fourth quadrant: the case where the line intersects the left side of the rectangle; the case where it intersects the upper side of the rectangle
*
*/
let getIntersectPoint = function (fromNode, endPoint) {
let x1 = fromNode.x;
let y1 = fromNode.y;
let x2 = endPoint.x;
let y2 = endPoint.y;
let fromCenterX = x1 + fromNode.width / 2;
let fromCenterY = y1 + fromNode.height / 2;
let dx = Math.abs(x1 - x2);
let dy = Math.abs(y1 - y2);
let tanDYX = dy / dx;
let fromDYX = fromNode.height / fromNode.width;
let returnPoint = null;
if (y1 == y2 && x1 < x2) {
returnPoint = new Point(x1 + fromNode.width, fromCenterY);
} else if (y1 == y2 && x1 > x2) {
returnPoint = new Point(x1, fromCenterY);
} else if (x1 == x2 && y1 < y2) {
returnPoint = new Point(fromCenterX, y1 + fromNode.height);
} else if (x1 == x2 && y1 > y2) {
returnPoint = new Point(fromCenterX, y1);
}
if (x1 > x2 && y1 < y2) {
if (fromDYX >= tanDYX) {
returnPoint = new Point(x1, fromCenterY + (tanDYX * fromNode.width) / 2);
} else {
returnPoint = new Point(
fromCenterX - ((dx / dy) * fromNode.height) / 2,
y1 + fromNode.height
);
}
} else if (x1 < x2 && y1 < y2) {
//
if (fromDYX >= tanDYX) {
returnPoint = new Point(x1 + fromNode.width, fromCenterY + (tanDYX * fromNode.width) / 2);
} else {
returnPoint = new Point(
fromCenterX + ((dx / dy) * fromNode.height) / 2,
y1 + fromNode.height
);
}
} else if (x1 < x2 && y1 > y2) {
if (fromDYX >= tanDYX) {
returnPoint = new Point(x1 + fromNode.width, fromCenterY - (tanDYX * fromNode.width) / 2);
} else {
returnPoint = new Point(fromCenterX + ((fromNode.height / 2) * dx) / dy, y1);
}
} else if (x1 > x2 && y1 > y2) {
if (fromDYX >= tanDYX) {
returnPoint = new Point(x1, fromCenterY - (fromNode.width / 2) * tanDYX);
} else {
returnPoint = new Point(fromCenterX - ((fromNode.height / 2) * dx) / dy, y1);
}
}
return returnPoint;
};
let getIntersectPoints = function (fromNode, endNode) {
let endIntersectPoint = { x: 0, y: 0 };
endIntersectPoint.x = endNode.x + endNode.width / 2;
endIntersectPoint.y = endNode.y + endNode.height / 2;
let startPoint = getIntersectPoint(fromNode, endIntersectPoint);
endIntersectPoint.x = fromNode.x + fromNode.width / 2;
endIntersectPoint.y = fromNode.y + fromNode.height / 2;
let endPoint = getIntersectPoint(endNode, endIntersectPoint);
return { startPoint: startPoint, endPoint: endPoint };
};
export const drawRels = function (diagram, rels, getC4ShapeObj, diagObj) {
let i = 0;
for (let rel of rels) {
i = i + 1;
let relTextWrap = rel.wrap && conf.wrap;
let relConf = messageFont(conf);
let diagramType = diagObj.db.getC4Type();
if (diagramType === 'C4Dynamic') rel.label.text = i + ': ' + rel.label.text;
let textLimitWidth = calculateTextWidth(rel.label.text, relConf);
calcC4ShapeTextWH('label', rel, relTextWrap, relConf, textLimitWidth);
if (rel.techn && rel.techn.text !== '') {
textLimitWidth = calculateTextWidth(rel.techn.text, relConf);
calcC4ShapeTextWH('techn', rel, relTextWrap, relConf, textLimitWidth);
}
if (rel.descr && rel.descr.text !== '') {
textLimitWidth = calculateTextWidth(rel.descr.text, relConf);
calcC4ShapeTextWH('descr', rel, relTextWrap, relConf, textLimitWidth);
}
let fromNode = getC4ShapeObj(rel.from);
let endNode = getC4ShapeObj(rel.to);
let points = getIntersectPoints(fromNode, endNode);
rel.startPoint = points.startPoint;
rel.endPoint = points.endPoint;
}
svgDraw.drawRels(diagram, rels, conf);
};
/**
* @param diagram
* @param parentBoundaryAlias
* @param parentBounds
* @param currentBoundarys
* @param diagObj
*/
function drawInsideBoundary(diagram, parentBoundaryAlias, parentBounds, currentBoundarys, diagObj) {
let currentBounds = new Bounds(diagObj);
// Calculate the width limit of the boundar. label/type 的长度,
currentBounds.data.widthLimit =
parentBounds.data.widthLimit / Math.min(c4BoundaryInRow, currentBoundarys.length);
// Math.min(
// conf.width * conf.c4ShapeInRow + conf.c4ShapeMargin * conf.c4ShapeInRow * 2,
// parentBounds.data.widthLimit / Math.min(conf.c4BoundaryInRow, currentBoundarys.length)
// );
for (let i = 0; i < currentBoundarys.length; i++) {
let currentBoundary = currentBoundarys[i];
let Y = 0;
currentBoundary.image = { width: 0, height: 0, Y: 0 };
if (currentBoundary.sprite) {
currentBoundary.image.width = 48;
currentBoundary.image.height = 48;
currentBoundary.image.Y = Y;
Y = currentBoundary.image.Y + currentBoundary.image.height;
}
let currentBoundaryTextWrap = currentBoundary.wrap && conf.wrap;
let currentBoundaryLabelConf = boundaryFont(conf);
currentBoundaryLabelConf.fontSize = currentBoundaryLabelConf.fontSize + 2;
currentBoundaryLabelConf.fontWeight = 'bold';
calcC4ShapeTextWH(
'label',
currentBoundary,
currentBoundaryTextWrap,
currentBoundaryLabelConf,
currentBounds.data.widthLimit
);
currentBoundary['label'].Y = Y + 8;
Y = currentBoundary['label'].Y + currentBoundary['label'].height;
if (currentBoundary.type && currentBoundary.type.text !== '') {
currentBoundary.type.text = '[' + currentBoundary.type.text + ']';
let currentBoundaryTypeConf = boundaryFont(conf);
calcC4ShapeTextWH(
'type',
currentBoundary,
currentBoundaryTextWrap,
currentBoundaryTypeConf,
currentBounds.data.widthLimit
);
currentBoundary['type'].Y = Y + 5;
Y = currentBoundary['type'].Y + currentBoundary['type'].height;
}
if (currentBoundary.descr && currentBoundary.descr.text !== '') {
let currentBoundaryDescrConf = boundaryFont(conf);
currentBoundaryDescrConf.fontSize = currentBoundaryDescrConf.fontSize - 2;
calcC4ShapeTextWH(
'descr',
currentBoundary,
currentBoundaryTextWrap,
currentBoundaryDescrConf,
currentBounds.data.widthLimit
);
currentBoundary['descr'].Y = Y + 20;
Y = currentBoundary['descr'].Y + currentBoundary['descr'].height;
}
if (i == 0 || i % c4BoundaryInRow === 0) {
// Calculate the drawing start point of the currentBoundarys.
let _x = parentBounds.data.startx + conf.diagramMarginX;
let _y = parentBounds.data.stopy + conf.diagramMarginY + Y;
currentBounds.setData(_x, _x, _y, _y);
} else {
// Calculate the drawing start point of the currentBoundarys.
let _x =
currentBounds.data.stopx !== currentBounds.data.startx
? currentBounds.data.stopx + conf.diagramMarginX
: currentBounds.data.startx;
let _y = currentBounds.data.starty;
currentBounds.setData(_x, _x, _y, _y);
}
currentBounds.name = currentBoundary.alias;
let currentPersonOrSystemArray = diagObj.db.getC4ShapeArray(currentBoundary.alias);
let currentPersonOrSystemKeys = diagObj.db.getC4ShapeKeys(currentBoundary.alias);
if (currentPersonOrSystemKeys.length > 0) {
drawC4ShapeArray(
currentBounds,
diagram,
currentPersonOrSystemArray,
currentPersonOrSystemKeys
);
}
parentBoundaryAlias = currentBoundary.alias;
let nextCurrentBoundarys = diagObj.db.getBoundarys(parentBoundaryAlias);
if (nextCurrentBoundarys.length > 0) {
// draw boundary inside currentBoundary
// bounds.init();
// parentBoundaryWidthLimit = bounds.data.stopx - bounds.startx;
drawInsideBoundary(
diagram,
parentBoundaryAlias,
currentBounds,
nextCurrentBoundarys,
diagObj
);
}
// draw boundary
if (currentBoundary.alias !== 'global') drawBoundary(diagram, currentBoundary, currentBounds);
parentBounds.data.stopy = Math.max(
currentBounds.data.stopy + conf.c4ShapeMargin,
parentBounds.data.stopy
);
parentBounds.data.stopx = Math.max(
currentBounds.data.stopx + conf.c4ShapeMargin,
parentBounds.data.stopx
);
globalBoundaryMaxX = Math.max(globalBoundaryMaxX, parentBounds.data.stopx);
globalBoundaryMaxY = Math.max(globalBoundaryMaxY, parentBounds.data.stopy);
}
}
/**
* Draws a sequenceDiagram in the tag with id: id based on the graph definition in text.
*
* @param {any} _text
* @param {any} id
* @param {any} _version
* @param diagObj
*/
export const draw = function (_text, id, _version, diagObj) {
conf = configApi.getConfig().c4;
const securityLevel = configApi.getConfig().securityLevel;
// Handle root and Document for when rendering in sanbox mode
let sandboxElement;
if (securityLevel === 'sandbox') {
sandboxElement = select('#i' + id);
}
const root =
securityLevel === 'sandbox'
? select(sandboxElement.nodes()[0].contentDocument.body)
: select('body');
let db = diagObj.db;
diagObj.db.setWrap(conf.wrap);
c4ShapeInRow = db.getC4ShapeInRow();
c4BoundaryInRow = db.getC4BoundaryInRow();
log.debug(`C:${JSON.stringify(conf, null, 2)}`);
const diagram =
securityLevel === 'sandbox' ? root.select(`[id="${id}"]`) : select(`[id="${id}"]`);
svgDraw.insertComputerIcon(diagram);
svgDraw.insertDatabaseIcon(diagram);
svgDraw.insertClockIcon(diagram);
let screenBounds = new Bounds(diagObj);
screenBounds.setData(
conf.diagramMarginX,
conf.diagramMarginX,
conf.diagramMarginY,
conf.diagramMarginY
);
screenBounds.data.widthLimit = screen.availWidth;
globalBoundaryMaxX = conf.diagramMarginX;
globalBoundaryMaxY = conf.diagramMarginY;
const title = diagObj.db.getTitle();
let currentBoundarys = diagObj.db.getBoundarys('');
// switch (c4type) {
// case 'C4Context':
drawInsideBoundary(diagram, '', screenBounds, currentBoundarys, diagObj);
// break;
// }
// The arrow head definition is attached to the svg once
svgDraw.insertArrowHead(diagram);
svgDraw.insertArrowEnd(diagram);
svgDraw.insertArrowCrossHead(diagram);
svgDraw.insertArrowFilledHead(diagram);
drawRels(diagram, diagObj.db.getRels(), diagObj.db.getC4Shape, diagObj);
screenBounds.data.stopx = globalBoundaryMaxX;
screenBounds.data.stopy = globalBoundaryMaxY;
const box = screenBounds.data;
// Make sure the height of the diagram supports long menus.
let boxHeight = box.stopy - box.starty;
let height = boxHeight + 2 * conf.diagramMarginY;
// Make sure the width of the diagram supports wide menus.
let boxWidth = box.stopx - box.startx;
const width = boxWidth + 2 * conf.diagramMarginX;
if (title) {
diagram
.append('text')
.text(title)
.attr('x', (box.stopx - box.startx) / 2 - 4 * conf.diagramMarginX)
.attr('y', box.starty + conf.diagramMarginY);
}
configureSvgSize(diagram, height, width, conf.useMaxWidth);
const extraVertForTitle = title ? 60 : 0;
diagram.attr(
'viewBox',
box.startx -
conf.diagramMarginX +
' -' +
(conf.diagramMarginY + extraVertForTitle) +
' ' +
width +
' ' +
(height + extraVertForTitle)
);
addSVGAccessibilityFields(parser.yy, diagram, id);
log.debug(`models:`, box);
};
export default {
drawPersonOrSystemArray: drawC4ShapeArray,
drawBoundary,
setConf,
draw,
};

View File

@@ -0,0 +1,110 @@
import c4Db from '../c4Db';
import c4 from './c4Diagram.jison';
import { setConfig } from '../../../config';
setConfig({
securityLevel: 'strict',
});
describe.each(['Boundary'])('parsing a C4 %s', function (macroName) {
beforeEach(function () {
c4.parser.yy = c4Db;
c4.parser.yy.clear();
});
it('should parse a C4 diagram with one Boundary correctly', function () {
c4.parser.parse(`C4Context
title System Context diagram for Internet Banking System
${macroName}(b1, "BankBoundary") {
System(SystemAA, "Internet Banking System")
}`);
const yy = c4.parser.yy;
const boundaries = yy.getBoundarys();
expect(boundaries.length).toBe(2);
const boundary = boundaries[1];
expect(boundary).toEqual({
alias: 'b1',
label: {
text: 'BankBoundary',
},
// TODO: Why are link, and tags undefined instead of not appearing at all?
// Compare to Person where they don't show up.
link: undefined,
tags: undefined,
parentBoundary: 'global',
type: {
// TODO: Why is this `system` instead of `boundary`?
text: 'system',
},
wrap: false,
});
});
it('should parse the alias', function () {
c4.parser.parse(`C4Context
${macroName}(b1, "BankBoundary") {
System(SystemAA, "Internet Banking System")
}`);
expect(c4.parser.yy.getBoundarys()[1]).toMatchObject({
alias: 'b1',
});
});
it('should parse the label', function () {
c4.parser.parse(`C4Context
${macroName}(b1, "BankBoundary") {
System(SystemAA, "Internet Banking System")
}`);
expect(c4.parser.yy.getBoundarys()[1]).toMatchObject({
label: {
text: 'BankBoundary',
},
});
});
it('should parse the type', function () {
c4.parser.parse(`C4Context
${macroName}(b1, "", "company") {
System(SystemAA, "Internet Banking System")
}`);
expect(c4.parser.yy.getBoundarys()[1]).toMatchObject({
type: { text: 'company' },
});
});
it('should parse a link', function () {
c4.parser.parse(`C4Context
${macroName}(b1, $link="https://github.com/mermaidjs") {
System(SystemAA, "Internet Banking System")
}`);
expect(c4.parser.yy.getBoundarys()[1]).toMatchObject({
label: {
text: {
link: 'https://github.com/mermaidjs',
},
},
});
});
it('should parse tags', function () {
c4.parser.parse(`C4Context
${macroName}(b1, $tags="tag1,tag2") {
System(SystemAA, "Internet Banking System")
}`);
expect(c4.parser.yy.getBoundarys()[1]).toMatchObject({
label: {
text: {
tags: 'tag1,tag2',
},
},
});
});
});

View File

@@ -0,0 +1,352 @@
/** mermaid
* https://mermaidjs.github.io/
* (c) 2022 mzhx.meng@gmail.com
* MIT license.
*/
/* lexical grammar */
%lex
/* context */
%x person
%x person_ext
%x system
%x system_db
%x system_queue
%x system_ext
%x system_ext_db
%x system_ext_queue
%x boundary
%x enterprise_boundary
%x system_boundary
%x rel
%x birel
%x rel_u
%x rel_d
%x rel_l
%x rel_r
/* container */
%x container
%x container_db
%x container_queue
%x container_ext
%x container_ext_db
%x container_ext_queue
%x container_boundary
/* component */
%x component
%x component_db
%x component_queue
%x component_ext
%x component_ext_db
%x component_ext_queue
/* Dynamic diagram */
%x rel_index
%x index
/* Deployment diagram */
%x node
%x node_l
%x node_r
/* Relationship Types */
%x rel
%x rel_bi
%x rel_u
%x rel_d
%x rel_l
%x rel_r
%x rel_b
/* Custom tags/stereotypes */
%x update_el_style
%x update_rel_style
%x update_layout_config
%x attribute
%x string
%x string_kv
%x string_kv_key
%x string_kv_value
%x open_directive
%x type_directive
%x arg_directive
%x close_directive
%x acc_title
%x acc_descr
%x acc_descr_multiline
%%
\%\%\{ { this.begin('open_directive'); return 'open_directive'; }
.*direction\s+TB[^\n]* return 'direction_tb';
.*direction\s+BT[^\n]* return 'direction_bt';
.*direction\s+RL[^\n]* return 'direction_rl';
.*direction\s+LR[^\n]* return 'direction_lr';
<open_directive>((?:(?!\}\%\%)[^:.])*) { this.begin('type_directive'); return 'type_directive'; }
<type_directive>":" { this.popState(); this.begin('arg_directive'); return ':'; }
<type_directive,arg_directive>\}\%\% { this.popState(); this.popState(); return 'close_directive'; }
<arg_directive>((?:(?!\}\%\%).|\n)*) return 'arg_directive';
"title"\s[^#\n;]+ return 'title';
"accDescription"\s[^#\n;]+ return 'accDescription';
accTitle\s*":"\s* { this.begin("acc_title");return 'acc_title'; }
<acc_title>(?!\n|;|#)*[^\n]* { this.popState(); return "acc_title_value"; }
accDescr\s*":"\s* { this.begin("acc_descr");return 'acc_descr'; }
<acc_descr>(?!\n|;|#)*[^\n]* { this.popState(); return "acc_descr_value"; }
accDescr\s*"{"\s* { this.begin("acc_descr_multiline");}
<acc_descr_multiline>[\}] { this.popState(); }
<acc_descr_multiline>[^\}]* return "acc_descr_multiline_value";
\%\%(?!\{)*[^\n]*(\r?\n?)+ /* skip comments */
\%\%[^\n]*(\r?\n)* c /* skip comments */
\s*(\r?\n)+ return 'NEWLINE';
\s+ /* skip whitespace */
"C4Context" return 'C4_CONTEXT';
"C4Container" return 'C4_CONTAINER';
"C4Component" return 'C4_COMPONENT';
"C4Dynamic" return 'C4_DYNAMIC';
"C4Deployment" return 'C4_DEPLOYMENT';
"Person_Ext" { this.begin("person_ext"); return 'PERSON_EXT';}
"Person" { this.begin("person"); return 'PERSON';}
"SystemQueue_Ext" { this.begin("system_ext_queue"); return 'SYSTEM_EXT_QUEUE';}
"SystemDb_Ext" { this.begin("system_ext_db"); return 'SYSTEM_EXT_DB';}
"System_Ext" { this.begin("system_ext"); return 'SYSTEM_EXT';}
"SystemQueue" { this.begin("system_queue"); return 'SYSTEM_QUEUE';}
"SystemDb" { this.begin("system_db"); return 'SYSTEM_DB';}
"System" { this.begin("system"); return 'SYSTEM';}
"Boundary" { this.begin("boundary"); return 'BOUNDARY';}
"Enterprise_Boundary" { this.begin("enterprise_boundary"); return 'ENTERPRISE_BOUNDARY';}
"System_Boundary" { this.begin("system_boundary"); return 'SYSTEM_BOUNDARY';}
"ContainerQueue_Ext" { this.begin("container_ext_queue"); return 'CONTAINER_EXT_QUEUE';}
"ContainerDb_Ext" { this.begin("container_ext_db"); return 'CONTAINER_EXT_DB';}
"Container_Ext" { this.begin("container_ext"); return 'CONTAINER_EXT';}
"ContainerQueue" { this.begin("container_queue"); return 'CONTAINER_QUEUE';}
"ContainerDb" { this.begin("container_db"); return 'CONTAINER_DB';}
"Container" { this.begin("container"); return 'CONTAINER';}
"Container_Boundary" { this.begin("container_boundary"); return 'CONTAINER_BOUNDARY';}
"ComponentQueue_Ext" { this.begin("component_ext_queue"); return 'COMPONENT_EXT_QUEUE';}
"ComponentDb_Ext" { this.begin("component_ext_db"); return 'COMPONENT_EXT_DB';}
"Component_Ext" { this.begin("component_ext"); return 'COMPONENT_EXT';}
"ComponentQueue" { this.begin("component_queue"); return 'COMPONENT_QUEUE';}
"ComponentDb" { this.begin("component_db"); return 'COMPONENT_DB';}
"Component" { this.begin("component"); return 'COMPONENT';}
"Deployment_Node" { this.begin("node"); return 'NODE';}
"Node" { this.begin("node"); return 'NODE';}
"Node_L" { this.begin("node_l"); return 'NODE_L';}
"Node_R" { this.begin("node_r"); return 'NODE_R';}
"Rel" { this.begin("rel"); return 'REL';}
"BiRel" { this.begin("birel"); return 'BIREL';}
"Rel_Up" { this.begin("rel_u"); return 'REL_U';}
"Rel_U" { this.begin("rel_u"); return 'REL_U';}
"Rel_Down" { this.begin("rel_d"); return 'REL_D';}
"Rel_D" { this.begin("rel_d"); return 'REL_D';}
"Rel_Left" { this.begin("rel_l"); return 'REL_L';}
"Rel_L" { this.begin("rel_l"); return 'REL_L';}
"Rel_Right" { this.begin("rel_r"); return 'REL_R';}
"Rel_R" { this.begin("rel_r"); return 'REL_R';}
"Rel_Back" { this.begin("rel_b"); return 'REL_B';}
"RelIndex" { this.begin("rel_index"); return 'REL_INDEX';}
"UpdateElementStyle" { this.begin("update_el_style"); return 'UPDATE_EL_STYLE';}
"UpdateRelStyle" { this.begin("update_rel_style"); return 'UPDATE_REL_STYLE';}
"UpdateLayoutConfig" { this.begin("update_layout_config"); return 'UPDATE_LAYOUT_CONFIG';}
<person,person_ext,system_ext_queue,system_ext_db,system_ext,system_queue,system_db,system,boundary,enterprise_boundary,system_boundary,container_ext_db,container_ext,container_queue,container_db,container,container_boundary,component_ext_db,component_ext,component_queue,component_db,component,node,node_l,node_r,rel,birel,rel_u,rel_d,rel_l,rel_r,rel_b,rel_index,update_el_style,update_rel_style,update_layout_config><<EOF>> return "EOF_IN_STRUCT";
<person,person_ext,system_ext_queue,system_ext_db,system_ext,system_queue,system_db,system,boundary,enterprise_boundary,system_boundary,container_ext_db,container_ext,container_queue,container_db,container,container_boundary,component_ext_db,component_ext,component_queue,component_db,component,node,node_l,node_r,rel,birel,rel_u,rel_d,rel_l,rel_r,rel_b,rel_index,update_el_style,update_rel_style,update_layout_config>[(][ ]*[,] { this.begin("attribute"); return "ATTRIBUTE_EMPTY";}
<person,person_ext,system_ext_queue,system_ext_db,system_ext,system_queue,system_db,system,boundary,enterprise_boundary,system_boundary,container_ext_db,container_ext,container_queue,container_db,container,container_boundary,component_ext_db,component_ext,component_queue,component_db,component,node,node_l,node_r,rel,birel,rel_u,rel_d,rel_l,rel_r,rel_b,rel_index,update_el_style,update_rel_style,update_layout_config>[(] { this.begin("attribute"); }
<person,person_ext,system_ext_queue,system_ext_db,system_ext,system_queue,system_db,system,boundary,enterprise_boundary,system_boundary,container_ext_db,container_ext,container_queue,container_db,container,container_boundary,component_ext_db,component_ext,component_queue,component_db,component,node,node_l,node_r,rel,birel,rel_u,rel_d,rel_l,rel_r,rel_b,rel_index,update_el_style,update_rel_style,update_layout_config,attribute>[)] { this.popState();this.popState();}
<attribute>",," { return 'ATTRIBUTE_EMPTY';}
<attribute>"," { }
<attribute>[ ]*["]["] { return 'ATTRIBUTE_EMPTY';}
<attribute>[ ]*["] { this.begin("string");}
<string>["] { this.popState(); }
<string>[^"]* { return "STR";}
<attribute>[ ]*[\$] { this.begin("string_kv");}
<string_kv>[^=]* { this.begin("string_kv_key"); return "STR_KEY";}
<string_kv_key>[=][ ]*["] { this.popState(); this.begin("string_kv_value"); }
<string_kv_value>[^"]+ { return "STR_VALUE";}
<string_kv_value>["] { this.popState(); this.popState(); }
<attribute>[^,]+ { return "STR";}
'{' { /* this.begin("lbrace"); */ return "LBRACE";}
'}' { /* this.popState(); */ return "RBRACE";}
[\s]+ return 'SPACE';
[\n\r]+ return 'EOL';
<<EOF>> return 'EOF';
/lex
/* operator associations and precedence */
%left '^'
%start start
%% /* language grammar */
start
: mermaidDoc
| direction
| directive start
;
direction
: direction_tb
{ yy.setDirection('TB');}
| direction_bt
{ yy.setDirection('BT');}
| direction_rl
{ yy.setDirection('RL');}
| direction_lr
{ yy.setDirection('LR');}
;
mermaidDoc
: graphConfig
;
directive
: openDirective typeDirective closeDirective NEWLINE
| openDirective typeDirective ':' argDirective closeDirective NEWLINE
;
openDirective
: open_directive { yy.parseDirective('%%{', 'open_directive'); }
;
typeDirective
: type_directive { }
;
argDirective
: arg_directive { $1 = $1.trim().replace(/'/g, '"'); yy.parseDirective($1, 'arg_directive'); }
;
closeDirective
: close_directive { yy.parseDirective('}%%', 'close_directive', 'c4Context'); }
;
graphConfig
: C4_CONTEXT NEWLINE statements EOF {yy.setC4Type($1)}
| C4_CONTAINER NEWLINE statements EOF {yy.setC4Type($1)}
| C4_COMPONENT NEWLINE statements EOF {yy.setC4Type($1)}
| C4_DYNAMIC NEWLINE statements EOF {yy.setC4Type($1)}
| C4_DEPLOYMENT NEWLINE statements EOF {yy.setC4Type($1)}
;
statements
: otherStatements
| diagramStatements
| otherStatements diagramStatements
;
otherStatements
: otherStatement
| otherStatement NEWLINE
| otherStatement NEWLINE otherStatements
;
otherStatement
: title {yy.setTitle($1.substring(6));$$=$1.substring(6);}
| accDescription {yy.setAccDescription($1.substring(15));$$=$1.substring(15);}
| acc_title acc_title_value { $$=$2.trim();yy.setTitle($$); }
| acc_descr acc_descr_value { $$=$2.trim();yy.setAccDescription($$); }
| acc_descr_multiline_value { $$=$1.trim();yy.setAccDescription($$); }
;
boundaryStatement
: boundaryStartStatement diagramStatements boundaryStopStatement
;
boundaryStartStatement
: boundaryStart LBRACE NEWLINE
| boundaryStart NEWLINE LBRACE
| boundaryStart NEWLINE LBRACE NEWLINE
;
boundaryStart
: ENTERPRISE_BOUNDARY attributes {$2.splice(2, 0, 'ENTERPRISE'); yy.addPersonOrSystemBoundary(...$2); $$=$2;}
| SYSTEM_BOUNDARY attributes {$2.splice(2, 0, 'ENTERPRISE'); yy.addPersonOrSystemBoundary(...$2); $$=$2;}
| BOUNDARY attributes {yy.addPersonOrSystemBoundary(...$2); $$=$2;}
| CONTAINER_BOUNDARY attributes {$2.splice(2, 0, 'CONTAINER'); yy.addContainerBoundary(...$2); $$=$2;}
| NODE attributes {yy.addDeploymentNode('node', ...$2); $$=$2;}
| NODE_L attributes {yy.addDeploymentNode('nodeL', ...$2); $$=$2;}
| NODE_R attributes {yy.addDeploymentNode('nodeR', ...$2); $$=$2;}
;
boundaryStopStatement
: RBRACE { yy.popBoundaryParseStack() }
;
diagramStatements
: diagramStatement
| diagramStatement NEWLINE
| diagramStatement NEWLINE statements
;
diagramStatement
: PERSON attributes {yy.addPersonOrSystem('person', ...$2); $$=$2;}
| PERSON_EXT attributes {yy.addPersonOrSystem('external_person', ...$2); $$=$2;}
| SYSTEM attributes {yy.addPersonOrSystem('system', ...$2); $$=$2;}
| SYSTEM_DB attributes {yy.addPersonOrSystem('system_db', ...$2); $$=$2;}
| SYSTEM_QUEUE attributes {yy.addPersonOrSystem('system_queue', ...$2); $$=$2;}
| SYSTEM_EXT attributes {yy.addPersonOrSystem('external_system', ...$2); $$=$2;}
| SYSTEM_EXT_DB attributes {yy.addPersonOrSystem('external_system_db', ...$2); $$=$2;}
| SYSTEM_EXT_QUEUE attributes {yy.addPersonOrSystem('external_system_queue', ...$2); $$=$2;}
| CONTAINER attributes {yy.addContainer('container', ...$2); $$=$2;}
| CONTAINER_DB attributes {yy.addContainer('container_db', ...$2); $$=$2;}
| CONTAINER_QUEUE attributes {yy.addContainer('container_queue', ...$2); $$=$2;}
| CONTAINER_EXT attributes {yy.addContainer('external_container', ...$2); $$=$2;}
| CONTAINER_EXT_DB attributes {yy.addContainer('external_container_db', ...$2); $$=$2;}
| CONTAINER_EXT_QUEUE attributes {yy.addContainer('external_container_queue', ...$2); $$=$2;}
| COMPONENT attributes {yy.addComponent('component', ...$2); $$=$2;}
| COMPONENT_DB attributes {yy.addComponent('component_db', ...$2); $$=$2;}
| COMPONENT_QUEUE attributes {yy.addComponent('component_queue', ...$2); $$=$2;}
| COMPONENT_EXT attributes {yy.addComponent('external_component', ...$2); $$=$2;}
| COMPONENT_EXT_DB attributes {yy.addComponent('external_component_db', ...$2); $$=$2;}
| COMPONENT_EXT_QUEUE attributes {yy.addComponent('external_component_queue', ...$2); $$=$2;}
| boundaryStatement
| REL attributes {yy.addRel('rel', ...$2); $$=$2;}
| BIREL attributes {yy.addRel('birel', ...$2); $$=$2;}
| REL_U attributes {yy.addRel('rel_u', ...$2); $$=$2;}
| REL_D attributes {yy.addRel('rel_d', ...$2); $$=$2;}
| REL_L attributes {yy.addRel('rel_l', ...$2); $$=$2;}
| REL_R attributes {yy.addRel('rel_r', ...$2); $$=$2;}
| REL_B attributes {yy.addRel('rel_b', ...$2); $$=$2;}
| REL_INDEX attributes {$2.splice(0, 1); yy.addRel('rel', ...$2); $$=$2;}
| UPDATE_EL_STYLE attributes {yy.updateElStyle('update_el_style', ...$2); $$=$2;}
| UPDATE_REL_STYLE attributes {yy.updateRelStyle('update_rel_style', ...$2); $$=$2;}
| UPDATE_LAYOUT_CONFIG attributes {yy.updateLayoutConfig('update_layout_config', ...$2); $$=$2;}
;
attributes
: attribute { $$ = [$1]; }
| attribute attributes { $2.unshift($1); $$=$2;}
;
attribute
: STR { $$ = $1.trim(); }
| STR_KEY STR_VALUE { let kv={}; kv[$1.trim()]=$2.trim(); $$=kv; }
| ATTRIBUTE { $$ = $1.trim(); }
| ATTRIBUTE_EMPTY { $$ = ""; }
;

View File

@@ -0,0 +1,55 @@
import c4Db from '../c4Db';
import c4 from './c4Diagram.jison';
import { setConfig } from '../../../config';
setConfig({
securityLevel: 'strict',
});
describe('parsing a C4 diagram', function () {
beforeEach(function () {
c4.parser.yy = c4Db;
c4.parser.yy.clear();
});
it('should handle a trailing whitespaces after statements', function () {
const whitespace = ' ';
const rendered = c4.parser.parse(`C4Context${whitespace}
title System Context diagram for Internet Banking System${whitespace}
Person(customerA, "Banking Customer A", "A customer of the bank, with personal bank accounts.")${whitespace}`);
expect(rendered).toBe(true);
});
it('should handle parameter names that are keywords', function () {
c4.parser.parse(`C4Context
title title
Person(Person, "Person", "Person")`);
const yy = c4.parser.yy;
expect(yy.getTitle()).toBe('title');
const shapes = yy.getC4ShapeArray();
expect(shapes.length).toBe(1);
const onlyShape = shapes[0];
expect(onlyShape.alias).toBe('Person');
expect(onlyShape.descr.text).toBe('Person');
expect(onlyShape.label.text).toBe('Person');
});
it('should allow default in the parameters', function () {
c4.parser.parse(`C4Context
Person(default, "default", "default")`);
const yy = c4.parser.yy;
const shapes = yy.getC4ShapeArray();
expect(shapes.length).toBe(1);
const onlyShape = shapes[0];
expect(onlyShape.alias).toBe('default');
expect(onlyShape.descr.text).toBe('default');
expect(onlyShape.label.text).toBe('default');
});
});

View File

@@ -0,0 +1,111 @@
import c4Db from '../c4Db';
import c4 from './c4Diagram.jison';
import { setConfig } from '../../../config';
setConfig({
securityLevel: 'strict',
});
describe('parsing a C4 Person', function () {
beforeEach(function () {
c4.parser.yy = c4Db;
c4.parser.yy.clear();
});
it('should parse a C4 diagram with one Person correctly', function () {
c4.parser.parse(`C4Context
title System Context diagram for Internet Banking System
Person(customerA, "Banking Customer A", "A customer of the bank, with personal bank accounts.")`);
const yy = c4.parser.yy;
const shapes = yy.getC4ShapeArray();
expect(shapes.length).toBe(1);
const onlyShape = shapes[0];
expect(onlyShape).toEqual({
alias: 'customerA',
descr: {
text: 'A customer of the bank, with personal bank accounts.',
},
label: {
text: 'Banking Customer A',
},
parentBoundary: 'global',
typeC4Shape: {
text: 'person',
},
wrap: false,
});
});
it('should parse the alias', function () {
c4.parser.parse(`C4Context
Person(customerA, "Banking Customer A")`);
expect(c4.parser.yy.getC4ShapeArray()[0]).toMatchObject({
alias: 'customerA',
});
});
it('should parse the label', function () {
c4.parser.parse(`C4Context
Person(customerA, "Banking Customer A")`);
expect(c4.parser.yy.getC4ShapeArray()[0]).toMatchObject({
label: {
text: 'Banking Customer A',
},
});
});
it('should parse the description', function () {
c4.parser.parse(`C4Context
Person(customerA, "", "A customer of the bank, with personal bank accounts.")`);
expect(c4.parser.yy.getC4ShapeArray()[0]).toMatchObject({
descr: {
text: 'A customer of the bank, with personal bank accounts.',
},
});
});
it('should parse a sprite', function () {
c4.parser.parse(`C4Context
Person(customerA, $sprite="users")`);
expect(c4.parser.yy.getC4ShapeArray()[0]).toMatchObject({
label: {
text: {
sprite: 'users',
},
},
});
});
it('should parse a link', function () {
c4.parser.parse(`C4Context
Person(customerA, $link="https://github.com/mermaidjs")`);
expect(c4.parser.yy.getC4ShapeArray()[0]).toMatchObject({
label: {
text: {
link: 'https://github.com/mermaidjs',
},
},
});
});
it('should parse tags', function () {
c4.parser.parse(`C4Context
Person(customerA, $tags="tag1,tag2")`);
expect(c4.parser.yy.getC4ShapeArray()[0]).toMatchObject({
label: {
text: {
tags: 'tag1,tag2',
},
},
});
});
});

View File

@@ -0,0 +1,116 @@
import c4Db from '../c4Db';
import c4 from './c4Diagram.jison';
import { setConfig } from '../../../config';
setConfig({
securityLevel: 'strict',
});
describe('parsing a C4 Person_Ext', function () {
beforeEach(function () {
c4.parser.yy = c4Db;
c4.parser.yy.clear();
});
it('should parse a C4 diagram with one Person_Ext correctly', function () {
c4.parser.parse(`C4Context
title System Context diagram for Internet Banking System
Person_Ext(customerA, "Banking Customer A", "A customer of the bank, with personal bank accounts.")`);
const yy = c4.parser.yy;
const shapes = yy.getC4ShapeArray();
expect(shapes.length).toBe(1);
const onlyShape = shapes[0];
expect(onlyShape).toEqual({
alias: 'customerA',
descr: {
text: 'A customer of the bank, with personal bank accounts.',
},
label: {
text: 'Banking Customer A',
},
// TODO: Why are link, sprite, and tags undefined instead of not appearing at all?
// Compare to Person where they don't show up.
link: undefined,
sprite: undefined,
tags: undefined,
parentBoundary: 'global',
typeC4Shape: {
text: 'external_person',
},
wrap: false,
});
});
it('should parse the alias', function () {
c4.parser.parse(`C4Context
Person_Ext(customerA, "Banking Customer A")`);
expect(c4.parser.yy.getC4ShapeArray()[0]).toMatchObject({
alias: 'customerA',
});
});
it('should parse the label', function () {
c4.parser.parse(`C4Context
Person_Ext(customerA, "Banking Customer A")`);
expect(c4.parser.yy.getC4ShapeArray()[0]).toMatchObject({
label: {
text: 'Banking Customer A',
},
});
});
it('should parse the description', function () {
c4.parser.parse(`C4Context
Person_Ext(customerA, "", "A customer of the bank, with personal bank accounts.")`);
expect(c4.parser.yy.getC4ShapeArray()[0]).toMatchObject({
descr: {
text: 'A customer of the bank, with personal bank accounts.',
},
});
});
it('should parse a sprite', function () {
c4.parser.parse(`C4Context
Person_Ext(customerA, $sprite="users")`);
expect(c4.parser.yy.getC4ShapeArray()[0]).toMatchObject({
label: {
text: {
sprite: 'users',
},
},
});
});
it('should parse a link', function () {
c4.parser.parse(`C4Context
Person_Ext(customerA, $link="https://github.com/mermaidjs")`);
expect(c4.parser.yy.getC4ShapeArray()[0]).toMatchObject({
label: {
text: {
link: 'https://github.com/mermaidjs',
},
},
});
});
it('should parse tags', function () {
c4.parser.parse(`C4Context
Person_Ext(customerA, $tags="tag1,tag2")`);
expect(c4.parser.yy.getC4ShapeArray()[0]).toMatchObject({
label: {
text: {
tags: 'tag1,tag2',
},
},
});
});
});

View File

@@ -0,0 +1,123 @@
import c4Db from '../c4Db';
import c4 from './c4Diagram.jison';
import { setConfig } from '../../../config';
setConfig({
securityLevel: 'strict',
});
describe.each([
['System', 'system'],
['SystemDb', 'system_db'],
['SystemQueue', 'system_queue'],
['System_Ext', 'external_system'],
['SystemDb_Ext', 'external_system_db'],
['SystemQueue_Ext', 'external_system_queue'],
])('parsing a C4 %s', function (macroName, elementName) {
beforeEach(function () {
c4.parser.yy = c4Db;
c4.parser.yy.clear();
});
it('should parse a C4 diagram with one System correctly', function () {
c4.parser.parse(`C4Context
title System Context diagram for Internet Banking System
${macroName}(SystemAA, "Internet Banking System", "Allows customers to view information about their bank accounts, and make payments.")`);
const yy = c4.parser.yy;
const shapes = yy.getC4ShapeArray();
expect(shapes.length).toBe(1);
const onlyShape = shapes[0];
expect(onlyShape).toEqual({
alias: 'SystemAA',
descr: {
text: 'Allows customers to view information about their bank accounts, and make payments.',
},
label: {
text: 'Internet Banking System',
},
// TODO: Why are link, sprite, and tags undefined instead of not appearing at all?
// Compare to Person where they don't show up.
link: undefined,
sprite: undefined,
tags: undefined,
parentBoundary: 'global',
typeC4Shape: {
text: elementName,
},
wrap: false,
});
});
it('should parse the alias', function () {
c4.parser.parse(`C4Context
${macroName}(SystemAA, "Internet Banking System")`);
expect(c4.parser.yy.getC4ShapeArray()[0]).toMatchObject({
alias: 'SystemAA',
});
});
it('should parse the label', function () {
c4.parser.parse(`C4Context
${macroName}(SystemAA, "Internet Banking System")`);
expect(c4.parser.yy.getC4ShapeArray()[0]).toMatchObject({
label: {
text: 'Internet Banking System',
},
});
});
it('should parse the description', function () {
c4.parser.parse(`C4Context
${macroName}(SystemAA, "", "Allows customers to view information about their bank accounts, and make payments.")`);
expect(c4.parser.yy.getC4ShapeArray()[0]).toMatchObject({
descr: {
text: 'Allows customers to view information about their bank accounts, and make payments.',
},
});
});
it('should parse a sprite', function () {
c4.parser.parse(`C4Context
${macroName}(SystemAA, $sprite="users")`);
expect(c4.parser.yy.getC4ShapeArray()[0]).toMatchObject({
label: {
text: {
sprite: 'users',
},
},
});
});
it('should parse a link', function () {
c4.parser.parse(`C4Context
${macroName}(SystemAA, $link="https://github.com/mermaidjs")`);
expect(c4.parser.yy.getC4ShapeArray()[0]).toMatchObject({
label: {
text: {
link: 'https://github.com/mermaidjs',
},
},
});
});
it('should parse tags', function () {
c4.parser.parse(`C4Context
${macroName}(SystemAA, $tags="tag1,tag2")`);
expect(c4.parser.yy.getC4ShapeArray()[0]).toMatchObject({
label: {
text: {
tags: 'tag1,tag2',
},
},
});
});
});

View File

@@ -0,0 +1,8 @@
const getStyles = (options) =>
`.person {
stroke: ${options.personBorder};
fill: ${options.personBkg};
}
`;
export default getStyles;

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,388 @@
import { select } from 'd3';
import { log } from '../../logger';
import * as configApi from '../../config';
import common from '../common/common';
import utils from '../../utils';
import mermaidAPI from '../../mermaidAPI';
import {
setAccTitle,
getAccTitle,
getAccDescription,
setAccDescription,
clear as commonClear,
} from '../../commonDb';
const MERMAID_DOM_ID_PREFIX = 'classid-';
let relations = [];
let classes = {};
let classCounter = 0;
let funs = [];
const sanitizeText = (txt) => common.sanitizeText(txt, configApi.getConfig());
export const parseDirective = function (statement, context, type) {
mermaidAPI.parseDirective(this, statement, context, type);
};
const splitClassNameAndType = function (id) {
let genericType = '';
let className = id;
if (id.indexOf('~') > 0) {
let split = id.split('~');
className = split[0];
genericType = common.sanitizeText(split[1], configApi.getConfig());
}
return { className: className, type: genericType };
};
/**
* Function called by parser when a node definition has been found.
*
* @param id
* @public
*/
export const addClass = function (id) {
let classId = splitClassNameAndType(id);
// Only add class if not exists
if (typeof classes[classId.className] !== 'undefined') return;
classes[classId.className] = {
id: classId.className,
type: classId.type,
cssClasses: [],
methods: [],
members: [],
annotations: [],
domId: MERMAID_DOM_ID_PREFIX + classId.className + '-' + classCounter,
};
classCounter++;
};
/**
* Function to lookup domId from id in the graph definition.
*
* @param id
* @public
*/
export const lookUpDomId = function (id) {
const classKeys = Object.keys(classes);
for (let i = 0; i < classKeys.length; i++) {
if (classes[classKeys[i]].id === id) {
return classes[classKeys[i]].domId;
}
}
};
export const clear = function () {
relations = [];
classes = {};
funs = [];
funs.push(setupToolTips);
commonClear();
};
export const getClass = function (id) {
return classes[id];
};
export const getClasses = function () {
return classes;
};
export const getRelations = function () {
return relations;
};
export const addRelation = function (relation) {
log.debug('Adding relation: ' + JSON.stringify(relation));
addClass(relation.id1);
addClass(relation.id2);
relation.id1 = splitClassNameAndType(relation.id1).className;
relation.id2 = splitClassNameAndType(relation.id2).className;
relation.relationTitle1 = common.sanitizeText(
relation.relationTitle1.trim(),
configApi.getConfig()
);
relation.relationTitle2 = common.sanitizeText(
relation.relationTitle2.trim(),
configApi.getConfig()
);
relations.push(relation);
};
/**
* Adds an annotation to the specified class Annotations mark special properties of the given type
* (like 'interface' or 'service')
*
* @param className The class name
* @param annotation The name of the annotation without any brackets
* @public
*/
export const addAnnotation = function (className, annotation) {
const validatedClassName = splitClassNameAndType(className).className;
classes[validatedClassName].annotations.push(annotation);
};
/**
* Adds a member to the specified class
*
* @param className The class name
* @param member The full name of the member. If the member is enclosed in <<brackets>> it is
* treated as an annotation If the member is ending with a closing bracket ) it is treated as a
* method Otherwise the member will be treated as a normal property
* @public
*/
export const addMember = function (className, member) {
const validatedClassName = splitClassNameAndType(className).className;
const theClass = classes[validatedClassName];
if (typeof member === 'string') {
// Member can contain white spaces, we trim them out
const memberString = member.trim();
if (memberString.startsWith('<<') && memberString.endsWith('>>')) {
// Remove leading and trailing brackets
// theClass.annotations.push(memberString.substring(2, memberString.length - 2));
theClass.annotations.push(sanitizeText(memberString.substring(2, memberString.length - 2)));
} else if (memberString.indexOf(')') > 0) {
theClass.methods.push(sanitizeText(memberString));
} else if (memberString) {
theClass.members.push(sanitizeText(memberString));
}
}
};
export const addMembers = function (className, members) {
if (Array.isArray(members)) {
members.reverse();
members.forEach((member) => addMember(className, member));
}
};
export const cleanupLabel = function (label) {
if (label.substring(0, 1) === ':') {
return common.sanitizeText(label.substr(1).trim(), configApi.getConfig());
} else {
return sanitizeText(label.trim());
}
};
/**
* Called by parser when a special node is found, e.g. a clickable element.
*
* @param ids Comma separated list of ids
* @param className Class to add
*/
export const setCssClass = function (ids, className) {
ids.split(',').forEach(function (_id) {
let id = _id;
if (_id[0].match(/\d/)) id = MERMAID_DOM_ID_PREFIX + id;
if (typeof classes[id] !== 'undefined') {
classes[id].cssClasses.push(className);
}
});
};
/**
* Called by parser when a tooltip is found, e.g. a clickable element.
*
* @param ids Comma separated list of ids
* @param tooltip Tooltip to add
*/
const setTooltip = function (ids, tooltip) {
const config = configApi.getConfig();
ids.split(',').forEach(function (id) {
if (typeof tooltip !== 'undefined') {
classes[id].tooltip = common.sanitizeText(tooltip, config);
}
});
};
export const getTooltip = function (id) {
return classes[id].tooltip;
};
/**
* Called by parser when a link is found. Adds the URL to the vertex data.
*
* @param ids Comma separated list of ids
* @param linkStr URL to create a link for
* @param target Target of the link, _blank by default as originally defined in the svgDraw.js file
*/
export const setLink = function (ids, linkStr, target) {
const config = configApi.getConfig();
ids.split(',').forEach(function (_id) {
let id = _id;
if (_id[0].match(/\d/)) id = MERMAID_DOM_ID_PREFIX + id;
if (typeof classes[id] !== 'undefined') {
classes[id].link = utils.formatUrl(linkStr, config);
if (config.securityLevel === 'sandbox') {
classes[id].linkTarget = '_top';
} else if (typeof target === 'string') {
classes[id].linkTarget = sanitizeText(target);
} else {
classes[id].linkTarget = '_blank';
}
}
});
setCssClass(ids, 'clickable');
};
/**
* Called by parser when a click definition is found. Registers an event handler.
*
* @param ids Comma separated list of ids
* @param functionName Function to be called on click
* @param functionArgs Function args the function should be called with
*/
export const setClickEvent = function (ids, functionName, functionArgs) {
ids.split(',').forEach(function (id) {
setClickFunc(id, functionName, functionArgs);
classes[id].haveCallback = true;
});
setCssClass(ids, 'clickable');
};
const setClickFunc = function (domId, functionName, functionArgs) {
const config = configApi.getConfig();
let id = domId;
let elemId = lookUpDomId(id);
if (config.securityLevel !== 'loose') {
return;
}
if (typeof functionName === 'undefined') {
return;
}
if (typeof classes[id] !== 'undefined') {
let argList = [];
if (typeof functionArgs === 'string') {
/* Splits functionArgs by ',', ignoring all ',' in double quoted strings */
argList = functionArgs.split(/,(?=(?:(?:[^"]*"){2})*[^"]*$)/);
for (let i = 0; i < argList.length; i++) {
let item = argList[i].trim();
/* Removes all double quotes at the start and end of an argument */
/* This preserves all starting and ending whitespace inside */
if (item.charAt(0) === '"' && item.charAt(item.length - 1) === '"') {
item = item.substr(1, item.length - 2);
}
argList[i] = item;
}
}
/* if no arguments passed into callback, default to passing in id */
if (argList.length === 0) {
argList.push(elemId);
}
funs.push(function () {
const elem = document.querySelector(`[id="${elemId}"]`);
if (elem !== null) {
elem.addEventListener(
'click',
function () {
utils.runFunc(functionName, ...argList);
},
false
);
}
});
}
};
export const bindFunctions = function (element) {
funs.forEach(function (fun) {
fun(element);
});
};
export const lineType = {
LINE: 0,
DOTTED_LINE: 1,
};
export const relationType = {
AGGREGATION: 0,
EXTENSION: 1,
COMPOSITION: 2,
DEPENDENCY: 3,
LOLLIPOP: 4,
};
const setupToolTips = function (element) {
let tooltipElem = select('.mermaidTooltip');
if ((tooltipElem._groups || tooltipElem)[0][0] === null) {
tooltipElem = select('body').append('div').attr('class', 'mermaidTooltip').style('opacity', 0);
}
const svg = select(element).select('svg');
const nodes = svg.selectAll('g.node');
nodes
.on('mouseover', function () {
const el = select(this);
const title = el.attr('title');
// Dont try to draw a tooltip if no data is provided
if (title === null) {
return;
}
const rect = this.getBoundingClientRect();
tooltipElem.transition().duration(200).style('opacity', '.9');
tooltipElem
.text(el.attr('title'))
.style('left', window.scrollX + rect.left + (rect.right - rect.left) / 2 + 'px')
.style('top', window.scrollY + rect.top - 14 + document.body.scrollTop + 'px');
tooltipElem.html(tooltipElem.html().replace(/&lt;br\/&gt;/g, '<br/>'));
el.classed('hover', true);
})
.on('mouseout', function () {
tooltipElem.transition().duration(500).style('opacity', 0);
const el = select(this);
el.classed('hover', false);
});
};
funs.push(setupToolTips);
let direction = 'TB';
const getDirection = () => direction;
const setDirection = (dir) => {
direction = dir;
};
export default {
parseDirective,
setAccTitle,
getAccTitle,
getAccDescription,
setAccDescription,
getConfig: () => configApi.getConfig().class,
addClass,
bindFunctions,
clear,
getClass,
getClasses,
addAnnotation,
getRelations,
addRelation,
getDirection,
setDirection,
addMember,
addMembers,
cleanupLabel,
lineType,
relationType,
setClickEvent,
setCssClass,
setLink,
getTooltip,
setTooltip,
lookUpDomId,
};

View File

@@ -0,0 +1,9 @@
import type { DiagramDetector } from '../../diagram-api/detectType';
export const classDetectorV2: DiagramDetector = (txt, config) => {
// If we have confgured to use dagre-wrapper then we should return true in this function for classDiagram code thus making it use the new class diagram
if (txt.match(/^\s*classDiagram/) !== null && config?.class?.defaultRenderer === 'dagre-wrapper')
return true;
// We have not opted to use the new renderer so we should return true if we detect a class diagram
return txt.match(/^\s*classDiagram-v2/) !== null;
};

View File

@@ -0,0 +1,8 @@
import type { DiagramDetector } from '../../diagram-api/detectType';
export const classDetector: DiagramDetector = (txt, config) => {
// If we have confgured to use dagre-wrapper then we should never return true in this function
if (config?.class?.defaultRenderer === 'dagre-wrapper') return false;
// We have not opted to use the new renderer so we should return true if we detect a class diagram
return txt.match(/^\s*classDiagram/) !== null;
};

View File

@@ -0,0 +1,60 @@
import { parser } from './parser/classDiagram';
import classDb from './classDb';
describe('class diagram, ', function () {
describe('when parsing data from a classDiagram it', function () {
beforeEach(function () {
parser.yy = classDb;
parser.yy.clear();
});
it('should be possible to apply a css class to a class directly', function () {
const str = 'classDiagram\n' + 'class Class01:::exClass';
parser.parse(str);
expect(parser.yy.getClass('Class01').cssClasses[0]).toBe('exClass');
});
it('should be possible to apply a css class to a class directly with struct', function () {
const str =
'classDiagram\n' +
'class Class1:::exClass {\n' +
'int : test\n' +
'string : foo\n' +
'test()\n' +
'foo()\n' +
'}';
parser.parse(str);
const testClass = parser.yy.getClass('Class1');
expect(testClass.cssClasses[0]).toBe('exClass');
});
it('should be possible to apply a css class to a class with relations', function () {
const str = 'classDiagram\n' + 'Class01 <|-- Class02\ncssClass "Class01" exClass';
parser.parse(str);
expect(parser.yy.getClass('Class01').cssClasses[0]).toBe('exClass');
});
it('should be possible to apply a cssClass to a class', function () {
const str = 'classDiagram\n' + 'class Class01\n cssClass "Class01" exClass';
parser.parse(str);
expect(parser.yy.getClass('Class01').cssClasses[0]).toBe('exClass');
});
it('should be possible to apply a cssClass to a comma separated list of classes', function () {
const str =
'classDiagram\n' + 'class Class01\n class Class02\n cssClass "Class01,Class02" exClass';
parser.parse(str);
expect(parser.yy.getClass('Class01').cssClasses[0]).toBe('exClass');
expect(parser.yy.getClass('Class02').cssClasses[0]).toBe('exClass');
});
});
});

View File

@@ -0,0 +1,939 @@
import { parser } from './parser/classDiagram';
import classDb from './classDb';
import { vi } from 'vitest';
const spyOn = vi.spyOn;
describe('class diagram, ', function () {
describe('when parsing an info graph it', function () {
beforeEach(function () {
parser.yy = classDb;
});
it('should handle backquoted class names', function () {
const str = 'classDiagram\n' + 'class `Car`';
parser.parse(str);
});
it.skip('should handle a leading newline axa', function () {
const str = '\nclassDiagram\n' + 'class Car';
try {
parser.parse(str);
// Fail test if above expression doesn't throw anything.
} catch (e) {
expect(true).toBe(false);
}
});
it('should handle relation definitions', function () {
const str =
'classDiagram\n' +
'Class01 <|-- Class02\n' +
'Class03 *-- Class04\n' +
'Class05 o-- Class06\n' +
'Class07 .. Class08\n' +
'Class09 -- Class1';
parser.parse(str);
});
it('should handle backquoted relation definitions', function () {
const str =
'classDiagram\n' +
'`Class01` <|-- Class02\n' +
'Class03 *-- Class04\n' +
'Class05 o-- Class06\n' +
'Class07 .. Class08\n' +
'Class09 -- Class1';
parser.parse(str);
});
it('should handle relation definition of different types and directions', function () {
const str =
'classDiagram\n' +
'Class11 <|.. Class12\n' +
'Class13 --> Class14\n' +
'Class15 ..> Class16\n' +
'Class17 ..|> Class18\n' +
'Class19 <--* Class20';
parser.parse(str);
});
it('should handle cardinality and labels', function () {
const str =
'classDiagram\n' +
'Class01 "1" *-- "many" Class02 : contains\n' +
'Class03 o-- Class04 : aggregation\n' +
'Class05 --> "1" Class06';
parser.parse(str);
});
it('should handle visibility for methods and members', function () {
const str =
'classDiagram\n' +
'class TestClass\n' +
'TestClass : -int privateMember\n' +
'TestClass : +int publicMember\n' +
'TestClass : #int protectedMember\n' +
'TestClass : -privateMethod()\n' +
'TestClass : +publicMethod()\n' +
'TestClass : #protectedMethod()\n';
parser.parse(str);
});
it('should handle generic class', function () {
const str =
'classDiagram\n' +
'class Car~T~\n' +
'Driver -- Car : drives >\n' +
'Car *-- Wheel : have 4 >\n' +
'Car -- Person : < owns';
parser.parse(str);
});
it('should handle generic class with a literal name', function () {
const str =
'classDiagram\n' +
'class `Car`~T~\n' +
'Driver -- `Car` : drives >\n' +
'`Car` *-- Wheel : have 4 >\n' +
'`Car` -- Person : < owns';
parser.parse(str);
});
it('should break when another `{`is encountered before closing the first one while defining generic class with brackets', function () {
const str =
'classDiagram\n' +
'class Dummy_Class~T~ {\n' +
'String data\n' +
' void methods()\n' +
'}\n' +
'\n' +
'class Dummy_Class {\n' +
'class Flight {\n' +
' flightNumber : Integer\n' +
' departureTime : Date\n' +
'}';
let testPassed = false;
try {
parser.parse(str);
} catch (error) {
testPassed = true;
}
expect(testPassed).toBe(true);
});
it('should break when EOF is encountered before closing the first `{` while defining generic class with brackets', function () {
const str =
'classDiagram\n' +
'class Dummy_Class~T~ {\n' +
'String data\n' +
' void methods()\n' +
'}\n' +
'\n' +
'class Dummy_Class {\n';
let testPassed = false;
try {
parser.parse(str);
} catch (error) {
testPassed = true;
}
expect(testPassed).toBe(true);
});
it('should handle generic class with brackets', function () {
const str =
'classDiagram\n' +
'class Dummy_Class~T~ {\n' +
'String data\n' +
' void methods()\n' +
'}\n' +
'\n' +
'class Flight {\n' +
' flightNumber : Integer\n' +
' departureTime : Date\n' +
'}';
parser.parse(str);
});
it('should handle generic class with brackets and a literal name', function () {
const str =
'classDiagram\n' +
'class `Dummy_Class`~T~ {\n' +
'String data\n' +
' void methods()\n' +
'}\n' +
'\n' +
'class Flight {\n' +
' flightNumber : Integer\n' +
' departureTime : Date\n' +
'}';
parser.parse(str);
});
it('should handle class definitions', function () {
const str =
'classDiagram\n' +
'class Car\n' +
'Driver -- Car : drives >\n' +
'Car *-- Wheel : have 4 >\n' +
'Car -- Person : < owns';
parser.parse(str);
});
it('should handle method statements', function () {
const str =
'classDiagram\n' +
'Object <|-- ArrayList\n' +
'Object : equals()\n' +
'ArrayList : Object[] elementData\n' +
'ArrayList : size()';
parser.parse(str);
});
it('should handle parsing of method statements grouped by brackets', function () {
const str =
'classDiagram\n' +
'class Dummy_Class {\n' +
'String data\n' +
' void methods()\n' +
'}\n' +
'\n' +
'class Flight {\n' +
' flightNumber : Integer\n' +
' departureTime : Date\n' +
'}';
parser.parse(str);
});
it('should handle return types on methods', function () {
const str =
'classDiagram\n' +
'Object <|-- ArrayList\n' +
'Object : equals()\n' +
'Object : -Object[] objects\n' +
'Object : +getObjects() Object[]\n' +
'ArrayList : Dummy elementData\n' +
'ArrayList : getDummy() Dummy';
parser.parse(str);
});
it('should handle return types on methods grouped by brackets', function () {
const str =
'classDiagram\n' +
'class Dummy_Class {\n' +
'string data\n' +
'getDummy() Dummy\n' +
'}\n' +
'\n' +
'class Flight {\n' +
' int flightNumber\n' +
' datetime departureTime\n' +
' getDepartureTime() datetime\n' +
'}';
parser.parse(str);
});
it('should handle parsing of separators', function () {
const str =
'classDiagram\n' +
'class Foo1 {\n' +
' You can use\n' +
' several lines\n' +
'..\n' +
'as you want\n' +
'and group\n' +
'==\n' +
'things together.\n' +
'__\n' +
'You can have as many groups\n' +
'as you want\n' +
'--\n' +
'End of class\n' +
'}\n' +
'\n' +
'class User {\n' +
'.. Simple Getter ..\n' +
'+ getName()\n' +
'+ getAddress()\n' +
'.. Some setter ..\n' +
'+ setName()\n' +
'__ private data __\n' +
'int age\n' +
'-- encrypted --\n' +
'String password\n' +
'}';
parser.parse(str);
});
it('should handle a comment', function () {
const str =
'classDiagram\n' +
'class Class1 {\n' +
'%% Comment\n' +
'int : test\n' +
'string : foo\n' +
'test()\n' +
'foo()\n' +
'}';
parser.parse(str);
});
it('should handle comments at the start', function () {
const str = `%% Comment
classDiagram
class Class1 {
int : test
string : foo
test()
foo()
}`;
parser.parse(str);
});
it('should handle comments at the end', function () {
const str = `classDiagram
class Class1 {
int : test
string : foo
test()
foo()
}
%% Comment
`;
parser.parse(str);
});
it('should handle comments at the end no trailing newline', function () {
const str = `classDiagram
class Class1 {
int : test
string : foo
test()
foo()
}
%% Comment`;
parser.parse(str);
});
it('should handle a comment with multiple line feeds', function () {
const str = `classDiagram
%% Comment
class Class1 {
int : test
string : foo
test()
foo()
}`;
parser.parse(str);
});
it('should handle a comment with mermaid class diagram code in them', function () {
const str = `classDiagram
%% Comment Class01 <|-- Class02
class Class1 {
int : test
string : foo
test()
foo()
}`;
parser.parse(str);
});
it('should handle a comment inside brackets', function () {
const str =
'classDiagram\n' +
'class Class1 {\n' +
'%% Comment Class01 <|-- Class02\n' +
'int : test\n' +
'string : foo\n' +
'test()\n' +
'foo()\n' +
'}';
parser.parse(str);
});
it('should handle click statement with link', function () {
const str =
'classDiagram\n' +
'class Class1 {\n' +
'%% Comment Class01 <|-- Class02\n' +
'int : test\n' +
'string : foo\n' +
'test()\n' +
'foo()\n' +
'}\n' +
'link Class01 "google.com" ';
parser.parse(str);
});
it('should handle click statement with click and href link', function () {
const str =
'classDiagram\n' +
'class Class1 {\n' +
'%% Comment Class01 <|-- Class02\n' +
'int : test\n' +
'string : foo\n' +
'test()\n' +
'foo()\n' +
'}\n' +
'click Class01 href "google.com" ';
parser.parse(str);
});
it('should handle click statement with link and tooltip', function () {
const str =
'classDiagram\n' +
'class Class1 {\n' +
'%% Comment Class01 <|-- Class02\n' +
'int : test\n' +
'string : foo\n' +
'test()\n' +
'foo()\n' +
'}\n' +
'link Class01 "google.com" "A Tooltip" ';
parser.parse(str);
});
it('should handle click statement with click and href link and tooltip', function () {
const str =
'classDiagram\n' +
'class Class1 {\n' +
'%% Comment Class01 <|-- Class02\n' +
'int : test\n' +
'string : foo\n' +
'test()\n' +
'foo()\n' +
'}\n' +
'click Class01 href "google.com" "A Tooltip" ';
parser.parse(str);
});
it('should handle click statement with callback', function () {
const str =
'classDiagram\n' +
'class Class1 {\n' +
'%% Comment Class01 <|-- Class02\n' +
'int : test\n' +
'string : foo\n' +
'test()\n' +
'foo()\n' +
'}\n' +
'callback Class01 "functionCall" ';
parser.parse(str);
});
it('should handle click statement with click and call callback', function () {
const str =
'classDiagram\n' +
'class Class1 {\n' +
'%% Comment Class01 <|-- Class02\n' +
'int : test\n' +
'string : foo\n' +
'test()\n' +
'foo()\n' +
'}\n' +
'click Class01 call functionCall() ';
parser.parse(str);
});
it('should handle click statement with callback and tooltip', function () {
const str =
'classDiagram\n' +
'class Class1 {\n' +
'%% Comment Class01 <|-- Class02\n' +
'int : test\n' +
'string : foo\n' +
'test()\n' +
'foo()\n' +
'}\n' +
'callback Class01 "functionCall" "A Tooltip" ';
parser.parse(str);
});
it('should handle click statement with click and call callback and tooltip', function () {
const str =
'classDiagram\n' +
'class Class1 {\n' +
'%% Comment Class01 <|-- Class02\n' +
'int : test\n' +
'string : foo\n' +
'test()\n' +
'foo()\n' +
'}\n' +
'click Class01 call functionCall() "A Tooltip" ';
parser.parse(str);
});
it('should handle dashed relation definition of different types and directions', function () {
const str =
'classDiagram\n' +
'Class11 <|.. Class12\n' +
'Class13 <.. Class14\n' +
'Class15 ..|> Class16\n' +
'Class17 ..> Class18\n' +
'Class19 .. Class20';
parser.parse(str);
});
it('should handle generic types in members', function () {
const str =
'classDiagram\n' +
'class Car~T~\n' +
'Car : -List~Wheel~ wheels\n' +
'Car : +setWheels(List~Wheel~ wheels)\n' +
'Car : +getWheels() List~Wheel~';
parser.parse(str);
});
it('should handle generic types in members in class with brackets', function () {
const str =
'classDiagram\n' +
'class Car {\n' +
'List~Wheel~ wheels\n' +
'setWheels(List~Wheel~ wheels)\n' +
'+getWheels() List~Wheel~\n' +
'}';
parser.parse(str);
});
});
describe('when fetching data from a classDiagram graph it', function () {
beforeEach(function () {
parser.yy = classDb;
parser.yy.clear();
});
it('should handle relation definitions EXTENSION', function () {
const str = 'classDiagram\n' + 'Class01 <|-- Class02';
parser.parse(str);
const relations = parser.yy.getRelations();
expect(parser.yy.getClass('Class01').id).toBe('Class01');
expect(parser.yy.getClass('Class02').id).toBe('Class02');
expect(relations[0].relation.type1).toBe(classDb.relationType.EXTENSION);
expect(relations[0].relation.type2).toBe('none');
expect(relations[0].relation.lineType).toBe(classDb.lineType.LINE);
});
it('should handle accTitle and accDescr', function () {
const str = `classDiagram
accTitle: My Title
accDescr: My Description
Class01 <|-- Class02
`;
parser.parse(str);
expect(parser.yy.getAccTitle()).toBe('My Title');
expect(parser.yy.getAccDescription()).toBe('My Description');
});
it('should handle accTitle and multiline accDescr', function () {
const str = `classDiagram
accTitle: My Title
accDescr {
This is mu multi
line description
}
Class01 <|-- Class02
`;
parser.parse(str);
expect(parser.yy.getAccTitle()).toBe('My Title');
expect(parser.yy.getAccDescription()).toBe('This is mu multi\nline description');
});
it('should handle relation definitions AGGREGATION and dotted line', function () {
const str = 'classDiagram\n' + 'Class01 o.. Class02';
parser.parse(str);
const relations = parser.yy.getRelations();
expect(parser.yy.getClass('Class01').id).toBe('Class01');
expect(parser.yy.getClass('Class02').id).toBe('Class02');
expect(relations[0].relation.type1).toBe(classDb.relationType.AGGREGATION);
expect(relations[0].relation.type2).toBe('none');
expect(relations[0].relation.lineType).toBe(classDb.lineType.DOTTED_LINE);
});
it('should handle relation definitions COMPOSITION on both sides', function () {
const str = 'classDiagram\n' + 'Class01 *--* Class02';
parser.parse(str);
const relations = parser.yy.getRelations();
expect(parser.yy.getClass('Class01').id).toBe('Class01');
expect(parser.yy.getClass('Class02').id).toBe('Class02');
expect(relations[0].relation.type1).toBe(classDb.relationType.COMPOSITION);
expect(relations[0].relation.type2).toBe(classDb.relationType.COMPOSITION);
expect(relations[0].relation.lineType).toBe(classDb.lineType.LINE);
});
it('should handle relation definitions no types', function () {
const str = 'classDiagram\n' + 'Class01 -- Class02';
parser.parse(str);
const relations = parser.yy.getRelations();
expect(parser.yy.getClass('Class01').id).toBe('Class01');
expect(parser.yy.getClass('Class02').id).toBe('Class02');
expect(relations[0].relation.type1).toBe('none');
expect(relations[0].relation.type2).toBe('none');
expect(relations[0].relation.lineType).toBe(classDb.lineType.LINE);
});
it('should handle relation definitions with type only on right side', function () {
const str = 'classDiagram\n' + 'Class01 --|> Class02';
parser.parse(str);
const relations = parser.yy.getRelations();
expect(parser.yy.getClass('Class01').id).toBe('Class01');
expect(parser.yy.getClass('Class02').id).toBe('Class02');
expect(relations[0].relation.type1).toBe('none');
expect(relations[0].relation.type2).toBe(classDb.relationType.EXTENSION);
expect(relations[0].relation.lineType).toBe(classDb.lineType.LINE);
});
it('should handle multiple classes and relation definitions', function () {
const str =
'classDiagram\n' +
'Class01 <|-- Class02\n' +
'Class03 *-- Class04\n' +
'Class05 o-- Class06\n' +
'Class07 .. Class08\n' +
'Class09 -- Class10';
parser.parse(str);
const relations = parser.yy.getRelations();
expect(parser.yy.getClass('Class01').id).toBe('Class01');
expect(parser.yy.getClass('Class10').id).toBe('Class10');
expect(relations.length).toBe(5);
expect(relations[0].relation.type1).toBe(classDb.relationType.EXTENSION);
expect(relations[0].relation.type2).toBe('none');
expect(relations[0].relation.lineType).toBe(classDb.lineType.LINE);
expect(relations[3].relation.type1).toBe('none');
expect(relations[3].relation.type2).toBe('none');
expect(relations[3].relation.lineType).toBe(classDb.lineType.DOTTED_LINE);
});
it('should handle generic class with relation definitions', function () {
const str = 'classDiagram\n' + 'Class01~T~ <|-- Class02';
parser.parse(str);
const relations = parser.yy.getRelations();
expect(parser.yy.getClass('Class01').id).toBe('Class01');
expect(parser.yy.getClass('Class01').type).toBe('T');
expect(parser.yy.getClass('Class02').id).toBe('Class02');
expect(relations[0].relation.type1).toBe(classDb.relationType.EXTENSION);
expect(relations[0].relation.type2).toBe('none');
expect(relations[0].relation.lineType).toBe(classDb.lineType.LINE);
});
it('should handle class annotations', function () {
const str = 'classDiagram\n' + 'class Class1\n' + '<<interface>> Class1';
parser.parse(str);
const testClass = parser.yy.getClass('Class1');
expect(testClass.annotations.length).toBe(1);
expect(testClass.members.length).toBe(0);
expect(testClass.methods.length).toBe(0);
expect(testClass.annotations[0]).toBe('interface');
});
it('should handle class annotations with members and methods', function () {
const str =
'classDiagram\n' +
'class Class1\n' +
'Class1 : int test\n' +
'Class1 : test()\n' +
'<<interface>> Class1';
parser.parse(str);
const testClass = parser.yy.getClass('Class1');
expect(testClass.annotations.length).toBe(1);
expect(testClass.members.length).toBe(1);
expect(testClass.methods.length).toBe(1);
expect(testClass.annotations[0]).toBe('interface');
});
it('should handle class annotations in brackets', function () {
const str = 'classDiagram\n' + 'class Class1 {\n' + '<<interface>>\n' + '}';
parser.parse(str);
const testClass = parser.yy.getClass('Class1');
expect(testClass.annotations.length).toBe(1);
expect(testClass.members.length).toBe(0);
expect(testClass.methods.length).toBe(0);
expect(testClass.annotations[0]).toBe('interface');
});
it('should handle class annotations in brackets with members and methods', function () {
const str =
'classDiagram\n' +
'class Class1 {\n' +
'<<interface>>\n' +
'int : test\n' +
'test()\n' +
'}';
parser.parse(str);
const testClass = parser.yy.getClass('Class1');
expect(testClass.annotations.length).toBe(1);
expect(testClass.members.length).toBe(1);
expect(testClass.methods.length).toBe(1);
expect(testClass.annotations[0]).toBe('interface');
});
it('should add bracket members in right order', function () {
const str =
'classDiagram\n' +
'class Class1 {\n' +
'int : test\n' +
'string : foo\n' +
'test()\n' +
'foo()\n' +
'}';
parser.parse(str);
const testClass = parser.yy.getClass('Class1');
expect(testClass.members.length).toBe(2);
expect(testClass.methods.length).toBe(2);
expect(testClass.members[0]).toBe('int : test');
expect(testClass.members[1]).toBe('string : foo');
expect(testClass.methods[0]).toBe('test()');
expect(testClass.methods[1]).toBe('foo()');
});
it('should handle abstract methods', function () {
const str = 'classDiagram\n' + 'class Class1\n' + 'Class1 : someMethod()*';
parser.parse(str);
const testClass = parser.yy.getClass('Class1');
expect(testClass.annotations.length).toBe(0);
expect(testClass.members.length).toBe(0);
expect(testClass.methods.length).toBe(1);
expect(testClass.methods[0]).toBe('someMethod()*');
});
it('should handle static methods', function () {
const str = 'classDiagram\n' + 'class Class1\n' + 'Class1 : someMethod()$';
parser.parse(str);
const testClass = parser.yy.getClass('Class1');
expect(testClass.annotations.length).toBe(0);
expect(testClass.members.length).toBe(0);
expect(testClass.methods.length).toBe(1);
expect(testClass.methods[0]).toBe('someMethod()$');
});
it('should associate link and css appropriately', function () {
const str =
'classDiagram\n' +
'class Class1\n' +
'Class1 : someMethod()\n' +
'link Class1 "google.com"';
parser.parse(str);
const testClass = parser.yy.getClass('Class1');
expect(testClass.link).toBe('google.com');
expect(testClass.cssClasses.length).toBe(1);
expect(testClass.cssClasses[0]).toBe('clickable');
});
it('should associate click and href link and css appropriately', function () {
const str =
'classDiagram\n' +
'class Class1\n' +
'Class1 : someMethod()\n' +
'click Class1 href "google.com"';
parser.parse(str);
const testClass = parser.yy.getClass('Class1');
expect(testClass.link).toBe('google.com');
expect(testClass.cssClasses.length).toBe(1);
expect(testClass.cssClasses[0]).toBe('clickable');
});
it('should associate link with tooltip', function () {
const str =
'classDiagram\n' +
'class Class1\n' +
'Class1 : someMethod()\n' +
'link Class1 "google.com" "A tooltip"';
parser.parse(str);
const testClass = parser.yy.getClass('Class1');
expect(testClass.link).toBe('google.com');
expect(testClass.tooltip).toBe('A tooltip');
expect(testClass.cssClasses.length).toBe(1);
expect(testClass.cssClasses[0]).toBe('clickable');
});
it('should associate click and href link with tooltip', function () {
const str =
'classDiagram\n' +
'class Class1\n' +
'Class1 : someMethod()\n' +
'click Class1 href "google.com" "A tooltip"';
parser.parse(str);
const testClass = parser.yy.getClass('Class1');
expect(testClass.link).toBe('google.com');
expect(testClass.tooltip).toBe('A tooltip');
expect(testClass.cssClasses.length).toBe(1);
expect(testClass.cssClasses[0]).toBe('clickable');
});
it('should associate click and href link with tooltip and target appropriately', function () {
spyOn(classDb, 'setLink');
spyOn(classDb, 'setTooltip');
const str =
'classDiagram\n' +
'class Class1\n' +
'Class1 : someMethod()\n' +
'click Class1 href "google.com" "A tooltip" _self';
parser.parse(str);
expect(classDb.setLink).toHaveBeenCalledWith('Class1', 'google.com', '_self');
expect(classDb.setTooltip).toHaveBeenCalledWith('Class1', 'A tooltip');
});
it('should associate click and href link appropriately', function () {
spyOn(classDb, 'setLink');
const str =
'classDiagram\n' +
'class Class1\n' +
'Class1 : someMethod()\n' +
'click Class1 href "google.com"';
parser.parse(str);
expect(classDb.setLink).toHaveBeenCalledWith('Class1', 'google.com');
});
it('should associate click and href link with target appropriately', function () {
spyOn(classDb, 'setLink');
const str =
'classDiagram\n' +
'class Class1\n' +
'Class1 : someMethod()\n' +
'click Class1 href "google.com" _self';
parser.parse(str);
expect(classDb.setLink).toHaveBeenCalledWith('Class1', 'google.com', '_self');
});
it('should associate link appropriately', function () {
spyOn(classDb, 'setLink');
spyOn(classDb, 'setTooltip');
const str =
'classDiagram\n' +
'class Class1\n' +
'Class1 : someMethod()\n' +
'link Class1 "google.com" "A tooltip" _self';
parser.parse(str);
expect(classDb.setLink).toHaveBeenCalledWith('Class1', 'google.com', '_self');
expect(classDb.setTooltip).toHaveBeenCalledWith('Class1', 'A tooltip');
});
it('should associate callback appropriately', function () {
spyOn(classDb, 'setClickEvent');
const str =
'classDiagram\n' +
'class Class1\n' +
'Class1 : someMethod()\n' +
'callback Class1 "functionCall"';
parser.parse(str);
expect(classDb.setClickEvent).toHaveBeenCalledWith('Class1', 'functionCall');
});
it('should associate click and call callback appropriately', function () {
spyOn(classDb, 'setClickEvent');
const str =
'classDiagram\n' +
'class Class1\n' +
'Class1 : someMethod()\n' +
'click Class1 call functionCall()';
parser.parse(str);
expect(classDb.setClickEvent).toHaveBeenCalledWith('Class1', 'functionCall');
});
it('should associate callback appropriately with an arbitrary number of args', function () {
spyOn(classDb, 'setClickEvent');
const str =
'classDiagram\n' +
'class Class1\n' +
'Class1 : someMethod()\n' +
'click Class1 call functionCall("test0", test1, test2)';
parser.parse(str);
expect(classDb.setClickEvent).toHaveBeenCalledWith(
'Class1',
'functionCall',
'"test0", test1, test2'
);
});
it('should associate callback with tooltip', function () {
spyOn(classDb, 'setClickEvent');
spyOn(classDb, 'setTooltip');
const str =
'classDiagram\n' +
'class Class1\n' +
'Class1 : someMethod()\n' +
'click Class1 call functionCall() "A tooltip"';
parser.parse(str);
expect(classDb.setClickEvent).toHaveBeenCalledWith('Class1', 'functionCall');
expect(classDb.setTooltip).toHaveBeenCalledWith('Class1', 'A tooltip');
});
});
});

View File

@@ -0,0 +1,13 @@
// eslint-disable-next-line @typescript-eslint/no-var-requires
const fs = require('fs');
import { LALRGenerator } from 'jison';
describe('class diagram grammar', function () {
it('should introduce no new conflicts', function () {
const file = require.resolve('./parser/classDiagram.jison');
const grammarSource = fs.readFileSync(file, 'utf8');
const grammarParser = new LALRGenerator(grammarSource, {});
expect(grammarParser.conflicts < 16).toBe(true);
});
});

View File

@@ -0,0 +1,428 @@
import { select } from 'd3';
import graphlib from 'graphlib';
import { log } from '../../logger';
import { getConfig } from '../../config';
import { render } from '../../dagre-wrapper/index.js';
import { curveLinear } from 'd3';
import { interpolateToCurve, getStylesFromArray } from '../../utils';
import { setupGraphViewbox } from '../../setupGraphViewbox';
import common from '../common/common';
import addSVGAccessibilityFields from '../../accessibility';
let idCache = {};
const sanitizeText = (txt) => common.sanitizeText(txt, getConfig());
let conf = {
dividerMargin: 10,
padding: 5,
textHeight: 10,
};
/**
* Function that adds the vertices found during parsing to the graph to be rendered.
*
* @param {Object<
* string,
* { cssClasses: string[]; text: string; id: string; type: string; domId: string }
* >} classes
* Object containing the vertices.
* @param {SVGGElement} g The graph that is to be drawn.
* @param _id
* @param diagObj
*/
export const addClasses = function (classes, g, _id, diagObj) {
// const svg = select(`[id="${svgId}"]`);
const keys = Object.keys(classes);
log.info('keys:', keys);
log.info(classes);
// Iterate through each item in the vertex object (containing all the vertices found) in the graph definition
keys.forEach(function (id) {
const vertex = classes[id];
/**
* Variable for storing the classes for the vertex
*
* @type {string}
*/
let cssClassStr = '';
if (vertex.cssClasses.length > 0) {
cssClassStr = cssClassStr + ' ' + vertex.cssClasses.join(' ');
}
// if (vertex.classes.length > 0) {
// classStr = vertex.classes.join(' ');
// }
const styles = { labelStyle: '' }; //getStylesFromArray(vertex.styles);
// Use vertex id as text in the box if no text is provided by the graph definition
let vertexText = vertex.text !== undefined ? vertex.text : vertex.id;
// We create a SVG label, either by delegating to addHtmlLabel or manually
// let vertexNode;
// if (evaluate(getConfig().flowchart.htmlLabels)) {
// const node = {
// label: vertexText.replace(
// /fa[lrsb]?:fa-[\w-]+/g,
// s => `<i class='${s.replace(':', ' ')}'></i>`
// )
// };
// vertexNode = addHtmlLabel(svg, node).node();
// vertexNode.parentNode.removeChild(vertexNode);
// } else {
// const svgLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text');
// svgLabel.setAttribute('style', styles.labelStyle.replace('color:', 'fill:'));
// const rows = vertexText.split(common.lineBreakRegex);
// for (let j = 0; j < rows.length; j++) {
// const tspan = document.createElementNS('http://www.w3.org/2000/svg', 'tspan');
// tspan.setAttributeNS('http://www.w3.org/XML/1998/namespace', 'xml:space', 'preserve');
// tspan.setAttribute('dy', '1em');
// tspan.setAttribute('x', '1');
// tspan.textContent = rows[j];
// svgLabel.appendChild(tspan);
// }
// vertexNode = svgLabel;
// }
let radious = 0;
let _shape = '';
// Set the shape based parameters
switch (vertex.type) {
case 'class':
_shape = 'class_box';
break;
default:
_shape = 'class_box';
}
// Add the node
g.setNode(vertex.id, {
labelStyle: styles.labelStyle,
shape: _shape,
labelText: sanitizeText(vertexText),
classData: vertex,
rx: radious,
ry: radious,
class: cssClassStr,
style: styles.style,
id: vertex.id,
domId: vertex.domId,
tooltip: diagObj.db.getTooltip(vertex.id) || '',
haveCallback: vertex.haveCallback,
link: vertex.link,
width: vertex.type === 'group' ? 500 : undefined,
type: vertex.type,
padding: getConfig().flowchart.padding,
});
log.info('setNode', {
labelStyle: styles.labelStyle,
shape: _shape,
labelText: vertexText,
rx: radious,
ry: radious,
class: cssClassStr,
style: styles.style,
id: vertex.id,
width: vertex.type === 'group' ? 500 : undefined,
type: vertex.type,
padding: getConfig().flowchart.padding,
});
});
};
/**
* Add edges to graph based on parsed graph definition
*
* @param relations
* @param {object} g The graph object
*/
export const addRelations = function (relations, g) {
const conf = getConfig().flowchart;
let cnt = 0;
let defaultStyle;
let defaultLabelStyle;
// if (typeof relations.defaultStyle !== 'undefined') {
// const defaultStyles = getStylesFromArray(relations.defaultStyle);
// defaultStyle = defaultStyles.style;
// defaultLabelStyle = defaultStyles.labelStyle;
// }
relations.forEach(function (edge) {
cnt++;
const edgeData = {};
//Set relationship style and line type
edgeData.classes = 'relation';
edgeData.pattern = edge.relation.lineType == 1 ? 'dashed' : 'solid';
edgeData.id = 'id' + cnt;
// Set link type for rendering
if (edge.type === 'arrow_open') {
edgeData.arrowhead = 'none';
} else {
edgeData.arrowhead = 'normal';
}
log.info(edgeData, edge);
//Set edge extra labels
//edgeData.startLabelLeft = edge.relationTitle1;
edgeData.startLabelRight = edge.relationTitle1 === 'none' ? '' : edge.relationTitle1;
edgeData.endLabelLeft = edge.relationTitle2 === 'none' ? '' : edge.relationTitle2;
//edgeData.endLabelRight = edge.relationTitle2;
//Set relation arrow types
edgeData.arrowTypeStart = getArrowMarker(edge.relation.type1);
edgeData.arrowTypeEnd = getArrowMarker(edge.relation.type2);
let style = '';
let labelStyle = '';
if (typeof edge.style !== 'undefined') {
const styles = getStylesFromArray(edge.style);
style = styles.style;
labelStyle = styles.labelStyle;
} else {
style = 'fill:none';
if (typeof defaultStyle !== 'undefined') {
style = defaultStyle;
}
if (typeof defaultLabelStyle !== 'undefined') {
labelStyle = defaultLabelStyle;
}
}
edgeData.style = style;
edgeData.labelStyle = labelStyle;
if (typeof edge.interpolate !== 'undefined') {
edgeData.curve = interpolateToCurve(edge.interpolate, curveLinear);
} else if (typeof relations.defaultInterpolate !== 'undefined') {
edgeData.curve = interpolateToCurve(relations.defaultInterpolate, curveLinear);
} else {
edgeData.curve = interpolateToCurve(conf.curve, curveLinear);
}
edge.text = edge.title;
if (typeof edge.text === 'undefined') {
if (typeof edge.style !== 'undefined') {
edgeData.arrowheadStyle = 'fill: #333';
}
} else {
edgeData.arrowheadStyle = 'fill: #333';
edgeData.labelpos = 'c';
if (getConfig().flowchart.htmlLabels) {
edgeData.labelType = 'html';
edgeData.label = '<span class="edgeLabel">' + edge.text + '</span>';
} else {
edgeData.labelType = 'text';
edgeData.label = edge.text.replace(common.lineBreakRegex, '\n');
if (typeof edge.style === 'undefined') {
edgeData.style = edgeData.style || 'stroke: #333; stroke-width: 1.5px;fill:none';
}
edgeData.labelStyle = edgeData.labelStyle.replace('color:', 'fill:');
}
}
// Add the edge to the graph
g.setEdge(edge.id1, edge.id2, edgeData, cnt);
});
};
/**
* Merges the value of `conf` with the passed `cnf`
*
* @param {object} cnf Config to merge
*/
export const setConf = function (cnf) {
const keys = Object.keys(cnf);
keys.forEach(function (key) {
conf[key] = cnf[key];
});
};
/**
* Draws a flowchart in the tag with id: id based on the graph definition in text.
*
* @param {string} text
* @param {string} id
* @param _version
* @param diagObj
*/
export const draw = function (text, id, _version, diagObj) {
log.info('Drawing class - ', id);
// diagObj.db.clear();
// const parser = diagObj.db.parser;
// parser.yy = classDb;
// Parse the graph definition
// try {
// parser.parse(text);
// } catch (err) {
// log.debug('Parsing failed');
// }
// Fetch the default direction, use TD if none was found
//let dir = 'TD';
const conf = getConfig().flowchart;
const securityLevel = getConfig().securityLevel;
log.info('config:', conf);
const nodeSpacing = conf.nodeSpacing || 50;
const rankSpacing = conf.rankSpacing || 50;
// Create the input mermaid.graph
const g = new graphlib.Graph({
multigraph: true,
compound: true,
})
.setGraph({
rankdir: diagObj.db.getDirection(),
nodesep: nodeSpacing,
ranksep: rankSpacing,
marginx: 8,
marginy: 8,
})
.setDefaultEdgeLabel(function () {
return {};
});
// let subG;
// const subGraphs = flowDb.getSubGraphs();
// log.info('Subgraphs - ', subGraphs);
// for (let i = subGraphs.length - 1; i >= 0; i--) {
// subG = subGraphs[i];
// log.info('Subgraph - ', subG);
// flowDb.addVertex(subG.id, subG.title, 'group', undefined, subG.classes);
// }
// Fetch the vertices/nodes and edges/links from the parsed graph definition
const classes = diagObj.db.getClasses();
const relations = diagObj.db.getRelations();
log.info(relations);
addClasses(classes, g, id, diagObj);
addRelations(relations, g);
// Add custom shapes
// flowChartShapes.addToRenderV2(addShape);
// Set up an SVG group so that we can translate the final graph.
let sandboxElement;
if (securityLevel === 'sandbox') {
sandboxElement = select('#i' + id);
}
const root =
securityLevel === 'sandbox'
? select(sandboxElement.nodes()[0].contentDocument.body)
: select('body');
const svg = root.select(`[id="${id}"]`);
// Run the renderer. This is what draws the final graph.
const element = root.select('#' + id + ' g');
render(
element,
g,
['aggregation', 'extension', 'composition', 'dependency', 'lollipop'],
'classDiagram',
id
);
setupGraphViewbox(g, svg, conf.diagramPadding, conf.useMaxWidth);
// Add label rects for non html labels
if (!conf.htmlLabels) {
const doc = securityLevel === 'sandbox' ? sandboxElement.nodes()[0].contentDocument : document;
const labels = doc.querySelectorAll('[id="' + id + '"] .edgeLabel .label');
for (let k = 0; k < labels.length; k++) {
const label = labels[k];
// Get dimensions of label
const dim = label.getBBox();
const rect = doc.createElementNS('http://www.w3.org/2000/svg', 'rect');
rect.setAttribute('rx', 0);
rect.setAttribute('ry', 0);
rect.setAttribute('width', dim.width);
rect.setAttribute('height', dim.height);
// rect.setAttribute('style', 'fill:#e8e8e8;');
label.insertBefore(rect, label.firstChild);
}
}
addSVGAccessibilityFields(diagObj.db, svg, id);
// If node has a link, wrap it in an anchor SVG object.
// const keys = Object.keys(classes);
// keys.forEach(function(key) {
// const vertex = classes[key];
// if (vertex.link) {
// const node = select('#' + id + ' [id="' + key + '"]');
// if (node) {
// const link = document.createElementNS('http://www.w3.org/2000/svg', 'a');
// link.setAttributeNS('http://www.w3.org/2000/svg', 'class', vertex.classes.join(' '));
// link.setAttributeNS('http://www.w3.org/2000/svg', 'href', vertex.link);
// link.setAttributeNS('http://www.w3.org/2000/svg', 'rel', 'noopener');
// const linkNode = node.insert(function() {
// return link;
// }, ':first-child');
// const shape = node.select('.label-container');
// if (shape) {
// linkNode.append(function() {
// return shape.node();
// });
// }
// const label = node.select('.label');
// if (label) {
// linkNode.append(function() {
// return label.node();
// });
// }
// }
// }
// });
};
/**
* Gets the arrow marker for a type index
*
* @param {number} type The type to look for
* @returns {'aggregation' | 'extension' | 'composition' | 'dependency'} The arrow marker
*/
function getArrowMarker(type) {
let marker;
switch (type) {
case 0:
marker = 'aggregation';
break;
case 1:
marker = 'extension';
break;
case 2:
marker = 'composition';
break;
case 3:
marker = 'dependency';
break;
case 4:
marker = 'lollipop';
break;
default:
marker = 'none';
}
return marker;
}
export default {
setConf,
draw,
};

View File

@@ -0,0 +1,250 @@
import { select } from 'd3';
import dagre from 'dagre';
import graphlib from 'graphlib';
import { log } from '../../logger';
import svgDraw from './svgDraw';
import { configureSvgSize } from '../../setupGraphViewbox';
import { getConfig } from '../../config';
import addSVGAccessibilityFields from '../../accessibility';
let idCache = {};
const padding = 20;
/**
* Gets the ID with the same label as in the cache
*
* @param {string} label The label to look for
* @returns {string} The resulting ID
*/
const getGraphId = function (label) {
const foundEntry = Object.entries(idCache).find((entry) => entry[1].label === label);
if (foundEntry) {
return foundEntry[0];
}
};
/**
* Setup arrow head and define the marker. The result is appended to the svg.
*
* @param {SVGSVGElement} elem The SVG element to append to
*/
const insertMarkers = function (elem) {
elem
.append('defs')
.append('marker')
.attr('id', 'extensionStart')
.attr('class', 'extension')
.attr('refX', 0)
.attr('refY', 7)
.attr('markerWidth', 190)
.attr('markerHeight', 240)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M 1,7 L18,13 V 1 Z');
elem
.append('defs')
.append('marker')
.attr('id', 'extensionEnd')
.attr('refX', 19)
.attr('refY', 7)
.attr('markerWidth', 20)
.attr('markerHeight', 28)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M 1,1 V 13 L18,7 Z'); // this is actual shape for arrowhead
elem
.append('defs')
.append('marker')
.attr('id', 'compositionStart')
.attr('class', 'extension')
.attr('refX', 0)
.attr('refY', 7)
.attr('markerWidth', 190)
.attr('markerHeight', 240)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M 18,7 L9,13 L1,7 L9,1 Z');
elem
.append('defs')
.append('marker')
.attr('id', 'compositionEnd')
.attr('refX', 19)
.attr('refY', 7)
.attr('markerWidth', 20)
.attr('markerHeight', 28)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M 18,7 L9,13 L1,7 L9,1 Z');
elem
.append('defs')
.append('marker')
.attr('id', 'aggregationStart')
.attr('class', 'extension')
.attr('refX', 0)
.attr('refY', 7)
.attr('markerWidth', 190)
.attr('markerHeight', 240)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M 18,7 L9,13 L1,7 L9,1 Z');
elem
.append('defs')
.append('marker')
.attr('id', 'aggregationEnd')
.attr('refX', 19)
.attr('refY', 7)
.attr('markerWidth', 20)
.attr('markerHeight', 28)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M 18,7 L9,13 L1,7 L9,1 Z');
elem
.append('defs')
.append('marker')
.attr('id', 'dependencyStart')
.attr('class', 'extension')
.attr('refX', 0)
.attr('refY', 7)
.attr('markerWidth', 190)
.attr('markerHeight', 240)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M 5,7 L9,13 L1,7 L9,1 Z');
elem
.append('defs')
.append('marker')
.attr('id', 'dependencyEnd')
.attr('refX', 19)
.attr('refY', 7)
.attr('markerWidth', 20)
.attr('markerHeight', 28)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M 18,7 L9,13 L14,7 L9,1 Z');
};
/**
* Draws a flowchart in the tag with id: id based on the graph definition in text.
*
* @param {string} text
* @param {string} id
* @param {any} _version
* @param diagObj
*/
export const draw = function (text, id, _version, diagObj) {
const conf = getConfig().class;
idCache = {};
// diagObj.db.clear();
// diagObj.parser.parse(text);
log.info('Rendering diagram ' + text);
const securityLevel = getConfig().securityLevel;
// Handle root and Document for when rendering in sanbox mode
let sandboxElement;
if (securityLevel === 'sandbox') {
sandboxElement = select('#i' + id);
}
const root =
securityLevel === 'sandbox'
? select(sandboxElement.nodes()[0].contentDocument.body)
: select('body');
// Fetch the default direction, use TD if none was found
const diagram = root.select(`[id='${id}']`);
insertMarkers(diagram);
// Layout graph, Create a new directed graph
const g = new graphlib.Graph({
multigraph: true,
});
// Set an object for the graph label
g.setGraph({
isMultiGraph: true,
});
// Default to assigning a new object as a label for each new edge.
g.setDefaultEdgeLabel(function () {
return {};
});
const classes = diagObj.db.getClasses();
const keys = Object.keys(classes);
for (let i = 0; i < keys.length; i++) {
const classDef = classes[keys[i]];
const node = svgDraw.drawClass(diagram, classDef, conf, diagObj);
idCache[node.id] = node;
// Add nodes to the graph. The first argument is the node id. The second is
// metadata about the node. In this case we're going to add labels to each of
// our nodes.
g.setNode(node.id, node);
log.info('Org height: ' + node.height);
}
const relations = diagObj.db.getRelations();
relations.forEach(function (relation) {
log.info(
'tjoho' + getGraphId(relation.id1) + getGraphId(relation.id2) + JSON.stringify(relation)
);
g.setEdge(
getGraphId(relation.id1),
getGraphId(relation.id2),
{
relation: relation,
},
relation.title || 'DEFAULT'
);
});
dagre.layout(g);
g.nodes().forEach(function (v) {
if (typeof v !== 'undefined' && typeof g.node(v) !== 'undefined') {
log.debug('Node ' + v + ': ' + JSON.stringify(g.node(v)));
root
.select('#' + diagObj.db.lookUpDomId(v))
.attr(
'transform',
'translate(' +
(g.node(v).x - g.node(v).width / 2) +
',' +
(g.node(v).y - g.node(v).height / 2) +
' )'
);
}
});
g.edges().forEach(function (e) {
if (typeof e !== 'undefined' && typeof g.edge(e) !== 'undefined') {
log.debug('Edge ' + e.v + ' -> ' + e.w + ': ' + JSON.stringify(g.edge(e)));
svgDraw.drawEdge(diagram, g.edge(e), g.edge(e).relation, conf, diagObj);
}
});
const svgBounds = diagram.node().getBBox();
const width = svgBounds.width + padding * 2;
const height = svgBounds.height + padding * 2;
configureSvgSize(diagram, height, width, conf.useMaxWidth);
// Ensure the viewBox includes the whole svgBounds area with extra space for padding
const vBox = `${svgBounds.x - padding} ${svgBounds.y - padding} ${width} ${height}`;
log.debug(`viewBox ${vBox}`);
diagram.attr('viewBox', vBox);
addSVGAccessibilityFields(diagObj.db, diagram, id);
};
export default {
draw,
};

View File

@@ -0,0 +1,354 @@
/** mermaid
* https://mermaidjs.github.io/
* (c) 2015 Knut Sveidqvist
* MIT license.
*/
/* lexical grammar */
%lex
%x string
%x bqstring
%x generic
%x struct
%x href
%x callback_name
%x callback_args
%x open_directive
%x type_directive
%x arg_directive
%x acc_title
%x acc_descr
%x acc_descr_multiline
%%
\%\%\{ { this.begin('open_directive'); return 'open_directive'; }
.*direction\s+TB[^\n]* return 'direction_tb';
.*direction\s+BT[^\n]* return 'direction_bt';
.*direction\s+RL[^\n]* return 'direction_rl';
.*direction\s+LR[^\n]* return 'direction_lr';
<open_directive>((?:(?!\}\%\%)[^:.])*) { this.begin('type_directive'); return 'type_directive'; }
<type_directive>":" { this.popState(); this.begin('arg_directive'); return ':'; }
<type_directive,arg_directive>\}\%\% { this.popState(); this.popState(); return 'close_directive'; }
<arg_directive>((?:(?!\}\%\%).|\n)*) return 'arg_directive';
\%\%(?!\{)*[^\n]*(\r?\n?)+ /* skip comments */
\%\%[^\n]*(\r?\n)* /* skip comments */
accTitle\s*":"\s* { this.begin("acc_title");return 'acc_title'; }
<acc_title>(?!\n|;|#)*[^\n]* { this.popState(); return "acc_title_value"; }
accDescr\s*":"\s* { this.begin("acc_descr");return 'acc_descr'; }
<acc_descr>(?!\n|;|#)*[^\n]* { this.popState(); return "acc_descr_value"; }
accDescr\s*"{"\s* { this.begin("acc_descr_multiline");}
<acc_descr_multiline>[\}] { this.popState(); }
<acc_descr_multiline>[^\}]* return "acc_descr_multiline_value";
\s*(\r?\n)+ return 'NEWLINE';
\s+ /* skip whitespace */
"classDiagram-v2" return 'CLASS_DIAGRAM';
"classDiagram" return 'CLASS_DIAGRAM';
[{] { this.begin("struct"); /*console.log('Starting struct');*/ return 'STRUCT_START';}
<INITIAL,struct>"[*]" { /*console.log('EDGE_STATE=',yytext);*/ return 'EDGE_STATE';}
<struct><<EOF>> return "EOF_IN_STRUCT";
<struct>[{] return "OPEN_IN_STRUCT";
<struct>[}] { /*console.log('Ending struct');*/this.popState(); return 'STRUCT_STOP';}}
<struct>[\n] /* nothing */
<struct>[^{}\n]* { /*console.log('lex-member: ' + yytext);*/ return "MEMBER";}
"class" return 'CLASS';
"cssClass" return 'CSSCLASS';
"callback" return 'CALLBACK';
"link" return 'LINK';
"click" return 'CLICK';
"<<" return 'ANNOTATION_START';
">>" return 'ANNOTATION_END';
[~] this.begin("generic");
<generic>[~] this.popState();
<generic>[^~]* return "GENERICTYPE";
["] this.begin("string");
<string>["] this.popState();
<string>[^"]* return "STR";
[`] this.begin("bqstring");
<bqstring>[`] this.popState();
<bqstring>[^`]+ return "BQUOTE_STR";
/*
---interactivity command---
'href' adds a link to the specified node. 'href' can only be specified when the
line was introduced with 'click'.
'href "<link>"' attaches the specified link to the node that was specified by 'click'.
*/
"href"[\s]+["] this.begin("href");
<href>["] this.popState();
<href>[^"]* return 'HREF';
/*
---interactivity command---
'call' adds a callback to the specified node. 'call' can only be specified when
the line was introduced with 'click'.
'call <callback_name>(<callback_args>)' attaches the function 'callback_name' with the specified
arguments to the node that was specified by 'click'.
Function arguments are optional: 'call <callback_name>()' simply executes 'callback_name' without any arguments.
*/
"call"[\s]+ this.begin("callback_name");
<callback_name>\([\s]*\) this.popState();
<callback_name>\( this.popState(); this.begin("callback_args");
<callback_name>[^(]* return 'CALLBACK_NAME';
<callback_args>\) this.popState();
<callback_args>[^)]* return 'CALLBACK_ARGS';
"_self" return 'LINK_TARGET';
"_blank" return 'LINK_TARGET';
"_parent" return 'LINK_TARGET';
"_top" return 'LINK_TARGET';
\s*\<\| return 'EXTENSION';
\s*\|\> return 'EXTENSION';
\s*\> return 'DEPENDENCY';
\s*\< return 'DEPENDENCY';
\s*\* return 'COMPOSITION';
\s*o return 'AGGREGATION';
\s*\(\) return 'LOLLIPOP';
\-\- return 'LINE';
\.\. return 'DOTTED_LINE';
":"{1}[^:\n;]+ return 'LABEL';
":"{3} return 'STYLE_SEPARATOR';
\- return 'MINUS';
"." return 'DOT';
\+ return 'PLUS';
\% return 'PCT';
"=" return 'EQUALS';
\= return 'EQUALS';
\w+ return 'ALPHA';
[!"#$%&'*+,-.`?\\/] return 'PUNCTUATION';
[0-9]+ return 'NUM';
[\u00AA\u00B5\u00BA\u00C0-\u00D6\u00D8-\u00F6]|
[\u00F8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0370-\u0374\u0376\u0377]|
[\u037A-\u037D\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5]|
[\u03F7-\u0481\u048A-\u0527\u0531-\u0556\u0559\u0561-\u0587\u05D0-\u05EA]|
[\u05F0-\u05F2\u0620-\u064A\u066E\u066F\u0671-\u06D3\u06D5\u06E5\u06E6\u06EE]|
[\u06EF\u06FA-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07CA-\u07EA]|
[\u07F4\u07F5\u07FA\u0800-\u0815\u081A\u0824\u0828\u0840-\u0858\u08A0]|
[\u08A2-\u08AC\u0904-\u0939\u093D\u0950\u0958-\u0961\u0971-\u0977]|
[\u0979-\u097F\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2]|
[\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u09F0\u09F1\u0A05-\u0A0A]|
[\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39]|
[\u0A59-\u0A5C\u0A5E\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8]|
[\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AD0\u0AE0\u0AE1\u0B05-\u0B0C]|
[\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B5C]|
[\u0B5D\u0B5F-\u0B61\u0B71\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99]|
[\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0]|
[\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C33\u0C35-\u0C39\u0C3D]|
[\u0C58\u0C59\u0C60\u0C61\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3]|
[\u0CB5-\u0CB9\u0CBD\u0CDE\u0CE0\u0CE1\u0CF1\u0CF2\u0D05-\u0D0C\u0D0E-\u0D10]|
[\u0D12-\u0D3A\u0D3D\u0D4E\u0D60\u0D61\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1]|
[\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E81]|
[\u0E82\u0E84\u0E87\u0E88\u0E8A\u0E8D\u0E94-\u0E97\u0E99-\u0E9F\u0EA1-\u0EA3]|
[\u0EA5\u0EA7\u0EAA\u0EAB\u0EAD-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6]|
[\u0EDC-\u0EDF\u0F00\u0F40-\u0F47\u0F49-\u0F6C\u0F88-\u0F8C\u1000-\u102A]|
[\u103F\u1050-\u1055\u105A-\u105D\u1061\u1065\u1066\u106E-\u1070\u1075-\u1081]|
[\u108E\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FC-\u1248\u124A-\u124D]|
[\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0]|
[\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310]|
[\u1312-\u1315\u1318-\u135A\u1380-\u138F\u13A0-\u13F4\u1401-\u166C]|
[\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u1700-\u170C\u170E-\u1711]|
[\u1720-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17D7]|
[\u17DC\u1820-\u1877\u1880-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191C]|
[\u1950-\u196D\u1970-\u1974\u1980-\u19AB\u19C1-\u19C7\u1A00-\u1A16]|
[\u1A20-\u1A54\u1AA7\u1B05-\u1B33\u1B45-\u1B4B\u1B83-\u1BA0\u1BAE\u1BAF]|
[\u1BBA-\u1BE5\u1C00-\u1C23\u1C4D-\u1C4F\u1C5A-\u1C7D\u1CE9-\u1CEC]|
[\u1CEE-\u1CF1\u1CF5\u1CF6\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D]|
[\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D]|
[\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3]|
[\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2071\u207F]|
[\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128]|
[\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2183\u2184]|
[\u2C00-\u2C2E\u2C30-\u2C5E\u2C60-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3]|
[\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D80-\u2D96\u2DA0-\u2DA6]|
[\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE]|
[\u2DD0-\u2DD6\u2DD8-\u2DDE\u2E2F\u3005\u3006\u3031-\u3035\u303B\u303C]|
[\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312D]|
[\u3131-\u318E\u31A0-\u31BA\u31F0-\u31FF\u3400-\u4DB5\u4E00-\u9FCC]|
[\uA000-\uA48C\uA4D0-\uA4FD\uA500-\uA60C\uA610-\uA61F\uA62A\uA62B]|
[\uA640-\uA66E\uA67F-\uA697\uA6A0-\uA6E5\uA717-\uA71F\uA722-\uA788]|
[\uA78B-\uA78E\uA790-\uA793\uA7A0-\uA7AA\uA7F8-\uA801\uA803-\uA805]|
[\uA807-\uA80A\uA80C-\uA822\uA840-\uA873\uA882-\uA8B3\uA8F2-\uA8F7\uA8FB]|
[\uA90A-\uA925\uA930-\uA946\uA960-\uA97C\uA984-\uA9B2\uA9CF\uAA00-\uAA28]|
[\uAA40-\uAA42\uAA44-\uAA4B\uAA60-\uAA76\uAA7A\uAA80-\uAAAF\uAAB1\uAAB5]|
[\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAADD\uAAE0-\uAAEA\uAAF2-\uAAF4]|
[\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E]|
[\uABC0-\uABE2\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D]|
[\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36]|
[\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D]|
[\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC]|
[\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF]|
[\uFFD2-\uFFD7\uFFDA-\uFFDC]
return 'UNICODE_TEXT';
\s return 'SPACE';
<<EOF>> return 'EOF';
/lex
/* operator associations and precedence */
%left '^'
%start start
%% /* language grammar */
start
: mermaidDoc
| statments
| direction
| directive start
;
direction
: direction_tb
{ yy.setDirection('TB');}
| direction_bt
{ yy.setDirection('BT');}
| direction_rl
{ yy.setDirection('RL');}
| direction_lr
{ yy.setDirection('LR');}
;
mermaidDoc
: graphConfig
;
directive
: openDirective typeDirective closeDirective NEWLINE
| openDirective typeDirective ':' argDirective closeDirective NEWLINE
;
openDirective
: open_directive { yy.parseDirective('%%{', 'open_directive'); }
;
typeDirective
: type_directive { yy.parseDirective($1, 'type_directive'); }
;
argDirective
: arg_directive { $1 = $1.trim().replace(/'/g, '"'); yy.parseDirective($1, 'arg_directive'); }
;
closeDirective
: close_directive { yy.parseDirective('}%%', 'close_directive', 'class'); }
;
graphConfig
: CLASS_DIAGRAM NEWLINE statements EOF
;
statements
: statement
| statement NEWLINE
| statement NEWLINE statements
;
className
: alphaNumToken { $$=$1; }
| classLiteralName { $$=$1; }
| alphaNumToken className { $$=$1+$2; }
| alphaNumToken GENERICTYPE { $$=$1+'~'+$2; }
| classLiteralName GENERICTYPE { $$=$1+'~'+$2; }
;
statement
: relationStatement { yy.addRelation($1); }
| relationStatement LABEL { $1.title = yy.cleanupLabel($2); yy.addRelation($1); }
| classStatement
| methodStatement
| annotationStatement
| clickStatement
| cssClassStatement
| directive
| direction
| acc_title acc_title_value { $$=$2.trim();yy.setAccTitle($$); }
| acc_descr acc_descr_value { $$=$2.trim();yy.setAccDescription($$); }
| acc_descr_multiline_value { $$=$1.trim();yy.setAccDescription($$); }
;
classStatement
: CLASS className {yy.addClass($2);}
| CLASS className STYLE_SEPARATOR alphaNumToken {yy.addClass($2);yy.setCssClass($2, $4);}
| CLASS className STRUCT_START members STRUCT_STOP {/*console.log($2,JSON.stringify($4));*/yy.addClass($2);yy.addMembers($2,$4);}
| CLASS className STYLE_SEPARATOR alphaNumToken STRUCT_START members STRUCT_STOP {yy.addClass($2);yy.setCssClass($2, $4);yy.addMembers($2,$6);}
;
annotationStatement
:ANNOTATION_START alphaNumToken ANNOTATION_END className { yy.addAnnotation($4,$2); }
;
members
: MEMBER { $$ = [$1]; }
| MEMBER members { $2.push($1);$$=$2;}
;
methodStatement
: className {/*console.log('Rel found',$1);*/}
| className LABEL {yy.addMember($1,yy.cleanupLabel($2));}
| MEMBER {/*console.warn('Member',$1);*/}
| SEPARATOR {/*console.log('sep found',$1);*/}
;
relationStatement
: className relation className { $$ = {'id1':$1,'id2':$3, relation:$2, relationTitle1:'none', relationTitle2:'none'}; }
| className STR relation className { $$ = {id1:$1, id2:$4, relation:$3, relationTitle1:$2, relationTitle2:'none'}}
| className relation STR className { $$ = {id1:$1, id2:$4, relation:$2, relationTitle1:'none', relationTitle2:$3}; }
| className STR relation STR className { $$ = {id1:$1, id2:$5, relation:$3, relationTitle1:$2, relationTitle2:$4} }
;
relation
: relationType lineType relationType { $$={type1:$1,type2:$3,lineType:$2}; }
| lineType relationType { $$={type1:'none',type2:$2,lineType:$1}; }
| relationType lineType { $$={type1:$1,type2:'none',lineType:$2}; }
| lineType { $$={type1:'none',type2:'none',lineType:$1}; }
;
relationType
: AGGREGATION { $$=yy.relationType.AGGREGATION;}
| EXTENSION { $$=yy.relationType.EXTENSION;}
| COMPOSITION { $$=yy.relationType.COMPOSITION;}
| DEPENDENCY { $$=yy.relationType.DEPENDENCY;}
| LOLLIPOP { $$=yy.relationType.LOLLIPOP;}
;
lineType
: LINE {$$=yy.lineType.LINE;}
| DOTTED_LINE {$$=yy.lineType.DOTTED_LINE;}
;
clickStatement
: CALLBACK className STR {$$ = $1;yy.setClickEvent($2, $3);}
| CALLBACK className STR STR {$$ = $1;yy.setClickEvent($2, $3);yy.setTooltip($2, $4);}
| LINK className STR {$$ = $1;yy.setLink($2, $3);}
| LINK className STR LINK_TARGET {$$ = $1;yy.setLink($2, $3,$4);}
| LINK className STR STR {$$ = $1;yy.setLink($2, $3);yy.setTooltip($2, $4);}
| LINK className STR STR LINK_TARGET {$$ = $1;yy.setLink($2, $3, $5);yy.setTooltip($2, $4);}
| CLICK className CALLBACK_NAME {$$ = $1;yy.setClickEvent($2, $3);}
| CLICK className CALLBACK_NAME STR {$$ = $1;yy.setClickEvent($2, $3);yy.setTooltip($2, $4);}
| CLICK className CALLBACK_NAME CALLBACK_ARGS {$$ = $1;yy.setClickEvent($2, $3, $4);}
| CLICK className CALLBACK_NAME CALLBACK_ARGS STR {$$ = $1;yy.setClickEvent($2, $3, $4);yy.setTooltip($2, $5);}
| CLICK className HREF {$$ = $1;yy.setLink($2, $3);}
| CLICK className HREF LINK_TARGET {$$ = $1;yy.setLink($2, $3, $4);}
| CLICK className HREF STR {$$ = $1;yy.setLink($2, $3);yy.setTooltip($2, $4);}
| CLICK className HREF STR LINK_TARGET {$$ = $1;yy.setLink($2, $3, $5);yy.setTooltip($2, $4);}
;
cssClassStatement
: CSSCLASS STR alphaNumToken {yy.setCssClass($2, $3);}
;
commentToken : textToken | graphCodeTokens ;
textToken : textNoTagsToken | TAGSTART | TAGEND | '==' | '--' | PCT | DEFAULT;
textNoTagsToken: alphaNumToken | SPACE | MINUS | keywords ;
alphaNumToken : UNICODE_TEXT | NUM | ALPHA;
classLiteralName : BQUOTE_STR;
%%

View File

@@ -0,0 +1,149 @@
const getStyles = (options) =>
`g.classGroup text {
fill: ${options.nodeBorder};
fill: ${options.classText};
stroke: none;
font-family: ${options.fontFamily};
font-size: 10px;
.title {
font-weight: bolder;
}
}
.nodeLabel, .edgeLabel {
color: ${options.classText};
}
.edgeLabel .label rect {
fill: ${options.mainBkg};
}
.label text {
fill: ${options.classText};
}
.edgeLabel .label span {
background: ${options.mainBkg};
}
.classTitle {
font-weight: bolder;
}
.node rect,
.node circle,
.node ellipse,
.node polygon,
.node path {
fill: ${options.mainBkg};
stroke: ${options.nodeBorder};
stroke-width: 1px;
}
.divider {
stroke: ${options.nodeBorder};
stroke: 1;
}
g.clickable {
cursor: pointer;
}
g.classGroup rect {
fill: ${options.mainBkg};
stroke: ${options.nodeBorder};
}
g.classGroup line {
stroke: ${options.nodeBorder};
stroke-width: 1;
}
.classLabel .box {
stroke: none;
stroke-width: 0;
fill: ${options.mainBkg};
opacity: 0.5;
}
.classLabel .label {
fill: ${options.nodeBorder};
font-size: 10px;
}
.relation {
stroke: ${options.lineColor};
stroke-width: 1;
fill: none;
}
.dashed-line{
stroke-dasharray: 3;
}
#compositionStart, .composition {
fill: ${options.lineColor} !important;
stroke: ${options.lineColor} !important;
stroke-width: 1;
}
#compositionEnd, .composition {
fill: ${options.lineColor} !important;
stroke: ${options.lineColor} !important;
stroke-width: 1;
}
#dependencyStart, .dependency {
fill: ${options.lineColor} !important;
stroke: ${options.lineColor} !important;
stroke-width: 1;
}
#dependencyStart, .dependency {
fill: ${options.lineColor} !important;
stroke: ${options.lineColor} !important;
stroke-width: 1;
}
#extensionStart, .extension {
fill: ${options.lineColor} !important;
stroke: ${options.lineColor} !important;
stroke-width: 1;
}
#extensionEnd, .extension {
fill: ${options.lineColor} !important;
stroke: ${options.lineColor} !important;
stroke-width: 1;
}
#aggregationStart, .aggregation {
fill: ${options.mainBkg} !important;
stroke: ${options.lineColor} !important;
stroke-width: 1;
}
#aggregationEnd, .aggregation {
fill: ${options.mainBkg} !important;
stroke: ${options.lineColor} !important;
stroke-width: 1;
}
#lollipopStart, .lollipop {
fill: ${options.mainBkg} !important;
stroke: ${options.lineColor} !important;
stroke-width: 1;
}
#lollipopEnd, .lollipop {
fill: ${options.mainBkg} !important;
stroke: ${options.lineColor} !important;
stroke-width: 1;
}
.edgeTerminals {
font-size: 11px;
}
`;
export default getStyles;

View File

@@ -0,0 +1,439 @@
import { line, curveBasis } from 'd3';
import utils from '../../utils';
import { log } from '../../logger';
import { parseGenericTypes } from '../common/common';
let edgeCount = 0;
export const drawEdge = function (elem, path, relation, conf, diagObj) {
const getRelationType = function (type) {
switch (type) {
case diagObj.db.relationType.AGGREGATION:
return 'aggregation';
case diagObj.db.EXTENSION:
return 'extension';
case diagObj.db.COMPOSITION:
return 'composition';
case diagObj.db.DEPENDENCY:
return 'dependency';
case diagObj.db.LOLLIPOP:
return 'lollipop';
}
};
path.points = path.points.filter((p) => !Number.isNaN(p.y));
// The data for our line
const lineData = path.points;
// This is the accessor function we talked about above
const lineFunction = line()
.x(function (d) {
return d.x;
})
.y(function (d) {
return d.y;
})
.curve(curveBasis);
const svgPath = elem
.append('path')
.attr('d', lineFunction(lineData))
.attr('id', 'edge' + edgeCount)
.attr('class', 'relation');
let url = '';
if (conf.arrowMarkerAbsolute) {
url =
window.location.protocol +
'//' +
window.location.host +
window.location.pathname +
window.location.search;
url = url.replace(/\(/g, '\\(');
url = url.replace(/\)/g, '\\)');
}
if (relation.relation.lineType == 1) {
svgPath.attr('class', 'relation dashed-line');
}
if (relation.relation.type1 !== 'none') {
svgPath.attr(
'marker-start',
'url(' + url + '#' + getRelationType(relation.relation.type1) + 'Start' + ')'
);
}
if (relation.relation.type2 !== 'none') {
svgPath.attr(
'marker-end',
'url(' + url + '#' + getRelationType(relation.relation.type2) + 'End' + ')'
);
}
let x, y;
const l = path.points.length;
// Calculate Label position
let labelPosition = utils.calcLabelPosition(path.points);
x = labelPosition.x;
y = labelPosition.y;
let p1_card_x, p1_card_y;
let p2_card_x, p2_card_y;
if (l % 2 !== 0 && l > 1) {
let cardinality_1_point = utils.calcCardinalityPosition(
relation.relation.type1 !== 'none',
path.points,
path.points[0]
);
let cardinality_2_point = utils.calcCardinalityPosition(
relation.relation.type2 !== 'none',
path.points,
path.points[l - 1]
);
log.debug('cardinality_1_point ' + JSON.stringify(cardinality_1_point));
log.debug('cardinality_2_point ' + JSON.stringify(cardinality_2_point));
p1_card_x = cardinality_1_point.x;
p1_card_y = cardinality_1_point.y;
p2_card_x = cardinality_2_point.x;
p2_card_y = cardinality_2_point.y;
}
if (typeof relation.title !== 'undefined') {
const g = elem.append('g').attr('class', 'classLabel');
const label = g
.append('text')
.attr('class', 'label')
.attr('x', x)
.attr('y', y)
.attr('fill', 'red')
.attr('text-anchor', 'middle')
.text(relation.title);
window.label = label;
const bounds = label.node().getBBox();
g.insert('rect', ':first-child')
.attr('class', 'box')
.attr('x', bounds.x - conf.padding / 2)
.attr('y', bounds.y - conf.padding / 2)
.attr('width', bounds.width + conf.padding)
.attr('height', bounds.height + conf.padding);
}
log.info('Rendering relation ' + JSON.stringify(relation));
if (typeof relation.relationTitle1 !== 'undefined' && relation.relationTitle1 !== 'none') {
const g = elem.append('g').attr('class', 'cardinality');
g.append('text')
.attr('class', 'type1')
.attr('x', p1_card_x)
.attr('y', p1_card_y)
.attr('fill', 'black')
.attr('font-size', '6')
.text(relation.relationTitle1);
}
if (typeof relation.relationTitle2 !== 'undefined' && relation.relationTitle2 !== 'none') {
const g = elem.append('g').attr('class', 'cardinality');
g.append('text')
.attr('class', 'type2')
.attr('x', p2_card_x)
.attr('y', p2_card_y)
.attr('fill', 'black')
.attr('font-size', '6')
.text(relation.relationTitle2);
}
edgeCount++;
};
/**
* Renders a class diagram
*
* @param {SVGSVGElement} elem The element to draw it into
* @param classDef
* @param conf
* @param diagObj
* @todo Add more information in the JSDOC here
*/
export const drawClass = function (elem, classDef, conf, diagObj) {
log.debug('Rendering class ', classDef, conf);
const id = classDef.id;
const classInfo = {
id: id,
label: classDef.id,
width: 0,
height: 0,
};
// add class group
const g = elem.append('g').attr('id', diagObj.db.lookUpDomId(id)).attr('class', 'classGroup');
// add title
let title;
if (classDef.link) {
title = g
.append('svg:a')
.attr('xlink:href', classDef.link)
.attr('target', classDef.linkTarget)
.append('text')
.attr('y', conf.textHeight + conf.padding)
.attr('x', 0);
} else {
title = g
.append('text')
.attr('y', conf.textHeight + conf.padding)
.attr('x', 0);
}
// add annotations
let isFirst = true;
classDef.annotations.forEach(function (member) {
const titleText2 = title.append('tspan').text('«' + member + '»');
if (!isFirst) titleText2.attr('dy', conf.textHeight);
isFirst = false;
});
let classTitleString = classDef.id;
if (classDef.type !== undefined && classDef.type !== '') {
classTitleString += '<' + classDef.type + '>';
}
const classTitle = title.append('tspan').text(classTitleString).attr('class', 'title');
// If class has annotations the title needs to have an offset of the text height
if (!isFirst) classTitle.attr('dy', conf.textHeight);
const titleHeight = title.node().getBBox().height;
const membersLine = g
.append('line') // text label for the x axis
.attr('x1', 0)
.attr('y1', conf.padding + titleHeight + conf.dividerMargin / 2)
.attr('y2', conf.padding + titleHeight + conf.dividerMargin / 2);
const members = g
.append('text') // text label for the x axis
.attr('x', conf.padding)
.attr('y', titleHeight + conf.dividerMargin + conf.textHeight)
.attr('fill', 'white')
.attr('class', 'classText');
isFirst = true;
classDef.members.forEach(function (member) {
addTspan(members, member, isFirst, conf);
isFirst = false;
});
const membersBox = members.node().getBBox();
const methodsLine = g
.append('line') // text label for the x axis
.attr('x1', 0)
.attr('y1', conf.padding + titleHeight + conf.dividerMargin + membersBox.height)
.attr('y2', conf.padding + titleHeight + conf.dividerMargin + membersBox.height);
const methods = g
.append('text') // text label for the x axis
.attr('x', conf.padding)
.attr('y', titleHeight + 2 * conf.dividerMargin + membersBox.height + conf.textHeight)
.attr('fill', 'white')
.attr('class', 'classText');
isFirst = true;
classDef.methods.forEach(function (method) {
addTspan(methods, method, isFirst, conf);
isFirst = false;
});
const classBox = g.node().getBBox();
var cssClassStr = ' ';
if (classDef.cssClasses.length > 0) {
cssClassStr = cssClassStr + classDef.cssClasses.join(' ');
}
const rect = g
.insert('rect', ':first-child')
.attr('x', 0)
.attr('y', 0)
.attr('width', classBox.width + 2 * conf.padding)
.attr('height', classBox.height + conf.padding + 0.5 * conf.dividerMargin)
.attr('class', cssClassStr);
const rectWidth = rect.node().getBBox().width;
// Center title
// We subtract the width of each text element from the class box width and divide it by 2
title.node().childNodes.forEach(function (x) {
x.setAttribute('x', (rectWidth - x.getBBox().width) / 2);
});
if (classDef.tooltip) {
title.insert('title').text(classDef.tooltip);
}
membersLine.attr('x2', rectWidth);
methodsLine.attr('x2', rectWidth);
classInfo.width = rectWidth;
classInfo.height = classBox.height + conf.padding + 0.5 * conf.dividerMargin;
return classInfo;
};
export const parseMember = function (text) {
const fieldRegEx = /^(\+|-|~|#)?(\w+)(~\w+~|\[\])?\s+(\w+) *(\*|\$)?$/;
const methodRegEx = /^([+|\-|~|#])?(\w+) *\( *(.*)\) *(\*|\$)? *(\w*[~|[\]]*\s*\w*~?)$/;
let fieldMatch = text.match(fieldRegEx);
let methodMatch = text.match(methodRegEx);
if (fieldMatch && !methodMatch) {
return buildFieldDisplay(fieldMatch);
} else if (methodMatch) {
return buildMethodDisplay(methodMatch);
} else {
return buildLegacyDisplay(text);
}
};
const buildFieldDisplay = function (parsedText) {
let cssStyle = '';
let displayText = '';
try {
let visibility = parsedText[1] ? parsedText[1].trim() : '';
let fieldType = parsedText[2] ? parsedText[2].trim() : '';
let genericType = parsedText[3] ? parseGenericTypes(parsedText[3].trim()) : '';
let fieldName = parsedText[4] ? parsedText[4].trim() : '';
let classifier = parsedText[5] ? parsedText[5].trim() : '';
displayText = visibility + fieldType + genericType + ' ' + fieldName;
cssStyle = parseClassifier(classifier);
} catch (err) {
displayText = parsedText;
}
return {
displayText: displayText,
cssStyle: cssStyle,
};
};
const buildMethodDisplay = function (parsedText) {
let cssStyle = '';
let displayText = '';
try {
let visibility = parsedText[1] ? parsedText[1].trim() : '';
let methodName = parsedText[2] ? parsedText[2].trim() : '';
let parameters = parsedText[3] ? parseGenericTypes(parsedText[3].trim()) : '';
let classifier = parsedText[4] ? parsedText[4].trim() : '';
let returnType = parsedText[5] ? ' : ' + parseGenericTypes(parsedText[5]).trim() : '';
displayText = visibility + methodName + '(' + parameters + ')' + returnType;
cssStyle = parseClassifier(classifier);
} catch (err) {
displayText = parsedText;
}
return {
displayText: displayText,
cssStyle: cssStyle,
};
};
const buildLegacyDisplay = function (text) {
// if for some reason we don't have any match, use old format to parse text
let displayText = '';
let cssStyle = '';
let returnType = '';
let methodStart = text.indexOf('(');
let methodEnd = text.indexOf(')');
if (methodStart > 1 && methodEnd > methodStart && methodEnd <= text.length) {
let visibility = '';
let methodName = '';
let firstChar = text.substring(0, 1);
if (firstChar.match(/\w/)) {
methodName = text.substring(0, methodStart).trim();
} else {
if (firstChar.match(/\+|-|~|#/)) {
visibility = firstChar;
}
methodName = text.substring(1, methodStart).trim();
}
const parameters = text.substring(methodStart + 1, methodEnd);
const classifier = text.substring(methodEnd + 1, methodEnd + 2);
cssStyle = parseClassifier(classifier);
displayText = visibility + methodName + '(' + parseGenericTypes(parameters.trim()) + ')';
if (methodEnd <= text.length) {
returnType = text.substring(methodEnd + 2).trim();
if (returnType !== '') {
returnType = ' : ' + parseGenericTypes(returnType);
displayText += returnType;
}
} else {
// finally - if all else fails, just send the text back as written (other than parsing for generic types)
displayText = parseGenericTypes(text);
}
}
return {
displayText,
cssStyle,
};
};
/**
* Adds a <tspan> for a member in a diagram
*
* @param {SVGElement} textEl The element to append to
* @param {string} txt The member
* @param {boolean} isFirst
* @param {{ padding: string; textHeight: string }} conf The configuration for the member
*/
const addTspan = function (textEl, txt, isFirst, conf) {
let member = parseMember(txt);
const tSpan = textEl.append('tspan').attr('x', conf.padding).text(member.displayText);
if (member.cssStyle !== '') {
tSpan.attr('style', member.cssStyle);
}
if (!isFirst) {
tSpan.attr('dy', conf.textHeight);
}
};
/**
* Gives the styles for a classifier
*
* @param {'+' | '-' | '#' | '~' | '*' | '$'} classifier The classifier string
* @returns {string} Styling for the classifier
*/
const parseClassifier = function (classifier) {
switch (classifier) {
case '*':
return 'font-style:italic;';
case '$':
return 'text-decoration:underline;';
default:
return '';
}
};
export default {
drawClass,
drawEdge,
parseMember,
};

View File

@@ -0,0 +1,175 @@
import svgDraw from './svgDraw';
describe('class member Renderer, ', function () {
describe('when parsing text to build method display string', function () {
it('should handle simple method declaration', function () {
const str = 'foo()';
let actual = svgDraw.parseMember(str);
expect(actual.displayText).toBe('foo()');
expect(actual.cssStyle).toBe('');
});
it('should handle public visibility', function () {
const str = '+foo()';
let actual = svgDraw.parseMember(str);
expect(actual.displayText).toBe('+foo()');
expect(actual.cssStyle).toBe('');
});
it('should handle private visibility', function () {
const str = '-foo()';
let actual = svgDraw.parseMember(str);
expect(actual.displayText).toBe('-foo()');
expect(actual.cssStyle).toBe('');
});
it('should handle protected visibility', function () {
const str = '#foo()';
let actual = svgDraw.parseMember(str);
expect(actual.displayText).toBe('#foo()');
expect(actual.cssStyle).toBe('');
});
it('should handle package/internal visibility', function () {
const str = '~foo()';
let actual = svgDraw.parseMember(str);
expect(actual.displayText).toBe('~foo()');
expect(actual.cssStyle).toBe('');
});
it('should ignore unknown character for visibility', function () {
const str = '!foo()';
let actual = svgDraw.parseMember(str);
expect(actual.displayText).toBe('foo()');
expect(actual.cssStyle).toBe('');
});
it('should handle abstract method classifier', function () {
const str = 'foo()*';
let actual = svgDraw.parseMember(str);
expect(actual.displayText).toBe('foo()');
expect(actual.cssStyle).toBe('font-style:italic;');
});
it('should handle static method classifier', function () {
const str = 'foo()$';
let actual = svgDraw.parseMember(str);
expect(actual.displayText).toBe('foo()');
expect(actual.cssStyle).toBe('text-decoration:underline;');
});
it('should ignore unknown character for classifier', function () {
const str = 'foo()!';
let actual = svgDraw.parseMember(str);
expect(actual.displayText).toBe('foo()');
expect(actual.cssStyle).toBe('');
});
it('should handle simple method declaration with parameters', function () {
const str = 'foo(int id)';
let actual = svgDraw.parseMember(str);
expect(actual.displayText).toBe('foo(int id)');
expect(actual.cssStyle).toBe('');
});
it('should handle simple method declaration with multiple parameters', function () {
const str = 'foo(int id, object thing)';
let actual = svgDraw.parseMember(str);
expect(actual.displayText).toBe('foo(int id, object thing)');
expect(actual.cssStyle).toBe('');
});
it('should handle simple method declaration with single item in parameters', function () {
const str = 'foo(id)';
let actual = svgDraw.parseMember(str);
expect(actual.displayText).toBe('foo(id)');
expect(actual.cssStyle).toBe('');
});
it('should handle simple method declaration with single item in parameters with extra spaces', function () {
const str = ' foo ( id) ';
let actual = svgDraw.parseMember(str);
expect(actual.displayText).toBe('foo(id)');
expect(actual.cssStyle).toBe('');
});
it('should handle method declaration with return value', function () {
const str = 'foo(id) int';
let actual = svgDraw.parseMember(str);
expect(actual.displayText).toBe('foo(id) : int');
expect(actual.cssStyle).toBe('');
});
it('should handle method declaration with generic return value', function () {
const str = 'foo(id) List~int~';
let actual = svgDraw.parseMember(str);
expect(actual.displayText).toBe('foo(id) : List<int>');
expect(actual.cssStyle).toBe('');
});
it('should handle method declaration with generic parameter', function () {
const str = 'foo(List~int~)';
let actual = svgDraw.parseMember(str);
expect(actual.displayText).toBe('foo(List<int>)');
expect(actual.cssStyle).toBe('');
});
it('should handle method declaration with all possible markup', function () {
const str = '+foo ( List~int~ ids )* List~Item~';
let actual = svgDraw.parseMember(str);
expect(actual.displayText).toBe('+foo(List<int> ids) : List<Item>');
expect(actual.cssStyle).toBe('font-style:italic;');
});
it('should handle method declaration with nested markup', function () {
const str = '+foo ( List~List~int~~ ids )* List~List~Item~~';
let actual = svgDraw.parseMember(str);
expect(actual.displayText).toBe('+foo(List<List<int>> ids) : List<List<Item>>');
expect(actual.cssStyle).toBe('font-style:italic;');
});
});
describe('when parsing text to build field display string', function () {
it('should handle simple field declaration', function () {
const str = 'int[] ids';
let actual = svgDraw.parseMember(str);
expect(actual.displayText).toBe('int[] ids');
expect(actual.cssStyle).toBe('');
});
it('should handle field declaration with generic type', function () {
const str = 'List~int~ ids';
let actual = svgDraw.parseMember(str);
expect(actual.displayText).toBe('List<int> ids');
expect(actual.cssStyle).toBe('');
});
it('should handle static field classifier', function () {
const str = 'String foo$';
let actual = svgDraw.parseMember(str);
expect(actual.displayText).toBe('String foo');
expect(actual.cssStyle).toBe('text-decoration:underline;');
});
});
});

View File

@@ -0,0 +1,72 @@
import { sanitizeText, removeScript, parseGenericTypes } from './common';
describe('when securityLevel is antiscript, all script must be removed', function () {
/**
* @param {string} original The original text
* @param {string} result The expected sanitized text
*/
function compareRemoveScript(original, result) {
expect(removeScript(original).trim()).toEqual(result);
}
it('should remove all script block, script inline.', function () {
const labelString = `1
Act1: Hello 1<script src="http://abc.com/script1.js"></script>1
<b>Act2</b>:
1<script>
alert('script run......');
</script>1
1`;
const exactlyString = `1
Act1: Hello 11
<b>Act2</b>:
11
1`;
compareRemoveScript(labelString, exactlyString);
});
it('should remove all javascript urls', function () {
compareRemoveScript(
`This is a <a href="javascript:runHijackingScript();">clean link</a> + <a href="javascript:runHijackingScript();">clean link</a>
and <a href="javascript&colon;bipassedMining();">me too</a>`,
`This is a <a>clean link</a> + <a>clean link</a>
and <a>me too</a>`
);
});
it('should detect malicious images', function () {
compareRemoveScript(`<img onerror="alert('hello');">`, `<img>`);
});
it('should detect iframes', function () {
compareRemoveScript(
`<iframe src="http://abc.com/script1.js"></iframe>
<iframe src="http://example.com/iframeexample"></iframe>`,
''
);
});
});
describe('Sanitize text', function () {
it('should remove script tag', function () {
const maliciousStr = 'javajavascript:script:alert(1)';
const result = sanitizeText(maliciousStr, {
securityLevel: 'strict',
flowchart: { htmlLabels: true },
});
expect(result).not.toContain('javascript:alert(1)');
});
});
describe('generic parser', function () {
it('should parse generic types', function () {
expect(parseGenericTypes('test~T~')).toEqual('test<T>');
expect(parseGenericTypes('test~Array~Array~string~~~')).toEqual('test<Array<Array<string>>>');
expect(parseGenericTypes('test~Array~Array~string[]~~~')).toEqual(
'test<Array<Array<string[]>>>'
);
expect(parseGenericTypes('test ~Array~Array~string[]~~~')).toEqual(
'test <Array<Array<string[]>>>'
);
});
});

View File

@@ -0,0 +1,166 @@
import DOMPurify from 'dompurify';
import { MermaidConfig } from '../../config.type';
/**
* Gets the rows of lines in a string
*
* @param {string | undefined} s The string to check the lines for
* @returns {string[]} The rows in that string
*/
export const getRows = (s?: string): string[] => {
if (!s) return [''];
const str = breakToPlaceholder(s).replace(/\\n/g, '#br#');
return str.split('#br#');
};
/**
* Removes script tags from a text
*
* @param {string} txt The text to sanitize
* @returns {string} The safer text
*/
export const removeScript = (txt: string): string => {
return DOMPurify.sanitize(txt);
};
const sanitizeMore = (text: string, config: MermaidConfig) => {
if (config.flowchart?.htmlLabels !== false) {
const level = config.securityLevel;
if (level === 'antiscript' || level === 'strict') {
text = removeScript(text);
} else if (level !== 'loose') {
text = breakToPlaceholder(text);
text = text.replace(/</g, '&lt;').replace(/>/g, '&gt;');
text = text.replace(/=/g, '&equals;');
text = placeholderToBreak(text);
}
}
return text;
};
export const sanitizeText = (text: string, config: MermaidConfig): string => {
if (!text) return text;
if (config.dompurifyConfig) {
text = DOMPurify.sanitize(sanitizeMore(text, config), config.dompurifyConfig).toString();
} else {
text = DOMPurify.sanitize(sanitizeMore(text, config));
}
return text;
};
export const sanitizeTextOrArray = (
a: string | string[] | string[][],
config: MermaidConfig
): string | string[] => {
if (typeof a === 'string') return sanitizeText(a, config);
// TODO: Refactor to avoid flat.
return a.flat().map((x: string) => sanitizeText(x, config));
};
export const lineBreakRegex = /<br\s*\/?>/gi;
/**
* Whether or not a text has any linebreaks
*
* @param {string} text The text to test
* @returns {boolean} Whether or not the text has breaks
*/
export const hasBreaks = (text: string): boolean => {
return lineBreakRegex.test(text);
};
/**
* Splits on <br> tags
*
* @param {string} text Text to split
* @returns {string[]} List of lines as strings
*/
export const splitBreaks = (text: string): string[] => {
return text.split(lineBreakRegex);
};
/**
* Converts placeholders to linebreaks in HTML
*
* @param {string} s HTML with placeholders
* @returns {string} HTML with breaks instead of placeholders
*/
const placeholderToBreak = (s: string): string => {
return s.replace(/#br#/g, '<br/>');
};
/**
* Opposite of `placeholderToBreak`, converts breaks to placeholders
*
* @param {string} s HTML string
* @returns {string} String with placeholders
*/
const breakToPlaceholder = (s: string): string => {
return s.replace(lineBreakRegex, '#br#');
};
/**
* Gets the current URL
*
* @param {boolean} useAbsolute Whether to return the absolute URL or not
* @returns {string} The current URL
*/
const getUrl = (useAbsolute: boolean): string => {
let url = '';
if (useAbsolute) {
url =
window.location.protocol +
'//' +
window.location.host +
window.location.pathname +
window.location.search;
url = url.replaceAll(/\(/g, '\\(');
url = url.replaceAll(/\)/g, '\\)');
}
return url;
};
/**
* Converts a string/boolean into a boolean
*
* @param {string | boolean} val String or boolean to convert
* @returns {boolean} The result from the input
*/
export const evaluate = (val?: string | boolean): boolean =>
val === false || ['false', 'null', '0'].includes(String(val).trim().toLowerCase()) ? false : true;
/**
* Makes generics in typescript syntax
*
* @example <caption>Array of array of strings in typescript syntax</caption>
* // returns "Array<Array<string>>"
* parseGenericTypes('Array~Array~string~~');
*
* @param {string} text The text to convert
* @returns {string} The converted string
*/
export const parseGenericTypes = function (text: string): string {
let cleanedText = text;
if (text.indexOf('~') !== -1) {
cleanedText = cleanedText.replace(/~([^~].*)/, '<$1');
cleanedText = cleanedText.replace(/~([^~]*)$/, '>$1');
return parseGenericTypes(cleanedText);
} else {
return cleanedText;
}
};
export default {
getRows,
sanitizeText,
sanitizeTextOrArray,
hasBreaks,
splitBreaks,
lineBreakRegex,
removeScript,
getUrl,
evaluate,
};

View File

@@ -0,0 +1,97 @@
import { log } from '../../logger';
import mermaidAPI from '../../mermaidAPI';
import * as configApi from '../../config';
import {
setAccTitle,
getAccTitle,
getAccDescription,
setAccDescription,
clear as commonClear,
} from '../../commonDb';
let entities = {};
let relationships = [];
const Cardinality = {
ZERO_OR_ONE: 'ZERO_OR_ONE',
ZERO_OR_MORE: 'ZERO_OR_MORE',
ONE_OR_MORE: 'ONE_OR_MORE',
ONLY_ONE: 'ONLY_ONE',
};
const Identification = {
NON_IDENTIFYING: 'NON_IDENTIFYING',
IDENTIFYING: 'IDENTIFYING',
};
export const parseDirective = function (statement, context, type) {
mermaidAPI.parseDirective(this, statement, context, type);
};
const addEntity = function (name) {
if (typeof entities[name] === 'undefined') {
entities[name] = { attributes: [] };
log.info('Added new entity :', name);
}
return entities[name];
};
const getEntities = () => entities;
const addAttributes = function (entityName, attribs) {
let entity = addEntity(entityName); // May do nothing (if entity has already been added)
// Process attribs in reverse order due to effect of recursive construction (last attribute is first)
let i;
for (i = attribs.length - 1; i >= 0; i--) {
entity.attributes.push(attribs[i]);
log.debug('Added attribute ', attribs[i].attributeName);
}
};
/**
* Add a relationship
*
* @param entA The first entity in the relationship
* @param rolA The role played by the first entity in relation to the second
* @param entB The second entity in the relationship
* @param rSpec The details of the relationship between the two entities
*/
const addRelationship = function (entA, rolA, entB, rSpec) {
let rel = {
entityA: entA,
roleA: rolA,
entityB: entB,
relSpec: rSpec,
};
relationships.push(rel);
log.debug('Added new relationship :', rel);
};
const getRelationships = () => relationships;
const clear = function () {
entities = {};
relationships = [];
commonClear();
};
export default {
Cardinality,
Identification,
parseDirective,
getConfig: () => configApi.getConfig().er,
addEntity,
addAttributes,
getEntities,
addRelationship,
getRelationships,
clear,
setAccTitle,
getAccTitle,
setAccDescription,
getAccDescription,
};

View File

@@ -0,0 +1,5 @@
import type { DiagramDetector } from '../../diagram-api/detectType';
export const erDetector: DiagramDetector = (txt) => {
return txt.match(/^\s*erDiagram/) !== null;
};

View File

@@ -0,0 +1,163 @@
const ERMarkers = {
ONLY_ONE_START: 'ONLY_ONE_START',
ONLY_ONE_END: 'ONLY_ONE_END',
ZERO_OR_ONE_START: 'ZERO_OR_ONE_START',
ZERO_OR_ONE_END: 'ZERO_OR_ONE_END',
ONE_OR_MORE_START: 'ONE_OR_MORE_START',
ONE_OR_MORE_END: 'ONE_OR_MORE_END',
ZERO_OR_MORE_START: 'ZERO_OR_MORE_START',
ZERO_OR_MORE_END: 'ZERO_OR_MORE_END',
};
/**
* Put the markers into the svg DOM for later use with edge paths
*
* @param elem
* @param conf
*/
const insertMarkers = function (elem, conf) {
let marker;
elem
.append('defs')
.append('marker')
.attr('id', ERMarkers.ONLY_ONE_START)
.attr('refX', 0)
.attr('refY', 9)
.attr('markerWidth', 18)
.attr('markerHeight', 18)
.attr('orient', 'auto')
.append('path')
.attr('stroke', conf.stroke)
.attr('fill', 'none')
.attr('d', 'M9,0 L9,18 M15,0 L15,18');
elem
.append('defs')
.append('marker')
.attr('id', ERMarkers.ONLY_ONE_END)
.attr('refX', 18)
.attr('refY', 9)
.attr('markerWidth', 18)
.attr('markerHeight', 18)
.attr('orient', 'auto')
.append('path')
.attr('stroke', conf.stroke)
.attr('fill', 'none')
.attr('d', 'M3,0 L3,18 M9,0 L9,18');
marker = elem
.append('defs')
.append('marker')
.attr('id', ERMarkers.ZERO_OR_ONE_START)
.attr('refX', 0)
.attr('refY', 9)
.attr('markerWidth', 30)
.attr('markerHeight', 18)
.attr('orient', 'auto');
marker
.append('circle')
.attr('stroke', conf.stroke)
.attr('fill', 'white')
.attr('cx', 21)
.attr('cy', 9)
.attr('r', 6);
marker.append('path').attr('stroke', conf.stroke).attr('fill', 'none').attr('d', 'M9,0 L9,18');
marker = elem
.append('defs')
.append('marker')
.attr('id', ERMarkers.ZERO_OR_ONE_END)
.attr('refX', 30)
.attr('refY', 9)
.attr('markerWidth', 30)
.attr('markerHeight', 18)
.attr('orient', 'auto');
marker
.append('circle')
.attr('stroke', conf.stroke)
.attr('fill', 'white')
.attr('cx', 9)
.attr('cy', 9)
.attr('r', 6);
marker.append('path').attr('stroke', conf.stroke).attr('fill', 'none').attr('d', 'M21,0 L21,18');
elem
.append('defs')
.append('marker')
.attr('id', ERMarkers.ONE_OR_MORE_START)
.attr('refX', 18)
.attr('refY', 18)
.attr('markerWidth', 45)
.attr('markerHeight', 36)
.attr('orient', 'auto')
.append('path')
.attr('stroke', conf.stroke)
.attr('fill', 'none')
.attr('d', 'M0,18 Q 18,0 36,18 Q 18,36 0,18 M42,9 L42,27');
elem
.append('defs')
.append('marker')
.attr('id', ERMarkers.ONE_OR_MORE_END)
.attr('refX', 27)
.attr('refY', 18)
.attr('markerWidth', 45)
.attr('markerHeight', 36)
.attr('orient', 'auto')
.append('path')
.attr('stroke', conf.stroke)
.attr('fill', 'none')
.attr('d', 'M3,9 L3,27 M9,18 Q27,0 45,18 Q27,36 9,18');
marker = elem
.append('defs')
.append('marker')
.attr('id', ERMarkers.ZERO_OR_MORE_START)
.attr('refX', 18)
.attr('refY', 18)
.attr('markerWidth', 57)
.attr('markerHeight', 36)
.attr('orient', 'auto');
marker
.append('circle')
.attr('stroke', conf.stroke)
.attr('fill', 'white')
.attr('cx', 48)
.attr('cy', 18)
.attr('r', 6);
marker
.append('path')
.attr('stroke', conf.stroke)
.attr('fill', 'none')
.attr('d', 'M0,18 Q18,0 36,18 Q18,36 0,18');
marker = elem
.append('defs')
.append('marker')
.attr('id', ERMarkers.ZERO_OR_MORE_END)
.attr('refX', 39)
.attr('refY', 18)
.attr('markerWidth', 57)
.attr('markerHeight', 36)
.attr('orient', 'auto');
marker
.append('circle')
.attr('stroke', conf.stroke)
.attr('fill', 'white')
.attr('cx', 9)
.attr('cy', 18)
.attr('r', 6);
marker
.append('path')
.attr('stroke', conf.stroke)
.attr('fill', 'none')
.attr('d', 'M21,18 Q39,0 57,18 Q39,36 21,18');
return;
};
export default {
ERMarkers,
insertMarkers,
};

View File

@@ -0,0 +1,653 @@
import graphlib from 'graphlib';
import { line, curveBasis, select } from 'd3';
// import erDb from './erDb';
// import erParser from './parser/erDiagram';
import dagre from 'dagre';
import { getConfig } from '../../config';
import { log } from '../../logger';
import erMarkers from './erMarkers';
import { configureSvgSize } from '../../setupGraphViewbox';
import addSVGAccessibilityFields from '../../accessibility';
import { parseGenericTypes } from '../common/common';
let conf = {};
/**
* Allows the top-level API module to inject config specific to this renderer, storing it in the
* local conf object. Note that generic config still needs to be retrieved using getConfig()
* imported from the config module
*
* @param cnf
*/
export const setConf = function (cnf) {
const keys = Object.keys(cnf);
for (let i = 0; i < keys.length; i++) {
conf[keys[i]] = cnf[keys[i]];
}
};
/**
* Draw attributes for an entity
*
* @param groupNode The svg group node for the entity
* @param entityTextNode The svg node for the entity label text
* @param attributes An array of attributes defined for the entity (each attribute has a type and a name)
* @returns {object} The bounding box of the entity, after attributes have been added. The bounding box has a .width and .height
*/
const drawAttributes = (groupNode, entityTextNode, attributes) => {
const heightPadding = conf.entityPadding / 3; // Padding internal to attribute boxes
const widthPadding = conf.entityPadding / 3; // Ditto
const attrFontSize = conf.fontSize * 0.85;
const labelBBox = entityTextNode.node().getBBox();
const attributeNodes = []; // Intermediate storage for attribute nodes created so that we can do a second pass
let hasKeyType = false;
let hasComment = false;
let maxTypeWidth = 0;
let maxNameWidth = 0;
let maxKeyWidth = 0;
let maxCommentWidth = 0;
let cumulativeHeight = labelBBox.height + heightPadding * 2;
let attrNum = 1;
// Check to see if any of the attributes has a key or a comment
attributes.forEach((item) => {
if (item.attributeKeyType !== undefined) {
hasKeyType = true;
}
if (item.attributeComment !== undefined) {
hasComment = true;
}
});
attributes.forEach((item) => {
const attrPrefix = `${entityTextNode.node().id}-attr-${attrNum}`;
let nodeHeight = 0;
const attributeType = parseGenericTypes(item.attributeType);
// Add a text node for the attribute type
const typeNode = groupNode
.append('text')
.attr('class', 'er entityLabel')
.attr('id', `${attrPrefix}-type`)
.attr('x', 0)
.attr('y', 0)
.attr('dominant-baseline', 'middle')
.attr('text-anchor', 'left')
.attr(
'style',
'font-family: ' + getConfig().fontFamily + '; font-size: ' + attrFontSize + 'px'
)
.text(attributeType);
// Add a text node for the attribute name
const nameNode = groupNode
.append('text')
.attr('class', 'er entityLabel')
.attr('id', `${attrPrefix}-name`)
.attr('x', 0)
.attr('y', 0)
.attr('dominant-baseline', 'middle')
.attr('text-anchor', 'left')
.attr(
'style',
'font-family: ' + getConfig().fontFamily + '; font-size: ' + attrFontSize + 'px'
)
.text(item.attributeName);
const attributeNode = {};
attributeNode.tn = typeNode;
attributeNode.nn = nameNode;
const typeBBox = typeNode.node().getBBox();
const nameBBox = nameNode.node().getBBox();
maxTypeWidth = Math.max(maxTypeWidth, typeBBox.width);
maxNameWidth = Math.max(maxNameWidth, nameBBox.width);
nodeHeight = Math.max(typeBBox.height, nameBBox.height);
if (hasKeyType) {
const keyTypeNode = groupNode
.append('text')
.attr('class', 'er entityLabel')
.attr('id', `${attrPrefix}-key`)
.attr('x', 0)
.attr('y', 0)
.attr('dominant-baseline', 'middle')
.attr('text-anchor', 'left')
.attr(
'style',
'font-family: ' + getConfig().fontFamily + '; font-size: ' + attrFontSize + 'px'
)
.text(item.attributeKeyType || '');
attributeNode.kn = keyTypeNode;
const keyTypeBBox = keyTypeNode.node().getBBox();
maxKeyWidth = Math.max(maxKeyWidth, keyTypeBBox.width);
nodeHeight = Math.max(nodeHeight, keyTypeBBox.height);
}
if (hasComment) {
const commentNode = groupNode
.append('text')
.attr('class', 'er entityLabel')
.attr('id', `${attrPrefix}-comment`)
.attr('x', 0)
.attr('y', 0)
.attr('dominant-baseline', 'middle')
.attr('text-anchor', 'left')
.attr(
'style',
'font-family: ' + getConfig().fontFamily + '; font-size: ' + attrFontSize + 'px'
)
.text(item.attributeComment || '');
attributeNode.cn = commentNode;
const commentNodeBBox = commentNode.node().getBBox();
maxCommentWidth = Math.max(maxCommentWidth, commentNodeBBox.width);
nodeHeight = Math.max(nodeHeight, commentNodeBBox.height);
}
attributeNode.height = nodeHeight;
// Keep a reference to the nodes so that we can iterate through them later
attributeNodes.push(attributeNode);
cumulativeHeight += nodeHeight + heightPadding * 2;
attrNum += 1;
});
let widthPaddingFactor = 4;
if (hasKeyType) {
widthPaddingFactor += 2;
}
if (hasComment) {
widthPaddingFactor += 2;
}
const maxWidth = maxTypeWidth + maxNameWidth + maxKeyWidth + maxCommentWidth;
// Calculate the new bounding box of the overall entity, now that attributes have been added
const bBox = {
width: Math.max(
conf.minEntityWidth,
Math.max(
labelBBox.width + conf.entityPadding * 2,
maxWidth + widthPadding * widthPaddingFactor
)
),
height:
attributes.length > 0
? cumulativeHeight
: Math.max(conf.minEntityHeight, labelBBox.height + conf.entityPadding * 2),
};
if (attributes.length > 0) {
// There might be some spare width for padding out attributes if the entity name is very long
const spareColumnWidth = Math.max(
0,
(bBox.width - maxWidth - widthPadding * widthPaddingFactor) / (widthPaddingFactor / 2)
);
// Position the entity label near the top of the entity bounding box
entityTextNode.attr(
'transform',
'translate(' + bBox.width / 2 + ',' + (heightPadding + labelBBox.height / 2) + ')'
);
// Add rectangular boxes for the attribute types/names
let heightOffset = labelBBox.height + heightPadding * 2; // Start at the bottom of the entity label
let attribStyle = 'attributeBoxOdd'; // We will flip the style on alternate rows to achieve a banded effect
attributeNodes.forEach((attributeNode) => {
// Calculate the alignment y co-ordinate for the type/name of the attribute
const alignY = heightOffset + heightPadding + attributeNode.height / 2;
// Position the type attribute
attributeNode.tn.attr('transform', 'translate(' + widthPadding + ',' + alignY + ')');
// TODO Handle spareWidth in attr('width')
// Insert a rectangle for the type
const typeRect = groupNode
.insert('rect', '#' + attributeNode.tn.node().id)
.attr('class', `er ${attribStyle}`)
.attr('fill', conf.fill)
.attr('fill-opacity', '100%')
.attr('stroke', conf.stroke)
.attr('x', 0)
.attr('y', heightOffset)
.attr('width', maxTypeWidth + widthPadding * 2 + spareColumnWidth)
.attr('height', attributeNode.height + heightPadding * 2);
const nameXOffset = parseFloat(typeRect.attr('x')) + parseFloat(typeRect.attr('width'));
// Position the name attribute
attributeNode.nn.attr(
'transform',
'translate(' + (nameXOffset + widthPadding) + ',' + alignY + ')'
);
// Insert a rectangle for the name
const nameRect = groupNode
.insert('rect', '#' + attributeNode.nn.node().id)
.attr('class', `er ${attribStyle}`)
.attr('fill', conf.fill)
.attr('fill-opacity', '100%')
.attr('stroke', conf.stroke)
.attr('x', nameXOffset)
.attr('y', heightOffset)
.attr('width', maxNameWidth + widthPadding * 2 + spareColumnWidth)
.attr('height', attributeNode.height + heightPadding * 2);
let keyTypeAndCommentXOffset =
parseFloat(nameRect.attr('x')) + parseFloat(nameRect.attr('width'));
if (hasKeyType) {
// Position the key type attribute
attributeNode.kn.attr(
'transform',
'translate(' + (keyTypeAndCommentXOffset + widthPadding) + ',' + alignY + ')'
);
// Insert a rectangle for the key type
const keyTypeRect = groupNode
.insert('rect', '#' + attributeNode.kn.node().id)
.attr('class', `er ${attribStyle}`)
.attr('fill', conf.fill)
.attr('fill-opacity', '100%')
.attr('stroke', conf.stroke)
.attr('x', keyTypeAndCommentXOffset)
.attr('y', heightOffset)
.attr('width', maxKeyWidth + widthPadding * 2 + spareColumnWidth)
.attr('height', attributeNode.height + heightPadding * 2);
keyTypeAndCommentXOffset =
parseFloat(keyTypeRect.attr('x')) + parseFloat(keyTypeRect.attr('width'));
}
if (hasComment) {
// Position the comment attribute
attributeNode.cn.attr(
'transform',
'translate(' + (keyTypeAndCommentXOffset + widthPadding) + ',' + alignY + ')'
);
// Insert a rectangle for the comment
groupNode
.insert('rect', '#' + attributeNode.cn.node().id)
.attr('class', `er ${attribStyle}`)
.attr('fill', conf.fill)
.attr('fill-opacity', '100%')
.attr('stroke', conf.stroke)
.attr('x', keyTypeAndCommentXOffset)
.attr('y', heightOffset)
.attr('width', maxCommentWidth + widthPadding * 2 + spareColumnWidth)
.attr('height', attributeNode.height + heightPadding * 2);
}
// Increment the height offset to move to the next row
heightOffset += attributeNode.height + heightPadding * 2;
// Flip the attribute style for row banding
attribStyle = attribStyle == 'attributeBoxOdd' ? 'attributeBoxEven' : 'attributeBoxOdd';
});
} else {
// Ensure the entity box is a decent size without any attributes
bBox.height = Math.max(conf.minEntityHeight, cumulativeHeight);
// Position the entity label in the middle of the box
entityTextNode.attr('transform', 'translate(' + bBox.width / 2 + ',' + bBox.height / 2 + ')');
}
return bBox;
};
/**
* Use D3 to construct the svg elements for the entities
*
* @param svgNode The svg node that contains the diagram
* @param entities The entities to be drawn
* @param graph The graph that contains the vertex and edge definitions post-layout
* @returns {object} The first entity that was inserted
*/
const drawEntities = function (svgNode, entities, graph) {
const keys = Object.keys(entities);
let firstOne;
keys.forEach(function (id) {
// Create a group for each entity
const groupNode = svgNode.append('g').attr('id', id);
firstOne = firstOne === undefined ? id : firstOne;
// Label the entity - this is done first so that we can get the bounding box
// which then determines the size of the rectangle
const textId = 'entity-' + id;
const textNode = groupNode
.append('text')
.attr('class', 'er entityLabel')
.attr('id', textId)
.attr('x', 0)
.attr('y', 0)
.attr('dominant-baseline', 'middle')
.attr('text-anchor', 'middle')
.attr(
'style',
'font-family: ' + getConfig().fontFamily + '; font-size: ' + conf.fontSize + 'px'
)
.text(id);
const { width: entityWidth, height: entityHeight } = drawAttributes(
groupNode,
textNode,
entities[id].attributes
);
// Draw the rectangle - insert it before the text so that the text is not obscured
const rectNode = groupNode
.insert('rect', '#' + textId)
.attr('class', 'er entityBox')
.attr('fill', conf.fill)
.attr('fill-opacity', '100%')
.attr('stroke', conf.stroke)
.attr('x', 0)
.attr('y', 0)
.attr('width', entityWidth)
.attr('height', entityHeight);
const rectBBox = rectNode.node().getBBox();
// Add the entity to the graph
graph.setNode(id, {
width: rectBBox.width,
height: rectBBox.height,
shape: 'rect',
id: id,
});
});
return firstOne;
}; // drawEntities
const adjustEntities = function (svgNode, graph) {
graph.nodes().forEach(function (v) {
if (typeof v !== 'undefined' && typeof graph.node(v) !== 'undefined') {
svgNode
.select('#' + v)
.attr(
'transform',
'translate(' +
(graph.node(v).x - graph.node(v).width / 2) +
',' +
(graph.node(v).y - graph.node(v).height / 2) +
' )'
);
}
});
return;
};
const getEdgeName = function (rel) {
return (rel.entityA + rel.roleA + rel.entityB).replace(/\s/g, '');
};
/**
* Add each relationship to the graph
*
* @param relationships The relationships to be added
* @param g The graph
* @returns {Array} The array of relationships
*/
const addRelationships = function (relationships, g) {
relationships.forEach(function (r) {
g.setEdge(r.entityA, r.entityB, { relationship: r }, getEdgeName(r));
});
return relationships;
}; // addRelationships
let relCnt = 0;
/**
* Draw a relationship using edge information from the graph
*
* @param svg The svg node
* @param rel The relationship to draw in the svg
* @param g The graph containing the edge information
* @param insert The insertion point in the svg DOM (because relationships have markers that need to
* sit 'behind' opaque entity boxes)
* @param diagObj
*/
const drawRelationshipFromLayout = function (svg, rel, g, insert, diagObj) {
relCnt++;
// Find the edge relating to this relationship
const edge = g.edge(rel.entityA, rel.entityB, getEdgeName(rel));
// Get a function that will generate the line path
const lineFunction = line()
.x(function (d) {
return d.x;
})
.y(function (d) {
return d.y;
})
.curve(curveBasis);
// Insert the line at the right place
const svgPath = svg
.insert('path', '#' + insert)
.attr('class', 'er relationshipLine')
.attr('d', lineFunction(edge.points))
.attr('stroke', conf.stroke)
.attr('fill', 'none');
// ...and with dashes if necessary
if (rel.relSpec.relType === diagObj.db.Identification.NON_IDENTIFYING) {
svgPath.attr('stroke-dasharray', '8,8');
}
// TODO: Understand this better
let url = '';
if (conf.arrowMarkerAbsolute) {
url =
window.location.protocol +
'//' +
window.location.host +
window.location.pathname +
window.location.search;
url = url.replace(/\(/g, '\\(');
url = url.replace(/\)/g, '\\)');
}
// Decide which start and end markers it needs. It may be possible to be more concise here
// by reversing a start marker to make an end marker...but this will do for now
// Note that the 'A' entity's marker is at the end of the relationship and the 'B' entity's marker is at the start
switch (rel.relSpec.cardA) {
case diagObj.db.Cardinality.ZERO_OR_ONE:
svgPath.attr('marker-end', 'url(' + url + '#' + erMarkers.ERMarkers.ZERO_OR_ONE_END + ')');
break;
case diagObj.db.Cardinality.ZERO_OR_MORE:
svgPath.attr('marker-end', 'url(' + url + '#' + erMarkers.ERMarkers.ZERO_OR_MORE_END + ')');
break;
case diagObj.db.Cardinality.ONE_OR_MORE:
svgPath.attr('marker-end', 'url(' + url + '#' + erMarkers.ERMarkers.ONE_OR_MORE_END + ')');
break;
case diagObj.db.Cardinality.ONLY_ONE:
svgPath.attr('marker-end', 'url(' + url + '#' + erMarkers.ERMarkers.ONLY_ONE_END + ')');
break;
}
switch (rel.relSpec.cardB) {
case diagObj.db.Cardinality.ZERO_OR_ONE:
svgPath.attr(
'marker-start',
'url(' + url + '#' + erMarkers.ERMarkers.ZERO_OR_ONE_START + ')'
);
break;
case diagObj.db.Cardinality.ZERO_OR_MORE:
svgPath.attr(
'marker-start',
'url(' + url + '#' + erMarkers.ERMarkers.ZERO_OR_MORE_START + ')'
);
break;
case diagObj.db.Cardinality.ONE_OR_MORE:
svgPath.attr(
'marker-start',
'url(' + url + '#' + erMarkers.ERMarkers.ONE_OR_MORE_START + ')'
);
break;
case diagObj.db.Cardinality.ONLY_ONE:
svgPath.attr('marker-start', 'url(' + url + '#' + erMarkers.ERMarkers.ONLY_ONE_START + ')');
break;
}
// Now label the relationship
// Find the half-way point
const len = svgPath.node().getTotalLength();
const labelPoint = svgPath.node().getPointAtLength(len * 0.5);
// Append a text node containing the label
const labelId = 'rel' + relCnt;
const labelNode = svg
.append('text')
.attr('class', 'er relationshipLabel')
.attr('id', labelId)
.attr('x', labelPoint.x)
.attr('y', labelPoint.y)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'middle')
.attr(
'style',
'font-family: ' + getConfig().fontFamily + '; font-size: ' + conf.fontSize + 'px'
)
.text(rel.roleA);
// Figure out how big the opaque 'container' rectangle needs to be
const labelBBox = labelNode.node().getBBox();
// Insert the opaque rectangle before the text label
svg
.insert('rect', '#' + labelId)
.attr('class', 'er relationshipLabelBox')
.attr('x', labelPoint.x - labelBBox.width / 2)
.attr('y', labelPoint.y - labelBBox.height / 2)
.attr('width', labelBBox.width)
.attr('height', labelBBox.height)
.attr('fill', 'white')
.attr('fill-opacity', '85%');
return;
};
/**
* Draw en E-R diagram in the tag with id: id based on the text definition of the diagram
*
* @param text The text of the diagram
* @param id The unique id of the DOM node that contains the diagram
* @param _version
* @param diagObj
*/
export const draw = function (text, id, _version, diagObj) {
conf = getConfig().er;
log.info('Drawing ER diagram');
// diag.db.clear();
const securityLevel = getConfig().securityLevel;
// Handle root and Document for when rendering in sanbox mode
let sandboxElement;
if (securityLevel === 'sandbox') {
sandboxElement = select('#i' + id);
}
const root =
securityLevel === 'sandbox'
? select(sandboxElement.nodes()[0].contentDocument.body)
: select('body');
// const doc = securityLevel === 'sandbox' ? sandboxElement.nodes()[0].contentDocument : document;
// Parse the text to populate erDb
// try {
// parser.parse(text);
// } catch (err) {
// log.debug('Parsing failed');
// }
// Get a reference to the svg node that contains the text
const svg = root.select(`[id='${id}']`);
// Add cardinality marker definitions to the svg
erMarkers.insertMarkers(svg, conf);
// Now we have to construct the diagram in a specific way:
// ---
// 1. Create all the entities in the svg node at 0,0, but with the correct dimensions (allowing for text content)
// 2. Make sure they are all added to the graph
// 3. Add all the edges (relationships) to the graph as well
// 4. Let dagre do its magic to layout the graph. This assigns:
// - the centre co-ordinates for each node, bearing in mind the dimensions and edge relationships
// - the path co-ordinates for each edge
// But it has no impact on the svg child nodes - the diagram remains with every entity rooted at 0,0
// 5. Now assign a transform to each entity in the svg node so that it gets drawn in the correct place, as determined by
// its centre point, which is obtained from the graph, and it's width and height
// 6. And finally, create all the edges in the svg node using information from the graph
// ---
// Create the graph
let g;
// TODO: Explore directed vs undirected graphs, and how the layout is affected
// An E-R diagram could be said to be undirected, but there is merit in setting
// the direction from parent to child in a one-to-many as this influences graphlib to
// put the parent above the child (does it?), which is intuitive. Most relationships
// in ER diagrams are one-to-many.
g = new graphlib.Graph({
multigraph: true,
directed: true,
compound: false,
})
.setGraph({
rankdir: conf.layoutDirection,
marginx: 20,
marginy: 20,
nodesep: 100,
edgesep: 100,
ranksep: 100,
})
.setDefaultEdgeLabel(function () {
return {};
});
// Draw the entities (at 0,0), returning the first svg node that got
// inserted - this represents the insertion point for relationship paths
const firstEntity = drawEntities(svg, diagObj.db.getEntities(), g);
// TODO: externalise the addition of entities to the graph - it's a bit 'buried' in the above
// Add all the relationships to the graph
const relationships = addRelationships(diagObj.db.getRelationships(), g);
dagre.layout(g); // Node and edge positions will be updated
// Adjust the positions of the entities so that they adhere to the layout
adjustEntities(svg, g);
// Draw the relationships
relationships.forEach(function (rel) {
drawRelationshipFromLayout(svg, rel, g, firstEntity, diagObj);
});
const padding = conf.diagramPadding;
const svgBounds = svg.node().getBBox();
const width = svgBounds.width + padding * 2;
const height = svgBounds.height + padding * 2;
configureSvgSize(svg, height, width, conf.useMaxWidth);
svg.attr('viewBox', `${svgBounds.x - padding} ${svgBounds.y - padding} ${width} ${height}`);
addSVGAccessibilityFields(diagObj.db, svg, id);
}; // draw
export default {
setConf,
draw,
};

View File

@@ -0,0 +1,178 @@
%lex
%options case-insensitive
%x open_directive type_directive arg_directive block
%x acc_title
%x acc_descr
%x acc_descr_multiline
%%
accTitle\s*":"\s* { this.begin("acc_title");return 'acc_title'; }
<acc_title>(?!\n|;|#)*[^\n]* { this.popState(); return "acc_title_value"; }
accDescr\s*":"\s* { this.begin("acc_descr");return 'acc_descr'; }
<acc_descr>(?!\n|;|#)*[^\n]* { this.popState(); return "acc_descr_value"; }
accDescr\s*"{"\s* { this.begin("acc_descr_multiline");}
<acc_descr_multiline>[\}] { this.popState(); }
<acc_descr_multiline>[^\}]* return "acc_descr_multiline_value";
\%\%\{ { this.begin('open_directive'); return 'open_directive'; }
<open_directive>((?:(?!\}\%\%)[^:.])*) { this.begin('type_directive'); return 'type_directive'; }
<type_directive>":" { this.popState(); this.begin('arg_directive'); return ':'; }
<type_directive,arg_directive>\}\%\% { this.popState(); this.popState(); return 'close_directive'; }
<arg_directive>((?:(?!\}\%\%).|\n)*) return 'arg_directive';
\%%(?!\{)[^\n]* /* skip comments */
[^\}]\%\%[^\n]* /* skip comments */
[\n]+ return 'NEWLINE';
\s+ /* skip whitespace */
[\s]+ return 'SPACE';
\"[^"]*\" return 'WORD';
"erDiagram" return 'ER_DIAGRAM';
"{" { this.begin("block"); return 'BLOCK_START'; }
<block>\s+ /* skip whitespace in block */
<block>\b((?:PK)|(?:FK))\b return 'ATTRIBUTE_KEY'
<block>(.*?)[~](.*?)*[~] return 'ATTRIBUTE_WORD';
<block>[A-Za-z][A-Za-z0-9\-_\[\]]* return 'ATTRIBUTE_WORD'
<block>\"[^"]*\" return 'COMMENT';
<block>[\n]+ /* nothing */
<block>"}" { this.popState(); return 'BLOCK_STOP'; }
<block>. return yytext[0];
\|o return 'ZERO_OR_ONE';
\}o return 'ZERO_OR_MORE';
\}\| return 'ONE_OR_MORE';
\|\| return 'ONLY_ONE';
o\| return 'ZERO_OR_ONE';
o\{ return 'ZERO_OR_MORE';
\|\{ return 'ONE_OR_MORE';
\.\. return 'NON_IDENTIFYING';
\-\- return 'IDENTIFYING';
\.\- return 'NON_IDENTIFYING';
\-\. return 'NON_IDENTIFYING';
[A-Za-z][A-Za-z0-9\-_]* return 'ALPHANUM';
. return yytext[0];
<<EOF>> return 'EOF';
/lex
%start start
%% /* language grammar */
start
: 'ER_DIAGRAM' document 'EOF' { /*console.log('finished parsing');*/ }
| directive start
;
document
: /* empty */ { $$ = [] }
| document line {$1.push($2);$$ = $1}
;
line
: SPACE statement { $$ = $2 }
| statement { $$ = $1 }
| NEWLINE { $$=[];}
| EOF { $$=[];}
;
directive
: openDirective typeDirective closeDirective 'NEWLINE'
| openDirective typeDirective ':' argDirective closeDirective 'NEWLINE'
;
statement
: directive
| entityName relSpec entityName ':' role
{
yy.addEntity($1);
yy.addEntity($3);
yy.addRelationship($1, $5, $3, $2);
/*console.log($1 + $2 + $3 + ':' + $5);*/
}
| entityName BLOCK_START attributes BLOCK_STOP
{
/* console.log('detected block'); */
yy.addEntity($1);
yy.addAttributes($1, $3);
/* console.log('handled block'); */
}
| entityName BLOCK_START BLOCK_STOP { yy.addEntity($1); }
| entityName { yy.addEntity($1); }
| title title_value { $$=$2.trim();yy.setAccTitle($$); }
| acc_title acc_title_value { $$=$2.trim();yy.setAccTitle($$); }
| acc_descr acc_descr_value { $$=$2.trim();yy.setAccDescription($$); }
| acc_descr_multiline_value { $$=$1.trim();yy.setAccDescription($$); }
;
entityName
: 'ALPHANUM' { $$ = $1; /*console.log('Entity: ' + $1);*/ }
| 'ALPHANUM' '.' entityName { $$ = $1 + $2 + $3; }
;
attributes
: attribute { $$ = [$1]; }
| attribute attributes { $2.push($1); $$=$2; }
;
attribute
: attributeType attributeName { $$ = { attributeType: $1, attributeName: $2 }; }
| attributeType attributeName attributeKeyType { $$ = { attributeType: $1, attributeName: $2, attributeKeyType: $3 }; }
| attributeType attributeName attributeComment { $$ = { attributeType: $1, attributeName: $2, attributeComment: $3 }; }
| attributeType attributeName attributeKeyType attributeComment { $$ = { attributeType: $1, attributeName: $2, attributeKeyType: $3, attributeComment: $4 }; }
;
attributeType
: ATTRIBUTE_WORD { $$=$1; }
;
attributeName
: ATTRIBUTE_WORD { $$=$1; }
;
attributeKeyType
: ATTRIBUTE_KEY { $$=$1; }
;
attributeComment
: COMMENT { $$=$1.replace(/"/g, ''); }
;
relSpec
: cardinality relType cardinality
{
$$ = { cardA: $3, relType: $2, cardB: $1 };
/*console.log('relSpec: ' + $3 + $2 + $1);*/
}
;
cardinality
: 'ZERO_OR_ONE' { $$ = yy.Cardinality.ZERO_OR_ONE; }
| 'ZERO_OR_MORE' { $$ = yy.Cardinality.ZERO_OR_MORE; }
| 'ONE_OR_MORE' { $$ = yy.Cardinality.ONE_OR_MORE; }
| 'ONLY_ONE' { $$ = yy.Cardinality.ONLY_ONE; }
;
relType
: 'NON_IDENTIFYING' { $$ = yy.Identification.NON_IDENTIFYING; }
| 'IDENTIFYING' { $$ = yy.Identification.IDENTIFYING; }
;
role
: 'WORD' { $$ = $1.replace(/"/g, ''); }
| 'ALPHANUM' { $$ = $1; }
;
openDirective
: open_directive { yy.parseDirective('%%{', 'open_directive'); }
;
typeDirective
: type_directive { yy.parseDirective($1, 'type_directive'); }
;
argDirective
: arg_directive { $1 = $1.trim().replace(/'/g, '"'); yy.parseDirective($1, 'arg_directive'); }
;
closeDirective
: close_directive { yy.parseDirective('}%%', 'close_directive', 'er'); }
;
%%

View File

@@ -0,0 +1,473 @@
import { setConfig } from '../../../config';
import erDb from '../erDb';
import erDiagram from './erDiagram';
setConfig({
securityLevel: 'strict',
});
describe('when parsing ER diagram it...', function () {
beforeEach(function () {
erDiagram.parser.yy = erDb;
erDiagram.parser.yy.clear();
});
it('should allow stand-alone entities with no relationships', function () {
const line1 = 'ISLAND';
const line2 = 'MAINLAND';
erDiagram.parser.parse(`erDiagram\n${line1}\n${line2}`);
expect(Object.keys(erDb.getEntities()).length).toBe(2);
expect(erDb.getRelationships().length).toBe(0);
});
it('should allow hyphens and underscores in entity names', function () {
const line1 = 'DUCK-BILLED-PLATYPUS';
const line2 = 'CHARACTER_SET';
erDiagram.parser.parse(`erDiagram\n${line1}\n${line2}`);
const entities = erDb.getEntities();
expect(entities.hasOwnProperty('DUCK-BILLED-PLATYPUS')).toBe(true);
expect(entities.hasOwnProperty('CHARACTER_SET')).toBe(true);
});
it('should allow an entity with a single attribute to be defined', function () {
const entity = 'BOOK';
const attribute = 'string title';
erDiagram.parser.parse(`erDiagram\n${entity} {\n${attribute}\n}`);
const entities = erDb.getEntities();
expect(Object.keys(entities).length).toBe(1);
expect(entities[entity].attributes.length).toBe(1);
});
it('should allow an entity with a single attribute to be defined with a key', function () {
const entity = 'BOOK';
const attribute = 'string title PK';
erDiagram.parser.parse(`erDiagram\n${entity} {\n${attribute}\n}`);
const entities = erDb.getEntities();
expect(Object.keys(entities).length).toBe(1);
expect(entities[entity].attributes.length).toBe(1);
});
it('should allow an entity with a single attribute to be defined with a comment', function () {
const entity = 'BOOK';
const attribute = `string title "comment"`;
erDiagram.parser.parse(`erDiagram\n${entity} {\n${attribute}\n}`);
const entities = erDb.getEntities();
expect(Object.keys(entities).length).toBe(1);
expect(entities[entity].attributes.length).toBe(1);
expect(entities[entity].attributes[0].attributeComment).toBe('comment');
});
it('should allow an entity with a single attribute to be defined with a key and a comment', function () {
const entity = 'BOOK';
const attribute = `string title PK "comment"`;
erDiagram.parser.parse(`erDiagram\n${entity} {\n${attribute}\n}`);
const entities = erDb.getEntities();
expect(Object.keys(entities).length).toBe(1);
expect(entities[entity].attributes.length).toBe(1);
});
it('should allow an entity with attribute starting with fk or pk and a comment', function () {
const entity = 'BOOK';
const attribute1 = 'int fk_title FK';
const attribute2 = 'string pk_author PK';
const attribute3 = 'float pk_price PK "comment"';
erDiagram.parser.parse(
`erDiagram\n${entity} {\n${attribute1} \n\n${attribute2}\n${attribute3}\n}`
);
const entities = erDb.getEntities();
expect(entities[entity].attributes.length).toBe(3);
});
it('should allow an entity with attribute that has a generic type', function () {
const entity = 'BOOK';
const attribute1 = 'type~T~ type';
const attribute2 = 'option~T~ readable "comment"';
const attribute3 = 'string id PK';
erDiagram.parser.parse(
`erDiagram\n${entity} {\n${attribute1}\n${attribute2}\n${attribute3}\n}`
);
const entities = erDb.getEntities();
expect(Object.keys(entities).length).toBe(1);
expect(entities[entity].attributes.length).toBe(3);
});
it('should allow an entity with attribute that is an array', function () {
const entity = 'BOOK';
const attribute1 = 'string[] readers FK "comment"';
const attribute2 = 'string[] authors FK';
erDiagram.parser.parse(`erDiagram\n${entity} {\n${attribute1}\n${attribute2}\n}`);
const entities = erDb.getEntities();
expect(Object.keys(entities).length).toBe(1);
expect(entities[entity].attributes.length).toBe(2);
});
it('should allow an entity with multiple attributes to be defined', function () {
const entity = 'BOOK';
const attribute1 = 'string title';
const attribute2 = 'string author';
const attribute3 = 'float price';
erDiagram.parser.parse(
`erDiagram\n${entity} {\n${attribute1}\n${attribute2}\n${attribute3}\n}`
);
const entities = erDb.getEntities();
expect(entities[entity].attributes.length).toBe(3);
});
it('should allow attribute definitions to be split into multiple blocks', function () {
const entity = 'BOOK';
const attribute1 = 'string title';
const attribute2 = 'string author';
const attribute3 = 'float price';
erDiagram.parser.parse(
`erDiagram\n${entity} {\n${attribute1}\n}\n${entity} {\n${attribute2}\n${attribute3}\n}`
);
const entities = erDb.getEntities();
expect(entities[entity].attributes.length).toBe(3);
});
it('should allow an empty attribute block', function () {
const entity = 'BOOK';
erDiagram.parser.parse(`erDiagram\n${entity} {}`);
const entities = erDb.getEntities();
expect(entities.hasOwnProperty('BOOK')).toBe(true);
expect(entities[entity].attributes.length).toBe(0);
});
it('should allow an attribute block to start immediately after the entity name', function () {
const entity = 'BOOK';
erDiagram.parser.parse(`erDiagram\n${entity}{}`);
const entities = erDb.getEntities();
expect(entities.hasOwnProperty('BOOK')).toBe(true);
expect(entities[entity].attributes.length).toBe(0);
});
it('should allow an attribute block to be separated from the entity name by spaces', function () {
const entity = 'BOOK';
erDiagram.parser.parse(`erDiagram\n${entity} {}`);
const entities = erDb.getEntities();
expect(entities.hasOwnProperty('BOOK')).toBe(true);
expect(entities[entity].attributes.length).toBe(0);
});
it('should allow whitespace before and after attribute definitions', function () {
const entity = 'BOOK';
const attribute = 'string title';
erDiagram.parser.parse(`erDiagram\n${entity} {\n \n\n ${attribute}\n\n \n}`);
const entities = erDb.getEntities();
expect(Object.keys(entities).length).toBe(1);
expect(entities[entity].attributes.length).toBe(1);
});
it('should allow no whitespace before and after attribute definitions', function () {
const entity = 'BOOK';
const attribute = 'string title';
erDiagram.parser.parse(`erDiagram\n${entity}{${attribute}}`);
const entities = erDb.getEntities();
expect(Object.keys(entities).length).toBe(1);
expect(entities[entity].attributes.length).toBe(1);
});
it('should associate two entities correctly', function () {
erDiagram.parser.parse('erDiagram\nCAR ||--o{ DRIVER : "insured for"');
const entities = erDb.getEntities();
const relationships = erDb.getRelationships();
expect(entities.hasOwnProperty('CAR')).toBe(true);
expect(entities.hasOwnProperty('DRIVER')).toBe(true);
expect(relationships.length).toBe(1);
expect(relationships[0].relSpec.cardA).toBe(erDb.Cardinality.ZERO_OR_MORE);
expect(relationships[0].relSpec.cardB).toBe(erDb.Cardinality.ONLY_ONE);
expect(relationships[0].relSpec.relType).toBe(erDb.Identification.IDENTIFYING);
});
it('should not create duplicate entities', function () {
const line1 = 'CAR ||--o{ DRIVER : "insured for"';
const line2 = 'DRIVER ||--|| LICENSE : has';
erDiagram.parser.parse(`erDiagram\n${line1}\n${line2}`);
const entities = erDb.getEntities();
expect(Object.keys(entities).length).toBe(3);
});
it('should create the role specified', function () {
const teacherRole = 'is teacher of';
const line1 = `TEACHER }o--o{ STUDENT : "${teacherRole}"`;
erDiagram.parser.parse(`erDiagram\n${line1}`);
const rels = erDb.getRelationships();
expect(rels[0].roleA).toBe(`${teacherRole}`);
});
it('should allow recursive relationships', function () {
erDiagram.parser.parse('erDiagram\nNODE ||--o{ NODE : "leads to"');
expect(Object.keys(erDb.getEntities()).length).toBe(1);
});
it('should allow for a accessibility title and description (accDescr)', function () {
const teacherRole = 'is teacher of';
const line1 = `TEACHER }o--o{ STUDENT : "${teacherRole}"`;
erDiagram.parser.parse(
`erDiagram
accTitle: graph title
accDescr: this graph is about stuff
${line1}`
);
expect(erDb.getAccTitle()).toBe('graph title');
expect(erDb.getAccDescription()).toBe('this graph is about stuff');
});
it('should allow for a accessibility title and multi line description (accDescr)', function () {
const teacherRole = 'is teacher of';
const line1 = `TEACHER }o--o{ STUDENT : "${teacherRole}"`;
erDiagram.parser.parse(
`erDiagram
accTitle: graph title
accDescr {
this graph is about stuff
}\n
${line1}`
);
expect(erDb.getAccTitle()).toBe('graph title');
expect(erDb.getAccDescription()).toBe('this graph is about stuff');
});
it('should allow more than one relationship between the same two entities', function () {
const line1 = 'CAR ||--o{ PERSON : "insured for"';
const line2 = 'CAR }o--|| PERSON : "owned by"';
erDiagram.parser.parse(`erDiagram\n${line1}\n${line2}`);
const entities = erDb.getEntities();
const rels = erDb.getRelationships();
expect(Object.keys(entities).length).toBe(2);
expect(rels.length).toBe(2);
});
it('should limit the number of relationships between the same two entities', function () {
/* TODO */
});
it('should not allow multiple relationships between the same two entities unless the roles are different', function () {
/* TODO */
});
it('should handle only-one-to-one-or-more relationships', function () {
erDiagram.parser.parse('erDiagram\nA ||--|{ B : has');
const rels = erDb.getRelationships();
expect(Object.keys(erDb.getEntities()).length).toBe(2);
expect(rels.length).toBe(1);
expect(rels[0].relSpec.cardA).toBe(erDb.Cardinality.ONE_OR_MORE);
expect(rels[0].relSpec.cardB).toBe(erDb.Cardinality.ONLY_ONE);
});
it('should handle only-one-to-zero-or-more relationships', function () {
erDiagram.parser.parse('erDiagram\nA ||..o{ B : has');
const rels = erDb.getRelationships();
expect(Object.keys(erDb.getEntities()).length).toBe(2);
expect(rels.length).toBe(1);
expect(rels[0].relSpec.cardA).toBe(erDb.Cardinality.ZERO_OR_MORE);
expect(rels[0].relSpec.cardB).toBe(erDb.Cardinality.ONLY_ONE);
});
it('should handle zero-or-one-to-zero-or-more relationships', function () {
erDiagram.parser.parse('erDiagram\nA |o..o{ B : has');
const rels = erDb.getRelationships();
expect(Object.keys(erDb.getEntities()).length).toBe(2);
expect(rels.length).toBe(1);
expect(rels[0].relSpec.cardA).toBe(erDb.Cardinality.ZERO_OR_MORE);
expect(rels[0].relSpec.cardB).toBe(erDb.Cardinality.ZERO_OR_ONE);
});
it('should handle zero-or-one-to-one-or-more relationships', function () {
erDiagram.parser.parse('erDiagram\nA |o--|{ B : has');
const rels = erDb.getRelationships();
expect(Object.keys(erDb.getEntities()).length).toBe(2);
expect(rels.length).toBe(1);
expect(rels[0].relSpec.cardA).toBe(erDb.Cardinality.ONE_OR_MORE);
expect(rels[0].relSpec.cardB).toBe(erDb.Cardinality.ZERO_OR_ONE);
});
it('should handle one-or-more-to-only-one relationships', function () {
erDiagram.parser.parse('erDiagram\nA }|--|| B : has');
const rels = erDb.getRelationships();
expect(Object.keys(erDb.getEntities()).length).toBe(2);
expect(rels.length).toBe(1);
expect(rels[0].relSpec.cardA).toBe(erDb.Cardinality.ONLY_ONE);
expect(rels[0].relSpec.cardB).toBe(erDb.Cardinality.ONE_OR_MORE);
});
it('should handle zero-or-more-to-only-one relationships', function () {
erDiagram.parser.parse('erDiagram\nA }o--|| B : has');
const rels = erDb.getRelationships();
expect(Object.keys(erDb.getEntities()).length).toBe(2);
expect(rels.length).toBe(1);
expect(rels[0].relSpec.cardA).toBe(erDb.Cardinality.ONLY_ONE);
expect(rels[0].relSpec.cardB).toBe(erDb.Cardinality.ZERO_OR_MORE);
});
it('should handle zero-or-more-to-zero-or-one relationships', function () {
erDiagram.parser.parse('erDiagram\nA }o..o| B : has');
const rels = erDb.getRelationships();
expect(Object.keys(erDb.getEntities()).length).toBe(2);
expect(rels.length).toBe(1);
expect(rels[0].relSpec.cardA).toBe(erDb.Cardinality.ZERO_OR_ONE);
expect(rels[0].relSpec.cardB).toBe(erDb.Cardinality.ZERO_OR_MORE);
});
it('should handle one-or-more-to-zero-or-one relationships', function () {
erDiagram.parser.parse('erDiagram\nA }|..o| B : has');
const rels = erDb.getRelationships();
expect(Object.keys(erDb.getEntities()).length).toBe(2);
expect(rels.length).toBe(1);
expect(rels[0].relSpec.cardA).toBe(erDb.Cardinality.ZERO_OR_ONE);
expect(rels[0].relSpec.cardB).toBe(erDb.Cardinality.ONE_OR_MORE);
});
it('should handle zero-or-one-to-only-one relationships', function () {
erDiagram.parser.parse('erDiagram\nA |o..|| B : has');
const rels = erDb.getRelationships();
expect(Object.keys(erDb.getEntities()).length).toBe(2);
expect(rels.length).toBe(1);
expect(rels[0].relSpec.cardA).toBe(erDb.Cardinality.ONLY_ONE);
expect(rels[0].relSpec.cardB).toBe(erDb.Cardinality.ZERO_OR_ONE);
});
it('should handle only-one-to-only-one relationships', function () {
erDiagram.parser.parse('erDiagram\nA ||..|| B : has');
const rels = erDb.getRelationships();
expect(Object.keys(erDb.getEntities()).length).toBe(2);
expect(rels.length).toBe(1);
expect(rels[0].relSpec.cardA).toBe(erDb.Cardinality.ONLY_ONE);
expect(rels[0].relSpec.cardB).toBe(erDb.Cardinality.ONLY_ONE);
});
it('should handle only-one-to-zero-or-one relationships', function () {
erDiagram.parser.parse('erDiagram\nA ||--o| B : has');
const rels = erDb.getRelationships();
expect(Object.keys(erDb.getEntities()).length).toBe(2);
expect(rels.length).toBe(1);
expect(rels[0].relSpec.cardA).toBe(erDb.Cardinality.ZERO_OR_ONE);
expect(rels[0].relSpec.cardB).toBe(erDb.Cardinality.ONLY_ONE);
});
it('should handle zero-or-one-to-zero-or-one relationships', function () {
erDiagram.parser.parse('erDiagram\nA |o..o| B : has');
const rels = erDb.getRelationships();
expect(Object.keys(erDb.getEntities()).length).toBe(2);
expect(rels.length).toBe(1);
expect(rels[0].relSpec.cardA).toBe(erDb.Cardinality.ZERO_OR_ONE);
expect(rels[0].relSpec.cardB).toBe(erDb.Cardinality.ZERO_OR_ONE);
});
it('should handle zero-or-more-to-zero-or-more relationships', function () {
erDiagram.parser.parse('erDiagram\nA }o--o{ B : has');
const rels = erDb.getRelationships();
expect(Object.keys(erDb.getEntities()).length).toBe(2);
expect(rels.length).toBe(1);
expect(rels[0].relSpec.cardA).toBe(erDb.Cardinality.ZERO_OR_MORE);
expect(rels[0].relSpec.cardB).toBe(erDb.Cardinality.ZERO_OR_MORE);
});
it('should handle one-or-more-to-one-or-more relationships', function () {
erDiagram.parser.parse('erDiagram\nA }|..|{ B : has');
const rels = erDb.getRelationships();
expect(Object.keys(erDb.getEntities()).length).toBe(2);
expect(rels.length).toBe(1);
expect(rels[0].relSpec.cardA).toBe(erDb.Cardinality.ONE_OR_MORE);
expect(rels[0].relSpec.cardB).toBe(erDb.Cardinality.ONE_OR_MORE);
});
it('should handle zero-or-more-to-one-or-more relationships', function () {
erDiagram.parser.parse('erDiagram\nA }o--|{ B : has');
const rels = erDb.getRelationships();
expect(Object.keys(erDb.getEntities()).length).toBe(2);
expect(rels.length).toBe(1);
expect(rels[0].relSpec.cardA).toBe(erDb.Cardinality.ONE_OR_MORE);
expect(rels[0].relSpec.cardB).toBe(erDb.Cardinality.ZERO_OR_MORE);
});
it('should handle one-or-more-to-zero-or-more relationships', function () {
erDiagram.parser.parse('erDiagram\nA }|..o{ B : has');
const rels = erDb.getRelationships();
expect(Object.keys(erDb.getEntities()).length).toBe(2);
expect(rels.length).toBe(1);
expect(rels[0].relSpec.cardA).toBe(erDb.Cardinality.ZERO_OR_MORE);
expect(rels[0].relSpec.cardB).toBe(erDb.Cardinality.ONE_OR_MORE);
});
it('should represent identifying relationships properly', function () {
erDiagram.parser.parse('erDiagram\nHOUSE ||--|{ ROOM : contains');
const rels = erDb.getRelationships();
expect(rels[0].relSpec.relType).toBe(erDb.Identification.IDENTIFYING);
});
it('should represent non-identifying relationships properly', function () {
erDiagram.parser.parse('erDiagram\n PERSON ||..o{ POSSESSION : owns');
const rels = erDb.getRelationships();
expect(rels[0].relSpec.relType).toBe(erDb.Identification.NON_IDENTIFYING);
});
it('should not accept a syntax error', function () {
const doc = 'erDiagram\nA xxx B : has';
expect(() => {
erDiagram.parser.parse(doc);
}).toThrowError();
});
it('should allow an empty quoted label', function () {
erDiagram.parser.parse('erDiagram\nCUSTOMER ||--|{ ORDER : ""');
const rels = erDb.getRelationships();
expect(rels[0].roleA).toBe('');
});
it('should allow an non-empty quoted label', function () {
erDiagram.parser.parse('erDiagram\nCUSTOMER ||--|{ ORDER : "places"');
const rels = erDb.getRelationships();
expect(rels[0].roleA).toBe('places');
});
it('should allow an non-empty unquoted label', function () {
erDiagram.parser.parse('erDiagram\nCUSTOMER ||--|{ ORDER : places');
const rels = erDb.getRelationships();
expect(rels[0].roleA).toBe('places');
});
it('should allow an entity name with a dot', function () {
erDiagram.parser.parse('erDiagram\nCUSTOMER.PROP ||--|{ ORDER : places');
const rels = erDb.getRelationships();
expect(rels[0].entityA).toBe('CUSTOMER.PROP');
});
});

View File

@@ -0,0 +1,32 @@
const getStyles = (options) =>
`
.entityBox {
fill: ${options.mainBkg};
stroke: ${options.nodeBorder};
}
.attributeBoxOdd {
fill: #ffffff;
stroke: ${options.nodeBorder};
}
.attributeBoxEven {
fill: #f2f2f2;
stroke: ${options.nodeBorder};
}
.relationshipLabelBox {
fill: ${options.tertiaryColor};
opacity: 0.7;
background-color: ${options.tertiaryColor};
rect {
opacity: 0.5;
}
}
.relationshipLine {
stroke: ${options.lineColor};
}
`;
export default getStyles;

View File

@@ -0,0 +1,101 @@
/** Created by knut on 14-12-11. */
import { select } from 'd3';
import { log } from '../../logger';
import { getErrorMessage } from '../../utils';
let conf = {};
/**
* Merges the value of `conf` with the passed `cnf`
*
* @param {object} cnf Config to merge
*/
export const setConf = function (cnf: any) {
conf = { ...conf, ...cnf };
};
/**
* Draws a an info picture in the tag with id: id based on the graph definition in text.
*
* @param text
* @param {string} id The text for the error
* @param {string} mermaidVersion The version
*/
export const draw = (text: string, id: string, mermaidVersion: string) => {
try {
log.debug('Renering svg for syntax error\n');
const svg = select('#' + id);
const g = svg.append('g');
g.append('path')
.attr('class', 'error-icon')
.attr(
'd',
'm411.313,123.313c6.25-6.25 6.25-16.375 0-22.625s-16.375-6.25-22.625,0l-32,32-9.375,9.375-20.688-20.688c-12.484-12.5-32.766-12.5-45.25,0l-16,16c-1.261,1.261-2.304,2.648-3.31,4.051-21.739-8.561-45.324-13.426-70.065-13.426-105.867,0-192,86.133-192,192s86.133,192 192,192 192-86.133 192-192c0-24.741-4.864-48.327-13.426-70.065 1.402-1.007 2.79-2.049 4.051-3.31l16-16c12.5-12.492 12.5-32.758 0-45.25l-20.688-20.688 9.375-9.375 32.001-31.999zm-219.313,100.687c-52.938,0-96,43.063-96,96 0,8.836-7.164,16-16,16s-16-7.164-16-16c0-70.578 57.422-128 128-128 8.836,0 16,7.164 16,16s-7.164,16-16,16z'
);
g.append('path')
.attr('class', 'error-icon')
.attr(
'd',
'm459.02,148.98c-6.25-6.25-16.375-6.25-22.625,0s-6.25,16.375 0,22.625l16,16c3.125,3.125 7.219,4.688 11.313,4.688 4.094,0 8.188-1.563 11.313-4.688 6.25-6.25 6.25-16.375 0-22.625l-16.001-16z'
);
g.append('path')
.attr('class', 'error-icon')
.attr(
'd',
'm340.395,75.605c3.125,3.125 7.219,4.688 11.313,4.688 4.094,0 8.188-1.563 11.313-4.688 6.25-6.25 6.25-16.375 0-22.625l-16-16c-6.25-6.25-16.375-6.25-22.625,0s-6.25,16.375 0,22.625l15.999,16z'
);
g.append('path')
.attr('class', 'error-icon')
.attr(
'd',
'm400,64c8.844,0 16-7.164 16-16v-32c0-8.836-7.156-16-16-16-8.844,0-16,7.164-16,16v32c0,8.836 7.156,16 16,16z'
);
g.append('path')
.attr('class', 'error-icon')
.attr(
'd',
'm496,96.586h-32c-8.844,0-16,7.164-16,16 0,8.836 7.156,16 16,16h32c8.844,0 16-7.164 16-16 0-8.836-7.156-16-16-16z'
);
g.append('path')
.attr('class', 'error-icon')
.attr(
'd',
'm436.98,75.605c3.125,3.125 7.219,4.688 11.313,4.688 4.094,0 8.188-1.563 11.313-4.688l32-32c6.25-6.25 6.25-16.375 0-22.625s-16.375-6.25-22.625,0l-32,32c-6.251,6.25-6.251,16.375-0.001,22.625z'
);
g.append('text') // text label for the x axis
.attr('class', 'error-text')
.attr('x', 1440)
.attr('y', 250)
.attr('font-size', '150px')
.style('text-anchor', 'middle')
.text('Syntax error in graph');
g.append('text') // text label for the x axis
.attr('class', 'error-text')
.attr('x', 1250)
.attr('y', 400)
.attr('font-size', '100px')
.style('text-anchor', 'middle')
.text('mermaid version ' + mermaidVersion);
svg.attr('height', 100);
svg.attr('width', 500);
svg.attr('viewBox', '768 0 912 512');
} catch (e) {
log.error('Error while rendering info diagram');
log.error(getErrorMessage(e));
}
};
export default {
setConf,
draw,
};

View File

@@ -0,0 +1,3 @@
const getStyles = () => ``;
export default getStyles;

View File

@@ -0,0 +1,374 @@
import dagreD3 from 'dagre-d3';
/**
* @param parent
* @param bbox
* @param node
*/
function question(parent, bbox, node) {
const w = bbox.width;
const h = bbox.height;
const s = (w + h) * 0.9;
const points = [
{ x: s / 2, y: 0 },
{ x: s, y: -s / 2 },
{ x: s / 2, y: -s },
{ x: 0, y: -s / 2 },
];
const shapeSvg = insertPolygonShape(parent, s, s, points);
node.intersect = function (point) {
return dagreD3.intersect.polygon(node, points, point);
};
return shapeSvg;
}
/**
* @param parent
* @param bbox
* @param node
*/
function hexagon(parent, bbox, node) {
const f = 4;
const h = bbox.height;
const m = h / f;
const w = bbox.width + 2 * m;
const points = [
{ x: m, y: 0 },
{ x: w - m, y: 0 },
{ x: w, y: -h / 2 },
{ x: w - m, y: -h },
{ x: m, y: -h },
{ x: 0, y: -h / 2 },
];
const shapeSvg = insertPolygonShape(parent, w, h, points);
node.intersect = function (point) {
return dagreD3.intersect.polygon(node, points, point);
};
return shapeSvg;
}
/**
* @param parent
* @param bbox
* @param node
*/
function rect_left_inv_arrow(parent, bbox, node) {
const w = bbox.width;
const h = bbox.height;
const points = [
{ x: -h / 2, y: 0 },
{ x: w, y: 0 },
{ x: w, y: -h },
{ x: -h / 2, y: -h },
{ x: 0, y: -h / 2 },
];
const shapeSvg = insertPolygonShape(parent, w, h, points);
node.intersect = function (point) {
return dagreD3.intersect.polygon(node, points, point);
};
return shapeSvg;
}
/**
* @param parent
* @param bbox
* @param node
*/
function lean_right(parent, bbox, node) {
const w = bbox.width;
const h = bbox.height;
const points = [
{ x: (-2 * h) / 6, y: 0 },
{ x: w - h / 6, y: 0 },
{ x: w + (2 * h) / 6, y: -h },
{ x: h / 6, y: -h },
];
const shapeSvg = insertPolygonShape(parent, w, h, points);
node.intersect = function (point) {
return dagreD3.intersect.polygon(node, points, point);
};
return shapeSvg;
}
/**
* @param parent
* @param bbox
* @param node
*/
function lean_left(parent, bbox, node) {
const w = bbox.width;
const h = bbox.height;
const points = [
{ x: (2 * h) / 6, y: 0 },
{ x: w + h / 6, y: 0 },
{ x: w - (2 * h) / 6, y: -h },
{ x: -h / 6, y: -h },
];
const shapeSvg = insertPolygonShape(parent, w, h, points);
node.intersect = function (point) {
return dagreD3.intersect.polygon(node, points, point);
};
return shapeSvg;
}
/**
* @param parent
* @param bbox
* @param node
*/
function trapezoid(parent, bbox, node) {
const w = bbox.width;
const h = bbox.height;
const points = [
{ x: (-2 * h) / 6, y: 0 },
{ x: w + (2 * h) / 6, y: 0 },
{ x: w - h / 6, y: -h },
{ x: h / 6, y: -h },
];
const shapeSvg = insertPolygonShape(parent, w, h, points);
node.intersect = function (point) {
return dagreD3.intersect.polygon(node, points, point);
};
return shapeSvg;
}
/**
* @param parent
* @param bbox
* @param node
*/
function inv_trapezoid(parent, bbox, node) {
const w = bbox.width;
const h = bbox.height;
const points = [
{ x: h / 6, y: 0 },
{ x: w - h / 6, y: 0 },
{ x: w + (2 * h) / 6, y: -h },
{ x: (-2 * h) / 6, y: -h },
];
const shapeSvg = insertPolygonShape(parent, w, h, points);
node.intersect = function (point) {
return dagreD3.intersect.polygon(node, points, point);
};
return shapeSvg;
}
/**
* @param parent
* @param bbox
* @param node
*/
function rect_right_inv_arrow(parent, bbox, node) {
const w = bbox.width;
const h = bbox.height;
const points = [
{ x: 0, y: 0 },
{ x: w + h / 2, y: 0 },
{ x: w, y: -h / 2 },
{ x: w + h / 2, y: -h },
{ x: 0, y: -h },
];
const shapeSvg = insertPolygonShape(parent, w, h, points);
node.intersect = function (point) {
return dagreD3.intersect.polygon(node, points, point);
};
return shapeSvg;
}
/**
* @param parent
* @param bbox
* @param node
*/
function stadium(parent, bbox, node) {
const h = bbox.height;
const w = bbox.width + h / 4;
const shapeSvg = parent
.insert('rect', ':first-child')
.attr('rx', h / 2)
.attr('ry', h / 2)
.attr('x', -w / 2)
.attr('y', -h / 2)
.attr('width', w)
.attr('height', h);
node.intersect = function (point) {
return dagreD3.intersect.rect(node, point);
};
return shapeSvg;
}
/**
* @param parent
* @param bbox
* @param node
*/
function subroutine(parent, bbox, node) {
const w = bbox.width;
const h = bbox.height;
const points = [
{ x: 0, y: 0 },
{ x: w, y: 0 },
{ x: w, y: -h },
{ x: 0, y: -h },
{ x: 0, y: 0 },
{ x: -8, y: 0 },
{ x: w + 8, y: 0 },
{ x: w + 8, y: -h },
{ x: -8, y: -h },
{ x: -8, y: 0 },
];
const shapeSvg = insertPolygonShape(parent, w, h, points);
node.intersect = function (point) {
return dagreD3.intersect.polygon(node, points, point);
};
return shapeSvg;
}
/**
* @param parent
* @param bbox
* @param node
*/
function cylinder(parent, bbox, node) {
const w = bbox.width;
const rx = w / 2;
const ry = rx / (2.5 + w / 50);
const h = bbox.height + ry;
const shape =
'M 0,' +
ry +
' a ' +
rx +
',' +
ry +
' 0,0,0 ' +
w +
' 0 a ' +
rx +
',' +
ry +
' 0,0,0 ' +
-w +
' 0 l 0,' +
h +
' a ' +
rx +
',' +
ry +
' 0,0,0 ' +
w +
' 0 l 0,' +
-h;
const shapeSvg = parent
.attr('label-offset-y', ry)
.insert('path', ':first-child')
.attr('d', shape)
.attr('transform', 'translate(' + -w / 2 + ',' + -(h / 2 + ry) + ')');
node.intersect = function (point) {
const pos = dagreD3.intersect.rect(node, point);
const x = pos.x - node.x;
if (
rx != 0 &&
(Math.abs(x) < node.width / 2 ||
(Math.abs(x) == node.width / 2 && Math.abs(pos.y - node.y) > node.height / 2 - ry))
) {
// ellipsis equation: x*x / a*a + y*y / b*b = 1
// solve for y to get adjustion value for pos.y
let y = ry * ry * (1 - (x * x) / (rx * rx));
if (y != 0) y = Math.sqrt(y);
y = ry - y;
if (point.y - node.y > 0) y = -y;
pos.y += y;
}
return pos;
};
return shapeSvg;
}
/** @param render */
export function addToRender(render) {
render.shapes().question = question;
render.shapes().hexagon = hexagon;
render.shapes().stadium = stadium;
render.shapes().subroutine = subroutine;
render.shapes().cylinder = cylinder;
// Add custom shape for box with inverted arrow on left side
render.shapes().rect_left_inv_arrow = rect_left_inv_arrow;
// Add custom shape for box with inverted arrow on left side
render.shapes().lean_right = lean_right;
// Add custom shape for box with inverted arrow on left side
render.shapes().lean_left = lean_left;
// Add custom shape for box with inverted arrow on left side
render.shapes().trapezoid = trapezoid;
// Add custom shape for box with inverted arrow on left side
render.shapes().inv_trapezoid = inv_trapezoid;
// Add custom shape for box with inverted arrow on right side
render.shapes().rect_right_inv_arrow = rect_right_inv_arrow;
}
/** @param addShape */
export function addToRenderV2(addShape) {
addShape({ question });
addShape({ hexagon });
addShape({ stadium });
addShape({ subroutine });
addShape({ cylinder });
// Add custom shape for box with inverted arrow on left side
addShape({ rect_left_inv_arrow });
// Add custom shape for box with inverted arrow on left side
addShape({ lean_right });
// Add custom shape for box with inverted arrow on left side
addShape({ lean_left });
// Add custom shape for box with inverted arrow on left side
addShape({ trapezoid });
// Add custom shape for box with inverted arrow on left side
addShape({ inv_trapezoid });
// Add custom shape for box with inverted arrow on right side
addShape({ rect_right_inv_arrow });
}
/**
* @param parent
* @param w
* @param h
* @param points
*/
function insertPolygonShape(parent, w, h, points) {
return parent
.insert('polygon', ':first-child')
.attr(
'points',
points
.map(function (d) {
return d.x + ',' + d.y;
})
.join(' ')
)
.attr('transform', 'translate(' + -w / 2 + ',' + h / 2 + ')');
}
export default {
addToRender,
addToRenderV2,
};

View File

@@ -0,0 +1,157 @@
import { addToRender } from './flowChartShapes';
describe('flowchart shapes', function () {
// rect-based shapes
[['stadium', useWidth, useHeight]].forEach(function ([shapeType, getW, getH]) {
it(`should add a ${shapeType} shape that renders a properly positioned rect element`, function () {
const mockRender = MockRender();
const mockSvg = MockSvg();
addToRender(mockRender);
[
[100, 100],
[123, 45],
[71, 300],
].forEach(function ([width, height]) {
const shape = mockRender.shapes()[shapeType](mockSvg, { width, height }, {});
const w = width + height / 4;
const h = height;
const dx = -getW(w, h) / 2;
const dy = -getH(w, h) / 2;
expect(shape.__tag).toEqual('rect');
expect(shape.__attrs).toHaveProperty('x', dx);
expect(shape.__attrs).toHaveProperty('y', dy);
});
});
});
// path-based shapes
[['cylinder', useWidth, useHeight]].forEach(function ([shapeType, getW, getH]) {
it(`should add a ${shapeType} shape that renders a properly positioned path element`, function () {
const mockRender = MockRender();
const mockSvg = MockSvg();
addToRender(mockRender);
[
[100, 100],
[123, 45],
[71, 300],
].forEach(function ([width, height]) {
const shape = mockRender.shapes()[shapeType](mockSvg, { width, height }, {});
expect(shape.__tag).toEqual('path');
expect(shape.__attrs).toHaveProperty('d');
});
});
});
// polygon-based shapes
[
[
'question',
4,
function (w, h) {
return (w + h) * 0.9;
},
function (w, h) {
return (w + h) * 0.9;
},
],
[
'hexagon',
6,
function (w, h) {
return w + h / 2;
},
useHeight,
],
['rect_left_inv_arrow', 5, useWidth, useHeight],
['rect_right_inv_arrow', 5, useWidth, useHeight],
['lean_right', 4, useWidth, useHeight],
['lean_left', 4, useWidth, useHeight],
['trapezoid', 4, useWidth, useHeight],
['inv_trapezoid', 4, useWidth, useHeight],
['subroutine', 10, useWidth, useHeight],
].forEach(function ([shapeType, expectedPointCount, getW, getH]) {
it(`should add a ${shapeType} shape that renders a properly translated polygon element`, function () {
const mockRender = MockRender();
const mockSvg = MockSvg();
addToRender(mockRender);
[
[100, 100],
[123, 45],
[71, 300],
].forEach(function ([width, height]) {
const shape = mockRender.shapes()[shapeType](mockSvg, { width, height }, {});
const dx = -getW(width, height) / 2;
const dy = getH(width, height) / 2;
const points = shape.__attrs.points.split(' ');
expect(shape.__tag).toEqual('polygon');
expect(shape.__attrs).toHaveProperty('transform', `translate(${dx},${dy})`);
expect(points).toHaveLength(expectedPointCount);
});
});
});
});
/**
*
*/
function MockRender() {
const shapes = {};
return {
shapes() {
return shapes;
},
};
}
/**
*
* @param tag
* @param {...any} args
*/
function MockSvg(tag, ...args) {
const children = [];
const attributes = {};
return {
get __args() {
return args;
},
get __tag() {
return tag;
},
get __children() {
return children;
},
get __attrs() {
return attributes;
},
insert: function (tag, ...args) {
const child = MockSvg(tag, ...args);
children.push(child);
return child;
},
attr(name, value) {
this.__attrs[name] = value;
return this;
},
};
}
/**
* @param w
* @param h
*/
function useWidth(w, h) {
return w;
}
/**
*
* @param w
* @param h
*/
function useHeight(w, h) {
return h;
}

View File

@@ -0,0 +1,783 @@
import { select } from 'd3';
import utils from '../../utils';
import * as configApi from '../../config';
import common from '../common/common';
import mermaidAPI from '../../mermaidAPI';
import { log } from '../../logger';
import {
setAccTitle,
getAccTitle,
getAccDescription,
setAccDescription,
clear as commonClear,
} from '../../commonDb';
const MERMAID_DOM_ID_PREFIX = 'flowchart-';
let vertexCounter = 0;
let config = configApi.getConfig();
let vertices = {};
let edges = [];
let classes = [];
let subGraphs = [];
let subGraphLookup = {};
let tooltips = {};
let subCount = 0;
let firstGraphFlag = true;
let direction;
let version; // As in graph
// Functions to be run after graph rendering
let funs = [];
const sanitizeText = (txt) => common.sanitizeText(txt, config);
export const parseDirective = function (statement, context, type) {
mermaidAPI.parseDirective(this, statement, context, type);
};
/**
* Function to lookup domId from id in the graph definition.
*
* @param id
* @public
*/
export const lookUpDomId = function (id) {
const veritceKeys = Object.keys(vertices);
for (let i = 0; i < veritceKeys.length; i++) {
if (vertices[veritceKeys[i]].id === id) {
return vertices[veritceKeys[i]].domId;
}
}
return id;
};
/**
* Function called by parser when a node definition has been found
*
* @param _id
* @param text
* @param type
* @param style
* @param classes
* @param dir
* @param props
*/
export const addVertex = function (_id, text, type, style, classes, dir, props = {}) {
let txt;
let id = _id;
if (typeof id === 'undefined') {
return;
}
if (id.trim().length === 0) {
return;
}
// if (id[0].match(/\d/)) id = MERMAID_DOM_ID_PREFIX + id;
if (typeof vertices[id] === 'undefined') {
vertices[id] = {
id: id,
domId: MERMAID_DOM_ID_PREFIX + id + '-' + vertexCounter,
styles: [],
classes: [],
};
}
vertexCounter++;
if (typeof text !== 'undefined') {
config = configApi.getConfig();
txt = sanitizeText(text.trim());
// strip quotes if string starts and ends with a quote
if (txt[0] === '"' && txt[txt.length - 1] === '"') {
txt = txt.substring(1, txt.length - 1);
}
vertices[id].text = txt;
} else {
if (typeof vertices[id].text === 'undefined') {
vertices[id].text = _id;
}
}
if (typeof type !== 'undefined') {
vertices[id].type = type;
}
if (typeof style !== 'undefined') {
if (style !== null) {
style.forEach(function (s) {
vertices[id].styles.push(s);
});
}
}
if (typeof classes !== 'undefined') {
if (classes !== null) {
classes.forEach(function (s) {
vertices[id].classes.push(s);
});
}
}
if (typeof dir !== 'undefined') {
vertices[id].dir = dir;
}
vertices[id].props = props;
};
/**
* Function called by parser when a link/edge definition has been found
*
* @param _start
* @param _end
* @param type
* @param linktext
*/
export const addSingleLink = function (_start, _end, type, linktext) {
let start = _start;
let end = _end;
// if (start[0].match(/\d/)) start = MERMAID_DOM_ID_PREFIX + start;
// if (end[0].match(/\d/)) end = MERMAID_DOM_ID_PREFIX + end;
// log.info('Got edge...', start, end);
const edge = { start: start, end: end, type: undefined, text: '' };
linktext = type.text;
if (typeof linktext !== 'undefined') {
edge.text = sanitizeText(linktext.trim());
// strip quotes if string starts and exnds with a quote
if (edge.text[0] === '"' && edge.text[edge.text.length - 1] === '"') {
edge.text = edge.text.substring(1, edge.text.length - 1);
}
}
if (typeof type !== 'undefined') {
edge.type = type.type;
edge.stroke = type.stroke;
edge.length = type.length;
}
edges.push(edge);
};
export const addLink = function (_start, _end, type, linktext) {
let i, j;
for (i = 0; i < _start.length; i++) {
for (j = 0; j < _end.length; j++) {
addSingleLink(_start[i], _end[j], type, linktext);
}
}
};
/**
* Updates a link's line interpolation algorithm
*
* @param positions
* @param interp
*/
export const updateLinkInterpolate = function (positions, interp) {
positions.forEach(function (pos) {
if (pos === 'default') {
edges.defaultInterpolate = interp;
} else {
edges[pos].interpolate = interp;
}
});
};
/**
* Updates a link with a style
*
* @param positions
* @param style
*/
export const updateLink = function (positions, style) {
positions.forEach(function (pos) {
if (pos === 'default') {
edges.defaultStyle = style;
} else {
if (utils.isSubstringInArray('fill', style) === -1) {
style.push('fill:none');
}
edges[pos].style = style;
}
});
};
export const addClass = function (id, style) {
if (typeof classes[id] === 'undefined') {
classes[id] = { id: id, styles: [], textStyles: [] };
}
if (typeof style !== 'undefined') {
if (style !== null) {
style.forEach(function (s) {
if (s.match('color')) {
const newStyle1 = s.replace('fill', 'bgFill');
const newStyle2 = newStyle1.replace('color', 'fill');
classes[id].textStyles.push(newStyle2);
}
classes[id].styles.push(s);
});
}
}
};
/**
* Called by parser when a graph definition is found, stores the direction of the chart.
*
* @param dir
*/
export const setDirection = function (dir) {
direction = dir;
if (direction.match(/.*</)) {
direction = 'RL';
}
if (direction.match(/.*\^/)) {
direction = 'BT';
}
if (direction.match(/.*>/)) {
direction = 'LR';
}
if (direction.match(/.*v/)) {
direction = 'TB';
}
};
/**
* Called by parser when a special node is found, e.g. a clickable element.
*
* @param ids Comma separated list of ids
* @param className Class to add
*/
export const setClass = function (ids, className) {
ids.split(',').forEach(function (_id) {
// let id = version === 'gen-2' ? lookUpDomId(_id) : _id;
let id = _id;
// if (_id[0].match(/\d/)) id = MERMAID_DOM_ID_PREFIX + id;
if (typeof vertices[id] !== 'undefined') {
vertices[id].classes.push(className);
}
if (typeof subGraphLookup[id] !== 'undefined') {
subGraphLookup[id].classes.push(className);
}
});
};
const setTooltip = function (ids, tooltip) {
ids.split(',').forEach(function (id) {
if (typeof tooltip !== 'undefined') {
tooltips[version === 'gen-1' ? lookUpDomId(id) : id] = sanitizeText(tooltip);
}
});
};
const setClickFun = function (id, functionName, functionArgs) {
let domId = lookUpDomId(id);
// if (_id[0].match(/\d/)) id = MERMAID_DOM_ID_PREFIX + id;
if (configApi.getConfig().securityLevel !== 'loose') {
return;
}
if (typeof functionName === 'undefined') {
return;
}
let argList = [];
if (typeof functionArgs === 'string') {
/* Splits functionArgs by ',', ignoring all ',' in double quoted strings */
argList = functionArgs.split(/,(?=(?:(?:[^"]*"){2})*[^"]*$)/);
for (let i = 0; i < argList.length; i++) {
let item = argList[i].trim();
/* Removes all double quotes at the start and end of an argument */
/* This preserves all starting and ending whitespace inside */
if (item.charAt(0) === '"' && item.charAt(item.length - 1) === '"') {
item = item.substr(1, item.length - 2);
}
argList[i] = item;
}
}
/* if no arguments passed into callback, default to passing in id */
if (argList.length === 0) {
argList.push(id);
}
if (typeof vertices[id] !== 'undefined') {
vertices[id].haveCallback = true;
funs.push(function () {
const elem = document.querySelector(`[id="${domId}"]`);
if (elem !== null) {
elem.addEventListener(
'click',
function () {
utils.runFunc(functionName, ...argList);
},
false
);
}
});
}
};
/**
* Called by parser when a link is found. Adds the URL to the vertex data.
*
* @param ids Comma separated list of ids
* @param linkStr URL to create a link for
* @param target
*/
export const setLink = function (ids, linkStr, target) {
ids.split(',').forEach(function (id) {
if (typeof vertices[id] !== 'undefined') {
vertices[id].link = utils.formatUrl(linkStr, config);
vertices[id].linkTarget = target;
}
});
setClass(ids, 'clickable');
};
export const getTooltip = function (id) {
return tooltips[id];
};
/**
* Called by parser when a click definition is found. Registers an event handler.
*
* @param ids Comma separated list of ids
* @param functionName Function to be called on click
* @param functionArgs
*/
export const setClickEvent = function (ids, functionName, functionArgs) {
ids.split(',').forEach(function (id) {
setClickFun(id, functionName, functionArgs);
});
setClass(ids, 'clickable');
};
export const bindFunctions = function (element) {
funs.forEach(function (fun) {
fun(element);
});
};
export const getDirection = function () {
return direction.trim();
};
/**
* Retrieval function for fetching the found nodes after parsing has completed.
*
* @returns {{} | any | vertices}
*/
export const getVertices = function () {
return vertices;
};
/**
* Retrieval function for fetching the found links after parsing has completed.
*
* @returns {{} | any | edges}
*/
export const getEdges = function () {
return edges;
};
/**
* Retrieval function for fetching the found class definitions after parsing has completed.
*
* @returns {{} | any | classes}
*/
export const getClasses = function () {
return classes;
};
const setupToolTips = function (element) {
let tooltipElem = select('.mermaidTooltip');
if ((tooltipElem._groups || tooltipElem)[0][0] === null) {
tooltipElem = select('body').append('div').attr('class', 'mermaidTooltip').style('opacity', 0);
}
const svg = select(element).select('svg');
const nodes = svg.selectAll('g.node');
nodes
.on('mouseover', function () {
const el = select(this);
const title = el.attr('title');
// Dont try to draw a tooltip if no data is provided
if (title === null) {
return;
}
const rect = this.getBoundingClientRect();
tooltipElem.transition().duration(200).style('opacity', '.9');
tooltipElem
.text(el.attr('title'))
.style('left', window.scrollX + rect.left + (rect.right - rect.left) / 2 + 'px')
.style('top', window.scrollY + rect.top - 14 + document.body.scrollTop + 'px');
tooltipElem.html(tooltipElem.html().replace(/&lt;br\/&gt;/g, '<br/>'));
el.classed('hover', true);
})
.on('mouseout', function () {
tooltipElem.transition().duration(500).style('opacity', 0);
const el = select(this);
el.classed('hover', false);
});
};
funs.push(setupToolTips);
/**
* Clears the internal graph db so that a new graph can be parsed.
*
* @param ver
*/
export const clear = function (ver = 'gen-1') {
vertices = {};
classes = {};
edges = [];
funs = [];
funs.push(setupToolTips);
subGraphs = [];
subGraphLookup = {};
subCount = 0;
tooltips = [];
firstGraphFlag = true;
version = ver;
commonClear();
};
export const setGen = (ver) => {
version = ver || 'gen-1';
};
/** @returns {string} */
export const defaultStyle = function () {
return 'fill:#ffa;stroke: #f66; stroke-width: 3px; stroke-dasharray: 5, 5;fill:#ffa;stroke: #666;';
};
/**
* Clears the internal graph db so that a new graph can be parsed.
*
* @param _id
* @param list
* @param _title
*/
export const addSubGraph = function (_id, list, _title) {
// console.log('addSubGraph', _id, list, _title);
let id = _id.trim();
let title = _title;
if (_id === _title && _title.match(/\s/)) {
id = undefined;
}
/** @param a */
function uniq(a) {
const prims = { boolean: {}, number: {}, string: {} };
const objs = [];
let dir; // = undefined; direction.trim();
const nodeList = a.filter(function (item) {
const type = typeof item;
if (item.stmt && item.stmt === 'dir') {
dir = item.value;
return false;
}
if (item.trim() === '') {
return false;
}
if (type in prims) {
return prims[type].hasOwnProperty(item) ? false : (prims[type][item] = true);
} else {
return objs.indexOf(item) >= 0 ? false : objs.push(item);
}
});
return { nodeList, dir };
}
let nodeList = [];
const { nodeList: nl, dir } = uniq(nodeList.concat.apply(nodeList, list));
nodeList = nl;
if (version === 'gen-1') {
for (let i = 0; i < nodeList.length; i++) {
nodeList[i] = lookUpDomId(nodeList[i]);
}
}
id = id || 'subGraph' + subCount;
// if (id[0].match(/\d/)) id = lookUpDomId(id);
title = title || '';
title = sanitizeText(title);
subCount = subCount + 1;
const subGraph = { id: id, nodes: nodeList, title: title.trim(), classes: [], dir };
log.info('Adding', subGraph.id, subGraph.nodes, subGraph.dir);
/** Deletes an id from all subgraphs */
// const del = _id => {
// subGraphs.forEach(sg => {
// const pos = sg.nodes.indexOf(_id);
// if (pos >= 0) {
// sg.nodes.splice(pos, 1);
// }
// });
// };
// // Removes the members of this subgraph from any other subgraphs, a node only belong to one subgraph
// subGraph.nodes.forEach(_id => del(_id));
// Remove the members in the new subgraph if they already belong to another subgraph
subGraph.nodes = makeUniq(subGraph, subGraphs).nodes;
subGraphs.push(subGraph);
subGraphLookup[id] = subGraph;
return id;
};
const getPosForId = function (id) {
for (let i = 0; i < subGraphs.length; i++) {
if (subGraphs[i].id === id) {
return i;
}
}
return -1;
};
let secCount = -1;
const posCrossRef = [];
const indexNodes2 = function (id, pos) {
const nodes = subGraphs[pos].nodes;
secCount = secCount + 1;
if (secCount > 2000) {
return;
}
posCrossRef[secCount] = pos;
// Check if match
if (subGraphs[pos].id === id) {
return {
result: true,
count: 0,
};
}
let count = 0;
let posCount = 1;
while (count < nodes.length) {
const childPos = getPosForId(nodes[count]);
// Ignore regular nodes (pos will be -1)
if (childPos >= 0) {
const res = indexNodes2(id, childPos);
if (res.result) {
return {
result: true,
count: posCount + res.count,
};
} else {
posCount = posCount + res.count;
}
}
count = count + 1;
}
return {
result: false,
count: posCount,
};
};
export const getDepthFirstPos = function (pos) {
return posCrossRef[pos];
};
export const indexNodes = function () {
secCount = -1;
if (subGraphs.length > 0) {
indexNodes2('none', subGraphs.length - 1, 0);
}
};
export const getSubGraphs = function () {
return subGraphs;
};
export const firstGraph = () => {
if (firstGraphFlag) {
firstGraphFlag = false;
return true;
}
return false;
};
const destructStartLink = (_str) => {
let str = _str.trim();
let type = 'arrow_open';
switch (str[0]) {
case '<':
type = 'arrow_point';
str = str.slice(1);
break;
case 'x':
type = 'arrow_cross';
str = str.slice(1);
break;
case 'o':
type = 'arrow_circle';
str = str.slice(1);
break;
}
let stroke = 'normal';
if (str.indexOf('=') !== -1) {
stroke = 'thick';
}
if (str.indexOf('.') !== -1) {
stroke = 'dotted';
}
return { type, stroke };
};
const countChar = (char, str) => {
const length = str.length;
let count = 0;
for (let i = 0; i < length; ++i) {
if (str[i] === char) {
++count;
}
}
return count;
};
const destructEndLink = (_str) => {
const str = _str.trim();
let line = str.slice(0, -1);
let type = 'arrow_open';
switch (str.slice(-1)) {
case 'x':
type = 'arrow_cross';
if (str[0] === 'x') {
type = 'double_' + type;
line = line.slice(1);
}
break;
case '>':
type = 'arrow_point';
if (str[0] === '<') {
type = 'double_' + type;
line = line.slice(1);
}
break;
case 'o':
type = 'arrow_circle';
if (str[0] === 'o') {
type = 'double_' + type;
line = line.slice(1);
}
break;
}
let stroke = 'normal';
let length = line.length - 1;
if (line[0] === '=') {
stroke = 'thick';
}
let dots = countChar('.', line);
if (dots) {
stroke = 'dotted';
length = dots;
}
return { type, stroke, length };
};
const destructLink = (_str, _startStr) => {
const info = destructEndLink(_str);
let startInfo;
if (_startStr) {
startInfo = destructStartLink(_startStr);
if (startInfo.stroke !== info.stroke) {
return { type: 'INVALID', stroke: 'INVALID' };
}
if (startInfo.type === 'arrow_open') {
// -- xyz --> - take arrow type from ending
startInfo.type = info.type;
} else {
// x-- xyz --> - not supported
if (startInfo.type !== info.type) return { type: 'INVALID', stroke: 'INVALID' };
startInfo.type = 'double_' + startInfo.type;
}
if (startInfo.type === 'double_arrow') {
startInfo.type = 'double_arrow_point';
}
startInfo.length = info.length;
return startInfo;
}
return info;
};
// Todo optimizer this by caching existing nodes
const exists = (allSgs, _id) => {
let res = false;
allSgs.forEach((sg) => {
const pos = sg.nodes.indexOf(_id);
if (pos >= 0) {
res = true;
}
});
return res;
};
/**
* Deletes an id from all subgraphs
*
* @param sg
* @param allSubgraphs
*/
const makeUniq = (sg, allSubgraphs) => {
const res = [];
sg.nodes.forEach((_id, pos) => {
if (!exists(allSubgraphs, _id)) {
res.push(sg.nodes[pos]);
}
});
return { nodes: res };
};
export default {
parseDirective,
defaultConfig: () => configApi.defaultConfig.flowchart,
setAccTitle,
getAccTitle,
getAccDescription,
setAccDescription,
addVertex,
lookUpDomId,
addLink,
updateLinkInterpolate,
updateLink,
addClass,
setDirection,
setClass,
setTooltip,
getTooltip,
setClickEvent,
setLink,
bindFunctions,
getDirection,
getVertices,
getEdges,
getClasses,
clear,
setGen,
defaultStyle,
addSubGraph,
getDepthFirstPos,
indexNodes,
getSubGraphs,
destructLink,
lex: {
firstGraph,
},
exists,
makeUniq,
};

View File

@@ -0,0 +1,43 @@
import flowDb from './flowDb';
describe('flow db subgraphs', () => {
let subgraphs;
beforeEach(() => {
subgraphs = [
{ nodes: ['a', 'b', 'c', 'e'] },
{ nodes: ['f', 'g', 'h'] },
{ nodes: ['i', 'j'] },
{ nodes: ['k'] },
];
});
describe('exist', () => {
it('should return true when the is exists in a subgraph', () => {
expect(flowDb.exists(subgraphs, 'a')).toBe(true);
expect(flowDb.exists(subgraphs, 'h')).toBe(true);
expect(flowDb.exists(subgraphs, 'j')).toBe(true);
expect(flowDb.exists(subgraphs, 'k')).toBe(true);
});
it('should return false when the is exists in a subgraph', () => {
expect(flowDb.exists(subgraphs, 'a2')).toBe(false);
expect(flowDb.exists(subgraphs, 'l')).toBe(false);
});
});
describe('makeUniq', () => {
it('should remove ids from sungraph that already exists in another subgraph even if it gets empty', () => {
const subgraph = flowDb.makeUniq({ nodes: ['i', 'j'] }, subgraphs);
expect(subgraph.nodes).toEqual([]);
});
it('should remove ids from sungraph that already exists in another subgraph', () => {
const subgraph = flowDb.makeUniq({ nodes: ['i', 'j', 'o'] }, subgraphs);
expect(subgraph.nodes).toEqual(['o']);
});
it('should not remove ids from subgraph if they are unique', () => {
const subgraph = flowDb.makeUniq({ nodes: ['q', 'r', 's'] }, subgraphs);
expect(subgraph.nodes).toEqual(['q', 'r', 's']);
});
});
});

View File

@@ -0,0 +1,8 @@
import type { DiagramDetector } from '../../diagram-api/detectType';
export const flowDetectorV2: DiagramDetector = (txt, config) => {
// If we have confgured to use dagre-wrapper then we should return true in this function for graph code thus making it use the new flowchart diagram
if (config?.flowchart?.defaultRenderer === 'dagre-wrapper' && txt.match(/^\s*graph/) !== null)
return true;
return txt.match(/^\s*flowchart/) !== null;
};

View File

@@ -0,0 +1,8 @@
import type { DiagramDetector } from '../../diagram-api/detectType';
export const flowDetector: DiagramDetector = (txt, config) => {
// If we have confired to only use new flow charts this function shohuld always return false
// as in not signalling true for a legacy flowchart
if (config?.flowchart?.defaultRenderer === 'dagre-wrapper') return false;
return txt.match(/^\s*graph/) !== null;
};

View File

@@ -0,0 +1,510 @@
import graphlib from 'graphlib';
import { select, curveLinear, selectAll } from 'd3';
import flowDb from './flowDb';
import { getConfig } from '../../config';
import { render } from '../../dagre-wrapper/index.js';
import addHtmlLabel from 'dagre-d3/lib/label/add-html-label.js';
import { log } from '../../logger';
import common, { evaluate } from '../common/common';
import { interpolateToCurve, getStylesFromArray } from '../../utils';
import { setupGraphViewbox } from '../../setupGraphViewbox';
import addSVGAccessibilityFields from '../../accessibility';
const conf = {};
export const setConf = function (cnf) {
const keys = Object.keys(cnf);
for (let i = 0; i < keys.length; i++) {
conf[keys[i]] = cnf[keys[i]];
}
};
/**
* Function that adds the vertices found during parsing to the graph to be rendered.
*
* @param vert Object containing the vertices.
* @param g The graph that is to be drawn.
* @param svgId
* @param root
* @param doc
* @param diagObj
*/
export const addVertices = function (vert, g, svgId, root, doc, diagObj) {
const svg = root.select(`[id="${svgId}"]`);
const keys = Object.keys(vert);
// Iterate through each item in the vertex object (containing all the vertices found) in the graph definition
keys.forEach(function (id) {
const vertex = vert[id];
/**
* Variable for storing the classes for the vertex
*
* @type {string}
*/
let classStr = 'default';
if (vertex.classes.length > 0) {
classStr = vertex.classes.join(' ');
}
const styles = getStylesFromArray(vertex.styles);
// Use vertex id as text in the box if no text is provided by the graph definition
let vertexText = vertex.text !== undefined ? vertex.text : vertex.id;
// We create a SVG label, either by delegating to addHtmlLabel or manually
let vertexNode;
if (evaluate(getConfig().flowchart.htmlLabels)) {
// TODO: addHtmlLabel accepts a labelStyle. Do we possibly have that?
const node = {
label: vertexText.replace(
/fa[lrsb]?:fa-[\w-]+/g,
(s) => `<i class='${s.replace(':', ' ')}'></i>`
),
};
vertexNode = addHtmlLabel(svg, node).node();
vertexNode.parentNode.removeChild(vertexNode);
} else {
const svgLabel = doc.createElementNS('http://www.w3.org/2000/svg', 'text');
svgLabel.setAttribute('style', styles.labelStyle.replace('color:', 'fill:'));
const rows = vertexText.split(common.lineBreakRegex);
for (let j = 0; j < rows.length; j++) {
const tspan = doc.createElementNS('http://www.w3.org/2000/svg', 'tspan');
tspan.setAttributeNS('http://www.w3.org/XML/1998/namespace', 'xml:space', 'preserve');
tspan.setAttribute('dy', '1em');
tspan.setAttribute('x', '1');
tspan.textContent = rows[j];
svgLabel.appendChild(tspan);
}
vertexNode = svgLabel;
}
let radious = 0;
let _shape = '';
// Set the shape based parameters
switch (vertex.type) {
case 'round':
radious = 5;
_shape = 'rect';
break;
case 'square':
_shape = 'rect';
break;
case 'diamond':
_shape = 'question';
break;
case 'hexagon':
_shape = 'hexagon';
break;
case 'odd':
_shape = 'rect_left_inv_arrow';
break;
case 'lean_right':
_shape = 'lean_right';
break;
case 'lean_left':
_shape = 'lean_left';
break;
case 'trapezoid':
_shape = 'trapezoid';
break;
case 'inv_trapezoid':
_shape = 'inv_trapezoid';
break;
case 'odd_right':
_shape = 'rect_left_inv_arrow';
break;
case 'circle':
_shape = 'circle';
break;
case 'ellipse':
_shape = 'ellipse';
break;
case 'stadium':
_shape = 'stadium';
break;
case 'subroutine':
_shape = 'subroutine';
break;
case 'cylinder':
_shape = 'cylinder';
break;
case 'group':
_shape = 'rect';
break;
case 'doublecircle':
_shape = 'doublecircle';
break;
default:
_shape = 'rect';
}
// Add the node
g.setNode(vertex.id, {
labelStyle: styles.labelStyle,
shape: _shape,
labelText: vertexText,
rx: radious,
ry: radious,
class: classStr,
style: styles.style,
id: vertex.id,
link: vertex.link,
linkTarget: vertex.linkTarget,
tooltip: diagObj.db.getTooltip(vertex.id) || '',
domId: diagObj.db.lookUpDomId(vertex.id),
haveCallback: vertex.haveCallback,
width: vertex.type === 'group' ? 500 : undefined,
dir: vertex.dir,
type: vertex.type,
props: vertex.props,
padding: getConfig().flowchart.padding,
});
log.info('setNode', {
labelStyle: styles.labelStyle,
shape: _shape,
labelText: vertexText,
rx: radious,
ry: radious,
class: classStr,
style: styles.style,
id: vertex.id,
domId: diagObj.db.lookUpDomId(vertex.id),
width: vertex.type === 'group' ? 500 : undefined,
type: vertex.type,
dir: vertex.dir,
props: vertex.props,
padding: getConfig().flowchart.padding,
});
});
};
/**
* Add edges to graph based on parsed graph definition
*
* @param {object} edges The edges to add to the graph
* @param {object} g The graph object
* @param diagObj
*/
export const addEdges = function (edges, g, diagObj) {
log.info('abc78 edges = ', edges);
let cnt = 0;
let linkIdCnt = {};
let defaultStyle;
let defaultLabelStyle;
if (typeof edges.defaultStyle !== 'undefined') {
const defaultStyles = getStylesFromArray(edges.defaultStyle);
defaultStyle = defaultStyles.style;
defaultLabelStyle = defaultStyles.labelStyle;
}
edges.forEach(function (edge) {
cnt++;
// Identify Link
var linkIdBase = 'L-' + edge.start + '-' + edge.end;
// count the links from+to the same node to give unique id
if (typeof linkIdCnt[linkIdBase] === 'undefined') {
linkIdCnt[linkIdBase] = 0;
log.info('abc78 new entry', linkIdBase, linkIdCnt[linkIdBase]);
} else {
linkIdCnt[linkIdBase]++;
log.info('abc78 new entry', linkIdBase, linkIdCnt[linkIdBase]);
}
let linkId = linkIdBase + '-' + linkIdCnt[linkIdBase];
log.info('abc78 new link id to be used is', linkIdBase, linkId, linkIdCnt[linkIdBase]);
var linkNameStart = 'LS-' + edge.start;
var linkNameEnd = 'LE-' + edge.end;
const edgeData = { style: '', labelStyle: '' };
edgeData.minlen = edge.length || 1;
//edgeData.id = 'id' + cnt;
// Set link type for rendering
if (edge.type === 'arrow_open') {
edgeData.arrowhead = 'none';
} else {
edgeData.arrowhead = 'normal';
}
// Check of arrow types, placed here in order not to break old rendering
edgeData.arrowTypeStart = 'arrow_open';
edgeData.arrowTypeEnd = 'arrow_open';
/* eslint-disable no-fallthrough */
switch (edge.type) {
case 'double_arrow_cross':
edgeData.arrowTypeStart = 'arrow_cross';
case 'arrow_cross':
edgeData.arrowTypeEnd = 'arrow_cross';
break;
case 'double_arrow_point':
edgeData.arrowTypeStart = 'arrow_point';
case 'arrow_point':
edgeData.arrowTypeEnd = 'arrow_point';
break;
case 'double_arrow_circle':
edgeData.arrowTypeStart = 'arrow_circle';
case 'arrow_circle':
edgeData.arrowTypeEnd = 'arrow_circle';
break;
}
let style = '';
let labelStyle = '';
switch (edge.stroke) {
case 'normal':
style = 'fill:none;';
if (typeof defaultStyle !== 'undefined') {
style = defaultStyle;
}
if (typeof defaultLabelStyle !== 'undefined') {
labelStyle = defaultLabelStyle;
}
edgeData.thickness = 'normal';
edgeData.pattern = 'solid';
break;
case 'dotted':
edgeData.thickness = 'normal';
edgeData.pattern = 'dotted';
edgeData.style = 'fill:none;stroke-width:2px;stroke-dasharray:3;';
break;
case 'thick':
edgeData.thickness = 'thick';
edgeData.pattern = 'solid';
edgeData.style = 'stroke-width: 3.5px;fill:none;';
break;
}
if (typeof edge.style !== 'undefined') {
const styles = getStylesFromArray(edge.style);
style = styles.style;
labelStyle = styles.labelStyle;
}
edgeData.style = edgeData.style += style;
edgeData.labelStyle = edgeData.labelStyle += labelStyle;
if (typeof edge.interpolate !== 'undefined') {
edgeData.curve = interpolateToCurve(edge.interpolate, curveLinear);
} else if (typeof edges.defaultInterpolate !== 'undefined') {
edgeData.curve = interpolateToCurve(edges.defaultInterpolate, curveLinear);
} else {
edgeData.curve = interpolateToCurve(conf.curve, curveLinear);
}
if (typeof edge.text === 'undefined') {
if (typeof edge.style !== 'undefined') {
edgeData.arrowheadStyle = 'fill: #333';
}
} else {
edgeData.arrowheadStyle = 'fill: #333';
edgeData.labelpos = 'c';
}
edgeData.labelType = 'text';
edgeData.label = edge.text.replace(common.lineBreakRegex, '\n');
if (typeof edge.style === 'undefined') {
edgeData.style = edgeData.style || 'stroke: #333; stroke-width: 1.5px;fill:none;';
}
edgeData.labelStyle = edgeData.labelStyle.replace('color:', 'fill:');
edgeData.id = linkId;
edgeData.classes = 'flowchart-link ' + linkNameStart + ' ' + linkNameEnd;
// Add the edge to the graph
g.setEdge(edge.start, edge.end, edgeData, cnt);
});
};
/**
* Returns the all the styles from classDef statements in the graph definition.
*
* @param text
* @param diagObj
* @returns {object} ClassDef styles
*/
export const getClasses = function (text, diagObj) {
log.info('Extracting classes');
diagObj.db.clear();
try {
// Parse the graph definition
diagObj.parse(text);
return diagObj.db.getClasses();
} catch (e) {
return;
}
};
/**
* Draws a flowchart in the tag with id: id based on the graph definition in text.
*
* @param text
* @param id
*/
export const draw = function (text, id, _version, diagObj) {
log.info('Drawing flowchart');
diagObj.db.clear();
flowDb.setGen('gen-2');
// Parse the graph definition
diagObj.parser.parse(text);
// Fetch the default direction, use TD if none was found
let dir = diagObj.db.getDirection();
if (typeof dir === 'undefined') {
dir = 'TD';
}
const { securityLevel, flowchart: conf } = getConfig();
const nodeSpacing = conf.nodeSpacing || 50;
const rankSpacing = conf.rankSpacing || 50;
// Handle root and document for when rendering in sandbox mode
let sandboxElement;
if (securityLevel === 'sandbox') {
sandboxElement = select('#i' + id);
}
const root =
securityLevel === 'sandbox'
? select(sandboxElement.nodes()[0].contentDocument.body)
: select('body');
const doc = securityLevel === 'sandbox' ? sandboxElement.nodes()[0].contentDocument : document;
// Create the input mermaid.graph
const g = new graphlib.Graph({
multigraph: true,
compound: true,
})
.setGraph({
rankdir: dir,
nodesep: nodeSpacing,
ranksep: rankSpacing,
marginx: 0,
marginy: 0,
})
.setDefaultEdgeLabel(function () {
return {};
});
let subG;
const subGraphs = diagObj.db.getSubGraphs();
log.info('Subgraphs - ', subGraphs);
for (let i = subGraphs.length - 1; i >= 0; i--) {
subG = subGraphs[i];
log.info('Subgraph - ', subG);
diagObj.db.addVertex(subG.id, subG.title, 'group', undefined, subG.classes, subG.dir);
}
// Fetch the vertices/nodes and edges/links from the parsed graph definition
const vert = diagObj.db.getVertices();
const edges = diagObj.db.getEdges();
log.info(edges);
let i = 0;
for (i = subGraphs.length - 1; i >= 0; i--) {
// for (let i = 0; i < subGraphs.length; i++) {
subG = subGraphs[i];
selectAll('cluster').append('text');
for (let j = 0; j < subG.nodes.length; j++) {
log.info('Setting up subgraphs', subG.nodes[j], subG.id);
g.setParent(subG.nodes[j], subG.id);
}
}
addVertices(vert, g, id, root, doc, diagObj);
addEdges(edges, g, diagObj);
// Add custom shapes
// flowChartShapes.addToRenderV2(addShape);
// Set up an SVG group so that we can translate the final graph.
const svg = root.select(`[id="${id}"]`);
// Adds title and description to the flow chart
addSVGAccessibilityFields(diagObj.db, svg, id);
// Run the renderer. This is what draws the final graph.
const element = root.select('#' + id + ' g');
render(element, g, ['point', 'circle', 'cross'], 'flowchart', id);
setupGraphViewbox(g, svg, conf.diagramPadding, conf.useMaxWidth);
// Index nodes
diagObj.db.indexNodes('subGraph' + i);
// Add label rects for non html labels
if (!conf.htmlLabels) {
const labels = doc.querySelectorAll('[id="' + id + '"] .edgeLabel .label');
for (let k = 0; k < labels.length; k++) {
const label = labels[k];
// Get dimensions of label
const dim = label.getBBox();
const rect = doc.createElementNS('http://www.w3.org/2000/svg', 'rect');
rect.setAttribute('rx', 0);
rect.setAttribute('ry', 0);
rect.setAttribute('width', dim.width);
rect.setAttribute('height', dim.height);
label.insertBefore(rect, label.firstChild);
}
}
// If node has a link, wrap it in an anchor SVG object.
const keys = Object.keys(vert);
keys.forEach(function (key) {
const vertex = vert[key];
if (vertex.link) {
const node = select('#' + id + ' [id="' + key + '"]');
if (node) {
const link = doc.createElementNS('http://www.w3.org/2000/svg', 'a');
link.setAttributeNS('http://www.w3.org/2000/svg', 'class', vertex.classes.join(' '));
link.setAttributeNS('http://www.w3.org/2000/svg', 'href', vertex.link);
link.setAttributeNS('http://www.w3.org/2000/svg', 'rel', 'noopener');
if (securityLevel === 'sandbox') {
link.setAttributeNS('http://www.w3.org/2000/svg', 'target', '_top');
} else if (vertex.linkTarget) {
link.setAttributeNS('http://www.w3.org/2000/svg', 'target', vertex.linkTarget);
}
const linkNode = node.insert(function () {
return link;
}, ':first-child');
const shape = node.select('.label-container');
if (shape) {
linkNode.append(function () {
return shape.node();
});
}
const label = node.select('.label');
if (label) {
linkNode.append(function () {
return label.node();
});
}
}
}
});
};
export default {
setConf,
addVertices,
addEdges,
getClasses,
draw,
};

View File

@@ -0,0 +1,151 @@
import flowDb from './flowDb';
import flowParser from './parser/flow';
import flowRenderer from './flowRenderer';
import Diagram from '../../Diagram';
import { addDiagrams } from '../../diagram-api/diagram-orchestration';
addDiagrams();
describe('when using mermaid and ', function () {
describe('when calling addEdges ', function () {
beforeEach(function () {
flowParser.parser.yy = flowDb;
flowDb.clear();
flowDb.setGen('gen-2');
});
it('should handle edges with text', function () {
const diag = new Diagram('graph TD;A-->|text ex|B;');
diag.db.getVertices();
const edges = diag.db.getEdges();
const mockG = {
setEdge: function (start, end, options) {
expect(start).toContain('flowchart-A-');
expect(end).toContain('flowchart-B-');
expect(options.arrowhead).toBe('normal');
expect(options.label.match('text ex')).toBeTruthy();
},
};
flowRenderer.addEdges(edges, mockG, diag);
});
it('should handle edges without text', function () {
const diag = new Diagram('graph TD;A-->B;');
diag.db.getVertices();
const edges = diag.db.getEdges();
const mockG = {
setEdge: function (start, end, options) {
expect(start).toContain('flowchart-A-');
expect(end).toContain('flowchart-B-');
expect(options.arrowhead).toBe('normal');
},
};
flowRenderer.addEdges(edges, mockG, diag);
});
it('should handle open-ended edges', function () {
const diag = new Diagram('graph TD;A---B;');
diag.db.getVertices();
const edges = diag.db.getEdges();
const mockG = {
setEdge: function (start, end, options) {
expect(start).toContain('flowchart-A-');
expect(end).toContain('flowchart-B-');
expect(options.arrowhead).toBe('none');
},
};
flowRenderer.addEdges(edges, mockG, diag);
});
it('should handle edges with styles defined', function () {
const diag = new Diagram('graph TD;A---B; linkStyle 0 stroke:val1,stroke-width:val2;');
diag.db.getVertices();
const edges = diag.db.getEdges();
const mockG = {
setEdge: function (start, end, options) {
expect(start).toContain('flowchart-A-');
expect(end).toContain('flowchart-B-');
expect(options.arrowhead).toBe('none');
expect(options.style).toBe('stroke:val1;stroke-width:val2;fill:none;');
},
};
flowRenderer.addEdges(edges, mockG, diag);
});
it('should handle edges with interpolation defined', function () {
const diag = new Diagram('graph TD;A---B; linkStyle 0 interpolate basis');
diag.db.getVertices();
const edges = diag.db.getEdges();
const mockG = {
setEdge: function (start, end, options) {
expect(start).toContain('flowchart-A-');
expect(end).toContain('flowchart-B-');
expect(options.arrowhead).toBe('none');
expect(options.curve).toBe('basis'); // mocked as string
},
};
flowRenderer.addEdges(edges, mockG, diag);
});
it('should handle edges with text and styles defined', function () {
const diag = new Diagram(
'graph TD;A---|the text|B; linkStyle 0 stroke:val1,stroke-width:val2;'
);
diag.db.getVertices();
const edges = diag.db.getEdges();
const mockG = {
setEdge: function (start, end, options) {
expect(start).toContain('flowchart-A-');
expect(end).toContain('flowchart-B-');
expect(options.arrowhead).toBe('none');
expect(options.label.match('the text')).toBeTruthy();
expect(options.style).toBe('stroke:val1;stroke-width:val2;fill:none;');
},
};
flowRenderer.addEdges(edges, mockG, diag);
});
it('should set fill to "none" by default when handling edges', function () {
const diag = new Diagram('graph TD;A---B; linkStyle 0 stroke:val1,stroke-width:val2;');
diag.db.getVertices();
const edges = diag.db.getEdges();
const mockG = {
setEdge: function (start, end, options) {
expect(start).toContain('flowchart-A-');
expect(end).toContain('flowchart-B');
expect(options.arrowhead).toBe('none');
expect(options.style).toBe('stroke:val1;stroke-width:val2;fill:none;');
},
};
flowRenderer.addEdges(edges, mockG, diag);
});
it('should not set fill to none if fill is set in linkStyle', function () {
const diag = new Diagram(
'graph TD;A---B; linkStyle 0 stroke:val1,stroke-width:val2,fill:blue;'
);
diag.db.getVertices();
const edges = diag.db.getEdges();
const mockG = {
setEdge: function (start, end, options) {
expect(start).toContain('flowchart-A-');
expect(end).toContain('flowchart-B-');
expect(options.arrowhead).toBe('none');
expect(options.style).toBe('stroke:val1;stroke-width:val2;fill:blue;');
},
};
flowRenderer.addEdges(edges, mockG, diag);
});
});
});

View File

@@ -0,0 +1,525 @@
import graphlib from 'graphlib';
import { select, curveLinear, selectAll } from 'd3';
import { getConfig } from '../../config';
import dagreD3 from 'dagre-d3';
import addHtmlLabel from 'dagre-d3/lib/label/add-html-label.js';
import { log } from '../../logger';
import common, { evaluate } from '../common/common';
import { interpolateToCurve, getStylesFromArray } from '../../utils';
import { setupGraphViewbox } from '../../setupGraphViewbox';
import flowChartShapes from './flowChartShapes';
import addSVGAccessibilityFields from '../../accessibility';
const conf = {};
export const setConf = function (cnf) {
const keys = Object.keys(cnf);
for (let i = 0; i < keys.length; i++) {
conf[keys[i]] = cnf[keys[i]];
}
};
/**
* Function that adds the vertices found in the graph definition to the graph to be rendered.
*
* @param vert Object containing the vertices.
* @param g The graph that is to be drawn.
* @param svgId
* @param root
* @param _doc
* @param diagObj
*/
export const addVertices = function (vert, g, svgId, root, _doc, diagObj) {
const svg = !root ? select(`[id="${svgId}"]`) : root.select(`[id="${svgId}"]`);
const doc = !_doc ? document : _doc;
const keys = Object.keys(vert);
// Iterate through each item in the vertex object (containing all the vertices found) in the graph definition
keys.forEach(function (id) {
const vertex = vert[id];
/**
* Variable for storing the classes for the vertex
*
* @type {string}
*/
let classStr = 'default';
if (vertex.classes.length > 0) {
classStr = vertex.classes.join(' ');
}
const styles = getStylesFromArray(vertex.styles);
// Use vertex id as text in the box if no text is provided by the graph definition
let vertexText = vertex.text !== undefined ? vertex.text : vertex.id;
// We create a SVG label, either by delegating to addHtmlLabel or manually
let vertexNode;
if (evaluate(getConfig().flowchart.htmlLabels)) {
// TODO: addHtmlLabel accepts a labelStyle. Do we possibly have that?
const node = {
label: vertexText.replace(
/fa[lrsb]?:fa-[\w-]+/g,
(s) => `<i class='${s.replace(':', ' ')}'></i>`
),
};
vertexNode = addHtmlLabel(svg, node).node();
vertexNode.parentNode.removeChild(vertexNode);
} else {
const svgLabel = doc.createElementNS('http://www.w3.org/2000/svg', 'text');
svgLabel.setAttribute('style', styles.labelStyle.replace('color:', 'fill:'));
const rows = vertexText.split(common.lineBreakRegex);
for (let j = 0; j < rows.length; j++) {
const tspan = doc.createElementNS('http://www.w3.org/2000/svg', 'tspan');
tspan.setAttributeNS('http://www.w3.org/XML/1998/namespace', 'xml:space', 'preserve');
tspan.setAttribute('dy', '1em');
tspan.setAttribute('x', '1');
tspan.textContent = rows[j];
svgLabel.appendChild(tspan);
}
vertexNode = svgLabel;
}
let radious = 0;
let _shape = '';
// Set the shape based parameters
switch (vertex.type) {
case 'round':
radious = 5;
_shape = 'rect';
break;
case 'square':
_shape = 'rect';
break;
case 'diamond':
_shape = 'question';
break;
case 'hexagon':
_shape = 'hexagon';
break;
case 'odd':
_shape = 'rect_left_inv_arrow';
break;
case 'lean_right':
_shape = 'lean_right';
break;
case 'lean_left':
_shape = 'lean_left';
break;
case 'trapezoid':
_shape = 'trapezoid';
break;
case 'inv_trapezoid':
_shape = 'inv_trapezoid';
break;
case 'odd_right':
_shape = 'rect_left_inv_arrow';
break;
case 'circle':
_shape = 'circle';
break;
case 'ellipse':
_shape = 'ellipse';
break;
case 'stadium':
_shape = 'stadium';
break;
case 'subroutine':
_shape = 'subroutine';
break;
case 'cylinder':
_shape = 'cylinder';
break;
case 'group':
_shape = 'rect';
break;
default:
_shape = 'rect';
}
// Add the node
log.warn('Adding node', vertex.id, vertex.domId);
g.setNode(diagObj.db.lookUpDomId(vertex.id), {
labelType: 'svg',
labelStyle: styles.labelStyle,
shape: _shape,
label: vertexNode,
rx: radious,
ry: radious,
class: classStr,
style: styles.style,
id: diagObj.db.lookUpDomId(vertex.id),
});
});
};
/**
* Add edges to graph based on parsed graph definition
*
* @param {object} edges The edges to add to the graph
* @param {object} g The graph object
* @param diagObj
*/
export const addEdges = function (edges, g, diagObj) {
let cnt = 0;
let defaultStyle;
let defaultLabelStyle;
if (typeof edges.defaultStyle !== 'undefined') {
const defaultStyles = getStylesFromArray(edges.defaultStyle);
defaultStyle = defaultStyles.style;
defaultLabelStyle = defaultStyles.labelStyle;
}
edges.forEach(function (edge) {
cnt++;
// Identify Link
var linkId = 'L-' + edge.start + '-' + edge.end;
var linkNameStart = 'LS-' + edge.start;
var linkNameEnd = 'LE-' + edge.end;
const edgeData = {};
// Set link type for rendering
if (edge.type === 'arrow_open') {
edgeData.arrowhead = 'none';
} else {
edgeData.arrowhead = 'normal';
}
let style = '';
let labelStyle = '';
if (typeof edge.style !== 'undefined') {
const styles = getStylesFromArray(edge.style);
style = styles.style;
labelStyle = styles.labelStyle;
} else {
switch (edge.stroke) {
case 'normal':
style = 'fill:none';
if (typeof defaultStyle !== 'undefined') {
style = defaultStyle;
}
if (typeof defaultLabelStyle !== 'undefined') {
labelStyle = defaultLabelStyle;
}
break;
case 'dotted':
style = 'fill:none;stroke-width:2px;stroke-dasharray:3;';
break;
case 'thick':
style = ' stroke-width: 3.5px;fill:none';
break;
}
}
edgeData.style = style;
edgeData.labelStyle = labelStyle;
if (typeof edge.interpolate !== 'undefined') {
edgeData.curve = interpolateToCurve(edge.interpolate, curveLinear);
} else if (typeof edges.defaultInterpolate !== 'undefined') {
edgeData.curve = interpolateToCurve(edges.defaultInterpolate, curveLinear);
} else {
edgeData.curve = interpolateToCurve(conf.curve, curveLinear);
}
if (typeof edge.text === 'undefined') {
if (typeof edge.style !== 'undefined') {
edgeData.arrowheadStyle = 'fill: #333';
}
} else {
edgeData.arrowheadStyle = 'fill: #333';
edgeData.labelpos = 'c';
if (evaluate(getConfig().flowchart.htmlLabels)) {
edgeData.labelType = 'html';
edgeData.label = `<span id="L-${linkId}" class="edgeLabel L-${linkNameStart}' L-${linkNameEnd}" style="${
edgeData.labelStyle
}">${edge.text.replace(
/fa[lrsb]?:fa-[\w-]+/g,
(s) => `<i class='${s.replace(':', ' ')}'></i>`
)}</span>`;
} else {
edgeData.labelType = 'text';
edgeData.label = edge.text.replace(common.lineBreakRegex, '\n');
if (typeof edge.style === 'undefined') {
edgeData.style = edgeData.style || 'stroke: #333; stroke-width: 1.5px;fill:none';
}
edgeData.labelStyle = edgeData.labelStyle.replace('color:', 'fill:');
}
}
edgeData.id = linkId;
edgeData.class = linkNameStart + ' ' + linkNameEnd;
edgeData.minlen = edge.length || 1;
// Add the edge to the graph
g.setEdge(diagObj.db.lookUpDomId(edge.start), diagObj.db.lookUpDomId(edge.end), edgeData, cnt);
});
};
/**
* Returns the all the styles from classDef statements in the graph definition.
*
* @param text
* @param diagObj
* @returns {object} ClassDef styles
*/
export const getClasses = function (text, diagObj) {
log.info('Extracting classes');
diagObj.db.clear();
try {
// Parse the graph definition
diagObj.parse(text);
return diagObj.db.getClasses();
} catch (e) {
return;
}
};
/**
* Draws a flowchart in the tag with id: id based on the graph definition in text.
*
* @param text
* @param id
* @param _version
* @param diagObj
*/
export const draw = function (text, id, _version, diagObj) {
log.info('Drawing flowchart');
diagObj.db.clear();
const { securityLevel, flowchart: conf } = getConfig();
let sandboxElement;
if (securityLevel === 'sandbox') {
sandboxElement = select('#i' + id);
}
const root =
securityLevel === 'sandbox'
? select(sandboxElement.nodes()[0].contentDocument.body)
: select('body');
const doc = securityLevel === 'sandbox' ? sandboxElement.nodes()[0].contentDocument : document;
// Parse the graph definition
try {
diagObj.parser.parse(text);
} catch (err) {
log.debug('Parsing failed');
}
// Fetch the default direction, use TD if none was found
let dir = diagObj.db.getDirection();
if (typeof dir === 'undefined') {
dir = 'TD';
}
const nodeSpacing = conf.nodeSpacing || 50;
const rankSpacing = conf.rankSpacing || 50;
// Create the input mermaid.graph
const g = new graphlib.Graph({
multigraph: true,
compound: true,
})
.setGraph({
rankdir: dir,
nodesep: nodeSpacing,
ranksep: rankSpacing,
marginx: 8,
marginy: 8,
})
.setDefaultEdgeLabel(function () {
return {};
});
let subG;
const subGraphs = diagObj.db.getSubGraphs();
for (let i = subGraphs.length - 1; i >= 0; i--) {
subG = subGraphs[i];
diagObj.db.addVertex(subG.id, subG.title, 'group', undefined, subG.classes);
}
// Fetch the vertices/nodes and edges/links from the parsed graph definition
const vert = diagObj.db.getVertices();
log.warn('Get vertices', vert);
const edges = diagObj.db.getEdges();
let i = 0;
for (i = subGraphs.length - 1; i >= 0; i--) {
subG = subGraphs[i];
selectAll('cluster').append('text');
for (let j = 0; j < subG.nodes.length; j++) {
log.warn(
'Setting subgraph',
subG.nodes[j],
diagObj.db.lookUpDomId(subG.nodes[j]),
diagObj.db.lookUpDomId(subG.id)
);
g.setParent(diagObj.db.lookUpDomId(subG.nodes[j]), diagObj.db.lookUpDomId(subG.id));
}
}
addVertices(vert, g, id, root, doc, diagObj);
addEdges(edges, g, diagObj);
// Create the renderer
const Render = dagreD3.render;
const render = new Render();
// Add custom shapes
flowChartShapes.addToRender(render);
// Add our custom arrow - an empty arrowhead
render.arrows().none = function normal(parent, id, edge, type) {
const marker = parent
.append('marker')
.attr('id', id)
.attr('viewBox', '0 0 10 10')
.attr('refX', 9)
.attr('refY', 5)
.attr('markerUnits', 'strokeWidth')
.attr('markerWidth', 8)
.attr('markerHeight', 6)
.attr('orient', 'auto');
const path = marker.append('path').attr('d', 'M 0 0 L 0 0 L 0 0 z');
dagreD3.util.applyStyle(path, edge[type + 'Style']);
};
// Override normal arrowhead defined in d3. Remove style & add class to allow css styling.
render.arrows().normal = function normal(parent, id) {
const marker = parent
.append('marker')
.attr('id', id)
.attr('viewBox', '0 0 10 10')
.attr('refX', 9)
.attr('refY', 5)
.attr('markerUnits', 'strokeWidth')
.attr('markerWidth', 8)
.attr('markerHeight', 6)
.attr('orient', 'auto');
marker
.append('path')
.attr('d', 'M 0 0 L 10 5 L 0 10 z')
.attr('class', 'arrowheadPath')
.style('stroke-width', 1)
.style('stroke-dasharray', '1,0');
};
// Set up an SVG group so that we can translate the final graph.
const svg = root.select(`[id="${id}"]`);
// Adds title and description to the flow chart
addSVGAccessibilityFields(diagObj.db, svg, id);
// Run the renderer. This is what draws the final graph.
const element = root.select('#' + id + ' g');
render(element, g);
element.selectAll('g.node').attr('title', function () {
return diagObj.db.getTooltip(this.id);
});
// Index nodes
diagObj.db.indexNodes('subGraph' + i);
// reposition labels
for (i = 0; i < subGraphs.length; i++) {
subG = subGraphs[i];
if (subG.title !== 'undefined') {
const clusterRects = doc.querySelectorAll(
'#' + id + ' [id="' + diagObj.db.lookUpDomId(subG.id) + '"] rect'
);
const clusterEl = doc.querySelectorAll(
'#' + id + ' [id="' + diagObj.db.lookUpDomId(subG.id) + '"]'
);
const xPos = clusterRects[0].x.baseVal.value;
const yPos = clusterRects[0].y.baseVal.value;
const _width = clusterRects[0].width.baseVal.value;
const cluster = select(clusterEl[0]);
const te = cluster.select('.label');
te.attr('transform', `translate(${xPos + _width / 2}, ${yPos + 14})`);
te.attr('id', id + 'Text');
for (let j = 0; j < subG.classes.length; j++) {
clusterEl[0].classList.add(subG.classes[j]);
}
}
}
// Add label rects for non html labels
if (!conf.htmlLabels) {
const labels = doc.querySelectorAll('[id="' + id + '"] .edgeLabel .label');
for (let k = 0; k < labels.length; k++) {
const label = labels[k];
// Get dimensions of label
const dim = label.getBBox();
const rect = doc.createElementNS('http://www.w3.org/2000/svg', 'rect');
rect.setAttribute('rx', 0);
rect.setAttribute('ry', 0);
rect.setAttribute('width', dim.width);
rect.setAttribute('height', dim.height);
// rect.setAttribute('style', 'fill:#e8e8e8;');
label.insertBefore(rect, label.firstChild);
}
}
setupGraphViewbox(g, svg, conf.diagramPadding, conf.useMaxWidth);
// If node has a link, wrap it in an anchor SVG object.
const keys = Object.keys(vert);
keys.forEach(function (key) {
const vertex = vert[key];
if (vertex.link) {
const node = root.select('#' + id + ' [id="' + diagObj.db.lookUpDomId(key) + '"]');
if (node) {
const link = doc.createElementNS('http://www.w3.org/2000/svg', 'a');
link.setAttributeNS('http://www.w3.org/2000/svg', 'class', vertex.classes.join(' '));
link.setAttributeNS('http://www.w3.org/2000/svg', 'href', vertex.link);
link.setAttributeNS('http://www.w3.org/2000/svg', 'rel', 'noopener');
if (securityLevel === 'sandbox') {
link.setAttributeNS('http://www.w3.org/2000/svg', 'target', '_top');
} else if (vertex.linkTarget) {
link.setAttributeNS('http://www.w3.org/2000/svg', 'target', vertex.linkTarget);
}
const linkNode = node.insert(function () {
return link;
}, ':first-child');
const shape = node.select('.label-container');
if (shape) {
linkNode.append(function () {
return shape.node();
});
}
const label = node.select('.label');
if (label) {
linkNode.append(function () {
return label.node();
});
}
}
}
});
};
export default {
setConf,
addVertices,
addEdges,
getClasses,
draw,
};

View File

@@ -0,0 +1,276 @@
import { addVertices, addEdges } from './flowRenderer';
import { setConfig } from '../../config';
setConfig({
flowchart: {
htmlLabels: false,
},
});
describe('the flowchart renderer', function () {
describe('when adding vertices to a graph', function () {
[
['round', 'rect', 5],
['square', 'rect'],
['diamond', 'question'],
['hexagon', 'hexagon'],
['odd', 'rect_left_inv_arrow'],
['lean_right', 'lean_right'],
['lean_left', 'lean_left'],
['trapezoid', 'trapezoid'],
['inv_trapezoid', 'inv_trapezoid'],
['odd_right', 'rect_left_inv_arrow'],
['circle', 'circle'],
['ellipse', 'ellipse'],
['stadium', 'stadium'],
['subroutine', 'subroutine'],
['cylinder', 'cylinder'],
['group', 'rect'],
].forEach(function ([type, expectedShape, expectedRadios = 0]) {
it(`should add the correct shaped node to the graph for vertex type ${type}`, function () {
const fakeDiag = {
db: {
lookUpDomId: () => {
return 'my-node-id';
},
},
};
const addedNodes = [];
const mockG = {
setNode: function (id, object) {
addedNodes.push([id, object]);
},
};
addVertices(
{
v1: {
type,
id: 'my-node-id',
classes: [],
styles: [],
text: 'my vertex text',
},
},
mockG,
'svg-id',
undefined,
undefined,
fakeDiag
);
expect(addedNodes).toHaveLength(1);
expect(addedNodes[0][0]).toEqual('my-node-id');
expect(addedNodes[0][1]).toHaveProperty('id', 'my-node-id');
expect(addedNodes[0][1]).toHaveProperty('labelType', 'svg');
expect(addedNodes[0][1]).toHaveProperty('shape', expectedShape);
expect(addedNodes[0][1]).toHaveProperty('rx', expectedRadios);
expect(addedNodes[0][1]).toHaveProperty('ry', expectedRadios);
});
});
['Multi<br>Line', 'Multi<br/>Line', 'Multi<br />Line', 'Multi<br\t/>Line'].forEach(function (
labelText
) {
it('should handle multiline texts with different line breaks', function () {
const addedNodes = [];
const fakeDiag = {
db: {
lookUpDomId: () => {
return 'my-node-id';
},
},
};
const mockG = {
setNode: function (id, object) {
addedNodes.push([id, object]);
},
};
addVertices(
{
v1: {
type: 'rect',
id: 'my-node-id',
classes: [],
styles: [],
text: 'Multi<br>Line',
},
},
mockG,
'svg-id',
false,
document,
fakeDiag
);
expect(addedNodes).toHaveLength(1);
expect(addedNodes[0][0]).toEqual('my-node-id');
expect(addedNodes[0][1]).toHaveProperty('id', 'my-node-id');
expect(addedNodes[0][1]).toHaveProperty('labelType', 'svg');
expect(addedNodes[0][1].label).toBeDefined();
expect(addedNodes[0][1].label).toBeDefined(); // <text> node
expect(addedNodes[0][1].label.firstChild.innerHTML).toEqual('Multi'); // <tspan> node, line 1
expect(addedNodes[0][1].label.lastChild.innerHTML).toEqual('Line'); // <tspan> node, line 2
});
});
[
[['fill:#fff'], 'fill:#fff;', ''],
[['color:#ccc'], '', 'color:#ccc;'],
[['fill:#fff', 'color:#ccc'], 'fill:#fff;', 'color:#ccc;'],
[
['fill:#fff', 'color:#ccc', 'text-align:center'],
'fill:#fff;',
'color:#ccc;text-align:center;',
],
].forEach(function ([style, expectedStyle, expectedLabelStyle]) {
it(`should add the styles to style and/or labelStyle for style ${style}`, function () {
const addedNodes = [];
const fakeDiag = {
db: {
lookUpDomId: () => {
return 'my-node-id';
},
},
};
const mockG = {
setNode: function (id, object) {
addedNodes.push([id, object]);
},
};
addVertices(
{
v1: {
type: 'rect',
id: 'my-node-id',
classes: [],
styles: style,
text: 'my vertex text',
},
},
mockG,
'svg-id',
undefined,
undefined,
fakeDiag
);
expect(addedNodes).toHaveLength(1);
expect(addedNodes[0][0]).toEqual('my-node-id');
expect(addedNodes[0][1]).toHaveProperty('id', 'my-node-id');
expect(addedNodes[0][1]).toHaveProperty('labelType', 'svg');
expect(addedNodes[0][1]).toHaveProperty('style', expectedStyle);
expect(addedNodes[0][1]).toHaveProperty('labelStyle', expectedLabelStyle);
});
});
it(`should add default class to all nodes which do not have another class assigned`, function () {
const addedNodes = [];
const mockG = {
setNode: function (id, object) {
addedNodes.push([id, object]);
},
};
const fakeDiag = {
db: {
lookUpDomId: () => {
return 'my-node-id';
},
},
};
addVertices(
{
v1: {
type: 'rect',
id: 'my-node-id',
classes: [],
styles: [],
text: 'my vertex text',
},
v2: {
type: 'rect',
id: 'myNode',
classes: ['myClass'],
styles: [],
text: 'my vertex text',
},
},
mockG,
'svg-id',
undefined,
undefined,
fakeDiag
);
expect(addedNodes).toHaveLength(2);
expect(addedNodes[0][0]).toEqual('my-node-id');
expect(addedNodes[0][1]).toHaveProperty('class', 'default');
expect(addedNodes[1][0]).toEqual('my-node-id');
expect(addedNodes[1][1]).toHaveProperty('class', 'myClass');
});
});
describe('when adding edges to a graph', function () {
it('should handle multiline texts and set centered label position', function () {
const addedEdges = [];
const fakeDiag = {
db: {
lookUpDomId: () => {
return 'my-node-id';
},
},
};
const mockG = {
setEdge: function (s, e, data, c) {
addedEdges.push(data);
},
};
addEdges(
[
{ text: 'Multi<br>Line' },
{ text: 'Multi<br/>Line' },
{ text: 'Multi<br />Line' },
{ text: 'Multi<br\t/>Line' },
{ style: ['stroke:DarkGray', 'stroke-width:2px'], text: 'Multi<br>Line' },
{ style: ['stroke:DarkGray', 'stroke-width:2px'], text: 'Multi<br/>Line' },
{ style: ['stroke:DarkGray', 'stroke-width:2px'], text: 'Multi<br />Line' },
{ style: ['stroke:DarkGray', 'stroke-width:2px'], text: 'Multi<br\t/>Line' },
],
mockG,
fakeDiag
);
addedEdges.forEach(function (edge) {
expect(edge).toHaveProperty('label', 'Multi\nLine');
expect(edge).toHaveProperty('labelpos', 'c');
});
});
[
[['stroke:DarkGray'], 'stroke:DarkGray;', ''],
[['color:red'], '', 'fill:red;'],
[['stroke:DarkGray', 'color:red'], 'stroke:DarkGray;', 'fill:red;'],
[
['stroke:DarkGray', 'color:red', 'stroke-width:2px'],
'stroke:DarkGray;stroke-width:2px;',
'fill:red;',
],
].forEach(function ([style, expectedStyle, expectedLabelStyle]) {
it(`should add the styles to style and/or labelStyle for style ${style}`, function () {
const addedEdges = [];
const fakeDiag = {
db: {
lookUpDomId: () => {
return 'my-node-id';
},
},
};
const mockG = {
setEdge: function (s, e, data, c) {
addedEdges.push(data);
},
};
addEdges([{ style: style, text: 'styling' }], mockG, fakeDiag);
expect(addedEdges).toHaveLength(1);
expect(addedEdges[0]).toHaveProperty('style', expectedStyle);
expect(addedEdges[0]).toHaveProperty('labelStyle', expectedLabelStyle);
});
});
});
});

Some files were not shown because too many files have changed in this diff Show More