From 1c3c2b6a7452ae9df00b3386ed1e557b6c04c729 Mon Sep 17 00:00:00 2001 From: chris moran Date: Wed, 1 Jul 2020 06:18:48 -0400 Subject: [PATCH] utils refactor - memoize to poor-man-cache properly-ish - wrapLabel, calculateTextDimensions (and friends) moved to utils for all to enjoy - drawSimpleText getTextObj moved to utils to support the functions listed above - assignWithDepth - Object.assign but depth-ier - random - for crypto random numbers --- src/utils.js | 334 +++++++++++++++++++++++++++++++++++++++++++--- src/utils.spec.js | 97 ++++++++++++++ 2 files changed, 414 insertions(+), 17 deletions(-) diff --git a/src/utils.js b/src/utils.js index ee3dc66fee..ddfa70151f 100644 --- a/src/utils.js +++ b/src/utils.js @@ -9,10 +9,13 @@ import { curveNatural, curveStep, curveStepAfter, - curveStepBefore + curveStepBefore, + select } from 'd3'; import { logger } from './logger'; import { sanitizeUrl } from '@braintree/sanitize-url'; +import common from './diagrams/common/common'; +import cryptoRandomString from 'crypto-random-string'; // Effectively an enum of the supported curve types, accessible by name const d3CurveTypes = { @@ -60,17 +63,29 @@ const anyComment = /\s*%%.*\n/gm; * ``` * * @param {string} text The text defining the graph - * @returns {object} the json object representing the init to pass to mermaid.initialize() + * @returns {object} the json object representing the init passed to mermaid.initialize() */ export const detectInit = function(text) { let inits = detectDirective(text, /(?:init\b)|(?:initialize\b)/); let results = {}; if (Array.isArray(inits)) { let args = inits.map(init => init.args); - results = Object.assign(results, ...args); + results = assignWithDepth(results, [...args]); } else { results = inits.args; } + if (results) { + let type = detectType(text); + ['config'].forEach(prop => { + if (typeof results[prop] !== 'undefined') { + if (type === 'flowchart-v2') { + type = 'flowchart'; + } + results[type] = results[prop]; + delete results[prop]; + } + }); + } return results; }; @@ -91,8 +106,8 @@ export const detectInit = function(text) { * ``` * * @param {string} text The text defining the graph - * @param {string|RegExp} type The directive to return (default: null - * @returns {object | Array} An object or Array representing the directive(s): { type: string, args: object|null } matchd by the input type + * @param {string|RegExp} type The directive to return (default: null) + * @returns {object | Array} An object or Array representing the directive(s): { type: string, args: object|null } matched by the input type * if a single directive was found, that directive object will be returned. */ export const detectDirective = function(text, type = null) { @@ -206,6 +221,20 @@ export const detectType = function(text) { return 'flowchart'; }; +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; + } + }; +}; + /** * @function isSubstringInArray * Detects whether a substring in present in a given array @@ -241,13 +270,13 @@ export const formatUrl = (linkStr, config) => { }; export const runFunc = (functionName, ...params) => { - var arrPaths = functionName.split('.'); + const arrPaths = functionName.split('.'); - var len = arrPaths.length - 1; - var fnName = arrPaths[len]; + const len = arrPaths.length - 1; + const fnName = arrPaths[len]; - var obj = window; - for (var i = 0; i < len; i++) { + let obj = window; + for (let i = 0; i < len; i++) { obj = obj[arrPaths[i]]; if (!obj) return; } @@ -268,10 +297,8 @@ const traverseEdge = points => { }); // Traverse half of total distance along points - const distanceToLabel = totalDistance / 2; - - let remainingDistance = distanceToLabel; - let center; + let remainingDistance = totalDistance / 2; + let center = undefined; prevPoint = undefined; points.forEach(point => { if (prevPoint && !center) { @@ -298,8 +325,7 @@ const traverseEdge = points => { }; const calcLabelPosition = points => { - const p = traverseEdge(points); - return p; + return traverseEdge(points); }; const calcCardinalityPosition = (isRelationTypePresent, points, initialPosition) => { @@ -317,7 +343,7 @@ const calcCardinalityPosition = (isRelationTypePresent, points, initialPosition) const distanceToCardinalityPoint = 25; let remainingDistance = distanceToCardinalityPoint; - let center; + let center = { x: 0, y: 0 }; prevPoint = undefined; points.forEach(point => { if (prevPoint && !center) { @@ -382,7 +408,279 @@ export const generateId = () => { ); }; +export const random = options => { + return cryptoRandomString(options); +}; + +/** + * @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`] + *

