Adding text wrap and logic for placing nodes in the svg

This commit is contained in:
Knut Sveidqvist 2022-07-23 10:16:54 +02:00
parent 6029c5371e
commit 7de68f0bf2
14 changed files with 564 additions and 46 deletions

View File

@ -41,6 +41,16 @@ journey
Go downstairs: 5: Me
Sit down: 5: Mee
</div>
<div class="mermaid" style="width: 50%;">
mindmap
root[
The root where the things
hap<br/>
hap<br/>
pen!
]
Child1
</div>
<div class="mermaid2" style="width: 50%;">
pie
accTitle: My Pie Chart Accessibility Title
@ -52,8 +62,8 @@ journey
"Magnesium" : 10.01
"Iron" : 5
</div>
<div class="mermaid2" style="width: 50%;">
gitGraph
<div class="mermaid" style="width: 50%;">
gitGraph TB
commit
commit
branch develop
@ -217,7 +227,7 @@ class Class10 {
size()
}
</div>
<div class="mermaid" style="width: 100%;">
<div class="mermaid2" style="width: 100%;">
%%{init: {'config': {'wrap': true }}}%%
sequenceDiagram
participant A as Extremely utterly long line of longness which had previously overflown the actor box as it is much longer than what it should be
@ -332,7 +342,7 @@ flowchart TD
rankSpacing: 50,
defaultRenderer: 'dagre-d3',
},
logLevel: 0,
logLevel: 1,
fontSize: 18,
curve: 'cardinal',
// securityLevel: 'sandbox',

View File

@ -1818,6 +1818,11 @@ const config = {
external_component_queue_bg_color: '#CCCCCC',
external_component_queue_border_color: '#BFBFBF',
},
mindmap: {
useMaxWidth: true,
diagramPadding: 10,
maxNodeWidth: 200,
},
};
config.class.arrowMarkerAbsolute = config.arrowMarkerAbsolute;

View File

@ -83,8 +83,10 @@ const detectType = function (text, cnf) {
if (cnf && cnf.flowchart && cnf.flowchart.defaultRenderer === 'dagre-wrapper')
return 'flowchart-v2';
const k = Object.keys(detectors);
console.log('here', k);
for (let i = 0; i < k.length; i++) {
const key = k[i];
console.log('Detecting type for', key);
const dia = detectors[key];
if (dia && dia.detector(text)) {
return key;

View File

@ -1,24 +1,17 @@
import { registerDiagram } from './diagramAPI.js';
// import mindmapDb from '../diagrams/mindmap/mindmapDb';
// import mindmapRenderer from '../diagrams/mindmap/mindmapRenderer';
// import mindmapParser from '../diagrams/mindmap/parser/mindmapDiagram';
// import mindmapDetector from '../diagrams/mindmap/mindmapDetector';
import mindmapDb from '../diagrams/mindmap/mindmapDb';
import mindmapRenderer from '../diagrams/mindmap/mindmapRenderer';
import mindmapParser from '../diagrams/mindmap/parser/mindmap';
import mindmapDetector from '../diagrams/mindmap/mindmapDetector';
import mindmapStyles from '../diagrams/mindmap/styles';
import gitGraphDb from '../diagrams/git/gitGraphAst';
import gitGraphRenderer from '../diagrams/git/gitGraphRenderer';
import gitGraphParser from '../diagrams/git/parser/gitGraph';
import gitGraphDetector from '../diagrams/git/gitGraphDetector';
import gitGraphStyles from '../diagrams/git/styles';
// Register mindmap and other built-in diagrams
// registerDiagram(
// 'mindmap',
// mindmapParser,
// mindmapDb,
// mindmapRenderer,
// undefined,
// mindmapRenderer,
// mindmapDetector
// );
const addDiagrams = () => {
registerDiagram(
'gitGraph',
@ -26,7 +19,17 @@ const addDiagrams = () => {
gitGraphDb,
gitGraphRenderer,
undefined,
gitGraphDetector
gitGraphDetector,
gitGraphStyles
);
registerDiagram(
'mindmap',
mindmapParser,
mindmapDb,
mindmapRenderer,
undefined,
mindmapDetector,
mindmapStyles
);
};
export default addDiagrams;

View File

@ -35,15 +35,19 @@ import journeyDb from '../diagrams/user-journey/journeyDb';
import journeyRenderer from '../diagrams/user-journey/journeyRenderer';
import journeyParser from '../diagrams/user-journey/parser/journey';
import { addDetector } from './detectType';
import { addStylesForDiagram } from '../styles';
import { sanitizeText as _sanitizeText } from '../diagrams/common/common';
import { getConfig as _getConfig } from '../config';
import { log as _log } from '../logger';
import { setupGraphViewbox as _setupGraphViewbox } from '../setupGraphViewbox';
let title = '';
let diagramTitle = '';
let description = '';
export const getConfig = _getConfig;
export const sanitizeText = (txt) => _sanitizeText(txt, getConfig());
export const log = _log;
const diagrams = {
c4: {
db: c4Db,
@ -171,13 +175,16 @@ const diagrams = {
},
},
};
// console.log(sequenceDb);
export const registerDiagram = (id, parser, db, renderer, init, detector) => {
export const registerDiagram = (id, parser, db, renderer, init, detector, styles) => {
diagrams[id] = { parser, db, renderer, init };
addDetector(id, detector);
addStylesForDiagram(id, styles);
};
export const getDiagrams = () => {
// console.log('diagrams', diagrams);
return diagrams;
};
export const setupGraphViewbox = _setupGraphViewbox;

227
src/diagram-api/text-wrap Normal file
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

@ -1,6 +1,5 @@
/** Created by knut on 15-01-14. */
import { log } from '../../logger';
import { sanitizeText } from '../../diagram-api/diagramAPI';
import { log, sanitizeText, getConfig } from '../../diagram-api/diagramAPI';
var message = '';
var info = false;
@ -26,7 +25,14 @@ export const getMindmap = () => {
return nodes.length > 0 ? nodes[0] : null;
};
export const addNode = (level, id, descr, type) => {
const node = { id: sanitizeText(id), level, descr: sanitizeText(descr), type, children: [] };
const node = {
id: sanitizeText(id),
level,
descr: sanitizeText(descr),
type,
children: [],
width: getConfig().mindmap.maxNodeWidth,
};
const parent = getParent(level);
if (parent) {
parent.children.push(node);

View File

@ -1,4 +1,4 @@
const detector = (txt) => {
const detector = function detect(txt) {
if (txt.match(/^\s*mindmap/)) {
return 'mindmap';
}

View File

@ -1,7 +1,33 @@
/** Created by knut on 14-12-11. */
import { select } from 'd3';
import { log } from '../../logger';
import { getConfig } from '../../config';
import { log, getConfig, setupGraphViewbox } from '../../diagram-api/diagramAPI';
import svgDraw from './svgDraw';
/**
* @param {any} svg The svg element to draw the diagram onto
* @param {object} mindmap The maindmap data and hierarchy
* @param {object} conf The configuration object
*/
function drawNodes(svg, mindmap, conf) {
svgDraw.drawNode(svg, mindmap, conf);
if (mindmap.children) {
mindmap.children.forEach((child) => {
drawNodes(svg, child, conf);
});
}
}
function drawEdges() {}
/**
* @param node
* @param isRoot
*/
function layoutMindmap(node, isRoot) {}
/**
* @param node
* @param isRoot
*/
function positionNodes(node, isRoot) {}
/**
* Draws a an info picture in the tag with id: id based on the graph definition in text.
@ -12,6 +38,7 @@ import { getConfig } from '../../config';
* @param diagObj
*/
export const draw = (text, id, version, diagObj) => {
const conf = getConfig();
try {
// const parser = infoParser.parser;
// parser.yy = db;
@ -30,24 +57,40 @@ export const draw = (text, id, version, diagObj) => {
const doc = securityLevel === 'sandbox' ? sandboxElement.nodes()[0].contentDocument : document;
// Parse the graph definition
// parser.parse(text);
// log.debug('Parsed info diagram');
// Fetch the default direction, use TD if none was found
const svg = root.select('#' + id);
const g = svg.append('g');
const mm = diagObj.db.getMindmap();
g.append('text') // text label for the x axis
.attr('x', 100)
.attr('y', 40)
.attr('class', 'version')
.attr('font-size', '32px')
.style('text-anchor', 'middle')
.text('v ' + version);
// mm.x = 0;
// mm.y = 0;
// svgDraw.drawNode(g, mm, getConfig());
// mm.children.forEach((child) => {
// child.x = 200;
// child.y = 200;
// child.width = 200;
// svgDraw.drawNode(g, child, getConfig());
// });
svg.attr('height', 100);
svg.attr('width', 400);
// svg.attr('viewBox', '0 0 300 150');
// 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 nodesElem = svg.append('g');
nodesElem.attr('class', 'mindmap-nodes');
drawNodes(nodesElem, mm, conf);
// Next step is to layout the mindmap, giving each node a position
// layoutMindmap(mm, conf);
// After this we can draw, first the edges and the then nodes with the correct position
// drawEdges(svg, mm, conf);
// positionNodes(svg, mm, conf);
// Setup the view box and size of the svg element
setupGraphViewbox(undefined, svg, conf.mindmap.diagramPadding, conf.mindmap.useMaxWidth);
} catch (e) {
log.error('Error while rendering info diagram');
log.error(e.message);

View File

@ -1,3 +1,9 @@
const getStyles = () => ``;
const getStyles = (options) =>
`
.node{
stroke: ${options.pieStrokeColor};
stroke-width : ${options.pieStrokeWidth};
opacity : ${options.pieOpacity};
}
`;
export default getStyles;

View File

@ -0,0 +1,100 @@
const lineBreakRegex = /<br\s*\/?>/gi;
import { select } from 'd3';
/**
* @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 = [],
lineNumber = 0,
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);
}
}
});
}
/**
* @param {object} elem The D3 dom element in which the node is to be added
* @param {object} node The node to be added
* @param {object} conf The configuration object
* @returns {number} The height nodes dom element
*/
export const drawNode = function (elem, node, conf) {
const nodeElem = elem.append('g');
nodeElem.attr('class', 'mindmap-node');
const rect = {
fill: '#EDF2AE',
stroke: '#666',
width: node.width,
anchor: 'start',
height: 100,
rx: 3,
ry: 3,
};
const r = nodeElem
.append('rect')
.attr('x', (-1 * node.width) / 2)
.attr('width', node.width)
.attr('fill', rect.fill)
.attr('stroke', rect.stroke)
.attr('rx', rect.rx)
.attr('ry', rect.ry);
const textElem = nodeElem.append('g');
// .attr('transform', 'translate(' + node.x + ', ' + node.y + ')');
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();
node.height = bbox.height + conf.fontSize * 1.1 * 0.5;
r.attr('height', node.height).attr('y', (-1 * node.height) / 2);
txt.attr('transform', 'translate( 0,' + (-1 * node.height) / 2 + ')');
// Position the node to its coordinate
if (node.x || node.y) {
nodeElem.attr('transform', 'translate(' + node.x + ',' + node.y + ')');
}
return node.height;
};
export default { drawNode };

102
src/setupGraphViewbox.js Normal file
View File

@ -0,0 +1,102 @@
import { log } from './logger';
/**
* Applys d3 attributes
*
* @param {any} d3Elem D3 Element to apply the attributes onto
* @param {[string, string][]} attrs Object.keys equivalent format of key to value mapping of attributes
*/
const d3Attrs = function (d3Elem, attrs) {
for (let attr of attrs) {
d3Elem.attr(attr[0], attr[1]);
}
};
/**
* Gives attributes for an SVG's size given arguments
*
* @param {number} height The height of the SVG
* @param {number} width The width of the SVG
* @param {boolean} useMaxWidth Whether or not to use max-width and set width to 100%
* @returns {Map<'height' | 'width' | 'style', string>} Attributes for the SVG
*/
export const calculateSvgSizeAttrs = function (height, width, useMaxWidth) {
let attrs = new Map();
attrs.set('height', height);
if (useMaxWidth) {
attrs.set('width', '100%');
attrs.set('style', `max-width: ${width}px;`);
} else {
attrs.set('width', width);
}
return attrs;
};
/**
* Applies attributes from `calculateSvgSizeAttrs`
*
* @param {SVGSVGElement} svgElem The SVG Element to configure
* @param {number} height The height of the SVG
* @param {number} width The width of the SVG
* @param tx
* @param ty
* @param {boolean} useMaxWidth Whether or not to use max-width and set width to 100%
*/
export const configureSvgSize = function (svgElem, height, width, tx, ty, useMaxWidth) {
const attrs = calculateSvgSizeAttrs(height, width, useMaxWidth);
d3Attrs(svgElem, attrs);
};
export const setupGraphViewbox = function (graph, svgElem, padding, useMaxWidth) {
const svgBounds = svgElem.node().getBBox();
const sWidth = svgBounds.width;
const sHeight = svgBounds.height;
let width;
let height;
let tx = 0;
let ty = 0;
if (graph) {
width = graph._label.width;
height = graph._label.height;
if (sWidth > width) {
tx = (sWidth - width) / 2 + padding;
width = sWidth + padding * 2;
} else {
if (Math.abs(sWidth - width) >= 2 * padding + 1) {
width = width - padding;
}
}
if (sHeight > height) {
ty = (sHeight - height) / 2 + padding;
height = sHeight + padding * 2;
}
} else {
width = sWidth + padding * 2;
height = sHeight + padding * 2;
}
// Ensure the viewBox includes the whole svgBounds area with extra space for padding
const vBox = graph
? `0 0 ${width} ${height}`
: `${svgBounds.x - padding} ${svgBounds.y - padding} ${width} ${height}`;
console.log(
'Graph.label',
graph ? graph._label : null,
'swidth',
sWidth,
'sheight',
sHeight,
'width',
width,
'height',
height,
'tx',
tx,
'ty',
ty,
'vBox',
vBox
);
svgElem.attr('viewBox', vBox);
svgElem.select('g').attr('transform', `translate(${tx}, ${ty})`);
};

View File

@ -2,7 +2,7 @@ import classDiagram from './diagrams/class/styles';
import er from './diagrams/er/styles';
import flowchart from './diagrams/flowchart/styles';
import gantt from './diagrams/gantt/styles';
import gitGraph from './diagrams/git/styles';
// import gitGraph from './diagrams/git/styles';
import info from './diagrams/info/styles';
import pie from './diagrams/pie/styles';
import requirement from './diagrams/requirement/styles';
@ -22,7 +22,7 @@ const themes = {
class: classDiagram,
stateDiagram,
state: stateDiagram,
gitGraph,
// gitGraph,
info,
pie,
er,
@ -89,4 +89,8 @@ const getStyles = (type, userStyles, options) => {
`;
};
export const addStylesForDiagram = (type, diagramTheme, options) => {
themes[type] = diagramTheme;
};
export default getStyles;

View File

@ -1,7 +1,10 @@
import utils from './utils';
import assignWithDepth from './assignWithDepth';
import detectType from './diagram-api/detectType';
import './diagram-api/diagram-orchestration';
import addDiagrams from './diagram-api/diagram-orchestration';
// Orchestrating diagrams and adding the dynamic ones to the list of diagrams
addDiagrams();
describe('when assignWithDepth: should merge objects within objects', function () {
it('should handle simple, depth:1 types (identity)', function () {
@ -214,7 +217,7 @@ Alice->Bob: hi`;
const type = detectType(str);
expect(type).toBe('flowchart');
});
it('should handle a graph definition for gitGraph', function () {
fit('should handle a graph definition for gitGraph', function () {
const str = ' \n gitGraph TB:\nbfs1:queue';
const type = detectType(str);
expect(type).toBe('gitGraph');