+ * Additionally, dissimilar types will not clobber unless the config.clobber parameter === true. Example: + * ``` + * 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 dst:any - the destination of the merge + * @param src:any - the source object(s) to merge into destination + * @param config:{ depth: number, clobber: boolean } - depth: depth to traverse within src and dst for merging - + * clobber: should dissimilar types clobber (default: { depth: 2, clobber: false }) + * @returns {*} + */ +export 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; + } + 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 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 + }; +}; + +export const drawSimpleText = function(elem, textData) { + // Remove and ignore br:s + const nText = textData.text.replace(common.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; +}; + +export const wrapLabel = memoize( + (label, maxWidth, config) => { + if (!label) { + return label; + } + config = Object.assign( + { fontSize: 12, fontWeight: 400, fontFamily: 'Arial', joinWith: '
' }, + config + ); + if (common.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}` +); + +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}` +); + +/** + * This calculates the text's height, taking into account the wrap breaks and + * both the statically configured height, width, and the length of the text (in pixels). + * + * If the wrapped text text has greater height, we extend the height, so it's + * value won't overflow. + * + * @return - The height for the given text + * @param text the text to measure + * @param config - the config for fontSize, fontFamily, and fontWeight all impacting the resulting size + */ +export const calculateTextHeight = function(text, config) { + config = Object.assign( + { fontSize: 12, fontWeight: 400, fontFamily: 'Arial', margin: 15 }, + config + ); + return calculateTextDimensions(text, config).height; +}; + +/** + * This calculates the width of the given text, font size and family. + * + * @return - The width for the given text + * @param text - The text to calculate the width of + * @param config - the config for fontSize, fontFamily, and fontWeight all impacting the resulting size + */ +export const calculateTextWidth = function(text, config) { + config = Object.assign({ fontSize: 12, fontWeight: 400, fontFamily: 'Arial' }, config); + return calculateTextDimensions(text, config).width; +}; + +/** + * This calculates the dimensions of the given text, font size, font family, font weight, and margins. + * + * @return - The width for the given text + * @param text - The text to calculate the width of + * @param config - the config for fontSize, fontFamily, fontWeight, and margin all impacting the resulting size + */ +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}` +); + export default { + assignWithDepth, + wrapLabel, + calculateTextHeight, + calculateTextWidth, + calculateTextDimensions, detectInit, detectDirective, detectType, @@ -393,5 +691,7 @@ export default { formatUrl, getStylesFromArray, generateId, + random, + memoize, runFunc }; diff --git a/src/utils.spec.js b/src/utils.spec.js index b50b4df6fa..85e9b1120a 100644 --- a/src/utils.spec.js +++ b/src/utils.spec.js @@ -1,6 +1,93 @@ /* eslint-env jasmine */ import utils from './utils'; +describe('when assignWithDepth: should merge objects within objects', function() { + it('should handle simple, depth:1 types (identity)', function() { + let config_0 = { foo: 'bar', bar: 0 }; + let config_1 = { foo: 'bar', bar: 0 }; + let result = utils.assignWithDepth(config_0, config_1); + expect(result).toEqual(config_1); + }); + it('should handle simple, depth:1 types (dst: undefined)', function() { + let config_0 = undefined; + let config_1 = { foo: 'bar', bar: 0 }; + let result = utils.assignWithDepth(config_0, config_1); + expect(result).toEqual(config_1); + }); + it('should handle simple, depth:1 types (src: undefined)', function() { + let config_0 = { foo: 'bar', bar: 0 }; + let config_1 = undefined; + let result = utils.assignWithDepth(config_0, config_1); + expect(result).toEqual(config_0); + }); + it('should handle simple, depth:1 types (merge)', function() { + let config_0 = { foo: 'bar', bar: 0 }; + let config_1 = { foo: 'foo' }; + let result = utils.assignWithDepth(config_0, config_1); + expect(result).toEqual({ foo: 'foo', bar: 0}); + }); + it('should handle depth:2 types (dst: orphan)', function() { + let config_0 = { foo: 'bar', bar: { foo: 'bar' } }; + let config_1 = { foo: 'bar' }; + let result = utils.assignWithDepth(config_0, config_1); + expect(result).toEqual(config_0); + }); + it('should handle depth:2 types (dst: object, src: simple type)', function() { + let config_0 = { foo: 'bar', bar: { foo: 'bar' } }; + let config_1 = { foo: 'foo', bar: 'should NOT clobber'}; + let result = utils.assignWithDepth(config_0, config_1); + expect(result).toEqual({ foo: 'foo', bar: { foo: 'bar' } } ); + }); + it('should handle depth:2 types (src: orphan)', function() { + let config_0 = { foo: 'bar' }; + let config_1 = { foo: 'bar', bar: { foo: 'bar' } }; + let result = utils.assignWithDepth(config_0, config_1); + expect(result).toEqual(config_1); + }); + it('should handle depth:2 types (merge)', function() { + let config_0 = { foo: 'bar', bar: { foo: 'bar' }, boofar: 1 }; + let config_1 = { foo: 'foo', bar: { bar: 0 }, foobar: 'foobar' }; + let result = utils.assignWithDepth(config_0, config_1); + expect(result).toEqual({ foo: "foo", bar: { foo: "bar", bar: 0 }, foobar: "foobar", boofar: 1 }); + }); + it('should handle depth:3 types (merge with clobber because assignWithDepth::depth == 2)', function() { + let config_0 = { foo: 'bar', bar: { foo: 'bar', bar: { foo: { message: 'this', willbe: 'clobbered' } } }, boofar: 1 }; + let config_1 = { foo: 'foo', bar: { foo: 'foo', bar: { foo: { message: 'clobbered other foo' } } }, foobar: 'foobar' }; + let result = utils.assignWithDepth(config_0, config_1); + expect(result).toEqual({ foo: "foo", bar: { foo: 'foo', bar: { foo: { message: 'clobbered other foo' } } }, foobar: "foobar", boofar: 1 }); + }); + it('should handle depth:3 types (merge with clobber because assignWithDepth::depth == 1)', function() { + let config_0 = { foo: 'bar', bar: { foo: 'bar', bar: { foo: { message: '', willNotbe: 'present' }, bar: 'shouldNotBePresent' } }, boofar: 1 }; + let config_1 = { foo: 'foo', bar: { foo: 'foo', bar: { foo: { message: 'this' } } }, foobar: 'foobar' }; + let result = utils.assignWithDepth(config_0, config_1, { depth: 1 }); + expect(result).toEqual({ foo: "foo", bar: { foo: 'foo', bar: { foo: { message: 'this' } } }, foobar: "foobar", boofar: 1 }); + }); + it('should handle depth:3 types (merge with no clobber because assignWithDepth::depth == 3)', function() { + let config_0 = { foo: 'bar', bar: { foo: 'bar', bar: { foo: { message: '', willbe: 'present' } } }, boofar: 1 }; + let config_1 = { foo: 'foo', bar: { foo: 'foo', bar: { foo: { message: 'this' } } }, foobar: 'foobar' }; + let result = utils.assignWithDepth(config_0, config_1, { depth: 3 }); + expect(result).toEqual({ foo: "foo", bar: { foo: 'foo', bar: { foo: { message: 'this', willbe: 'present' } } }, foobar: "foobar", boofar: 1 }); + }); +}); +describe('when memoizing', function() { + it('should return the same value', function() { + const fib = utils.memoize(function(n, canary) { + canary.flag = true; + if (n < 2){ + return 1; + }else{ + //We'll console.log a loader every time we have to recurse + return fib(n-2, canary) + fib(n-1, canary); + } + }); + let canary = {flag: false}; + fib(10, canary); + expect(canary.flag).toBe(true); + canary = {flag: false}; + fib(10, canary); + expect(canary.flag).toBe(false); + }); +}) describe('when detecting chart type ', function() { it('should handle a graph definition', function() { const str = 'graph TB\nbfs1:queue'; @@ -27,6 +114,16 @@ Alice->Bob: hi`; expect(type).toBe('sequence'); expect(init).toEqual({logLevel:0,theme:"dark"}); }); + it('should handle an init definition with config converted to the proper diagram configuration', function() { + const str = ` +%%{init: { 'logLevel': 0, 'theme': 'dark', 'config': {'wrapEnabled': true} } }%% +sequenceDiagram +Alice->Bob: hi`; + const type = utils.detectType(str); + const init = utils.detectInit(str); + expect(type).toBe('sequence'); + expect(init).toEqual({logLevel:0, theme:"dark", sequence: { wrapEnabled: true }}); + }); it('should handle a multiline init definition', function() { const str = ` %%{