• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mermaid-js / mermaid / 4608236643

pending completion
4608236643

push

github

Knut Sveidqvist
Merge branch 'release/10.1.0'

1643 of 1996 branches covered (82.31%)

Branch coverage included in aggregate %.

801 of 801 new or added lines in 37 files covered. (100.0%)

16190 of 33430 relevant lines covered (48.43%)

403.58 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

63.43
/packages/mermaid/src/utils.ts
1
// @ts-nocheck : TODO Fix ts errors
1✔
2
import { sanitizeUrl } from '@braintree/sanitize-url';
1✔
3
import {
1✔
4
  curveBasis,
1✔
5
  curveBasisClosed,
1✔
6
  curveBasisOpen,
1✔
7
  curveBumpX,
1✔
8
  curveBumpY,
1✔
9
  curveBundle,
1✔
10
  curveCardinalClosed,
1✔
11
  curveCardinalOpen,
1✔
12
  curveCardinal,
1✔
13
  curveCatmullRomClosed,
1✔
14
  curveCatmullRomOpen,
1✔
15
  curveCatmullRom,
1✔
16
  CurveFactory,
1✔
17
  curveLinear,
1✔
18
  curveLinearClosed,
1✔
19
  curveMonotoneX,
1✔
20
  curveMonotoneY,
1✔
21
  curveNatural,
1✔
22
  curveStep,
1✔
23
  curveStepAfter,
1✔
24
  curveStepBefore,
1✔
25
  select,
1✔
26
} from 'd3';
1✔
27
import common from './diagrams/common/common';
1✔
28
import { configKeys } from './defaultConfig';
1✔
29
import { log } from './logger';
1✔
30
import { detectType } from './diagram-api/detectType';
1✔
31
import assignWithDepth from './assignWithDepth';
1✔
32
import { MermaidConfig } from './config.type';
1✔
33
import memoize from 'lodash-es/memoize.js';
1✔
34

1✔
35
// Effectively an enum of the supported curve types, accessible by name
1✔
36
const d3CurveTypes = {
1✔
37
  curveBasis: curveBasis,
1✔
38
  curveBasisClosed: curveBasisClosed,
1✔
39
  curveBasisOpen: curveBasisOpen,
1✔
40
  curveBumpX: curveBumpX,
1✔
41
  curveBumpY: curveBumpY,
1✔
42
  curveBundle: curveBundle,
1✔
43
  curveCardinalClosed: curveCardinalClosed,
1✔
44
  curveCardinalOpen: curveCardinalOpen,
1✔
45
  curveCardinal: curveCardinal,
1✔
46
  curveCatmullRomClosed: curveCatmullRomClosed,
1✔
47
  curveCatmullRomOpen: curveCatmullRomOpen,
1✔
48
  curveCatmullRom: curveCatmullRom,
1✔
49
  curveLinear: curveLinear,
1✔
50
  curveLinearClosed: curveLinearClosed,
1✔
51
  curveMonotoneX: curveMonotoneX,
1✔
52
  curveMonotoneY: curveMonotoneY,
1✔
53
  curveNatural: curveNatural,
1✔
54
  curveStep: curveStep,
1✔
55
  curveStepAfter: curveStepAfter,
1✔
56
  curveStepBefore: curveStepBefore,
1✔
57
};
1✔
58
const directive = /%{2}{\s*(?:(\w+)\s*:|(\w+))\s*(?:(\w+)|((?:(?!}%{2}).|\r?\n)*))?\s*(?:}%{2})?/gi;
1✔
59
const directiveWithoutOpen =
1✔
60
  /\s*(?:(\w+)(?=:):|(\w+))\s*(?:(\w+)|((?:(?!}%{2}).|\r?\n)*))?\s*(?:}%{2})?/gi;
1✔
61

1✔
62
/**
1✔
63
 * Detects the init config object from the text
1✔
64
 *
1✔
65
 * @param text - The text defining the graph. For example:
1✔
66
 *
1✔
67
 * ```mermaid
1✔
68
 * %%{init: {"theme": "debug", "logLevel": 1 }}%%
1✔
69
 * graph LR
1✔
70
 *      a-->b
1✔
71
 *      b-->c
1✔
72
 *      c-->d
1✔
73
 *      d-->e
1✔
74
 *      e-->f
1✔
75
 *      f-->g
1✔
76
 *      g-->h
1✔
77
 * ```
1✔
78
 *
1✔
79
 * Or
1✔
80
 *
1✔
81
 * ```mermaid
1✔
82
 * %%{initialize: {"theme": "dark", logLevel: "debug" }}%%
1✔
83
 * graph LR
1✔
84
 *    a-->b
1✔
85
 *    b-->c
1✔
86
 *    c-->d
1✔
87
 *    d-->e
1✔
88
 *    e-->f
1✔
89
 *    f-->g
1✔
90
 *    g-->h
1✔
91
 * ```
1✔
92
 *
1✔
93
 * @param config - Optional mermaid configuration object.
1✔
94
 * @returns The json object representing the init passed to mermaid.initialize()
1✔
95
 */
1✔
96
export const detectInit = function (text: string, config?: MermaidConfig): MermaidConfig {
1✔
97
  const inits = detectDirective(text, /(?:init\b)|(?:initialize\b)/);
36✔
98
  let results = {};
36✔
99

36✔
100
  if (Array.isArray(inits)) {
36✔
101
    const args = inits.map((init) => init.args);
1✔
102
    directiveSanitizer(args);
1✔
103

1✔
104
    results = assignWithDepth(results, [...args]);
1✔
105
  } else {
36✔
106
    results = inits.args;
35✔
107
  }
35✔
108
  if (results) {
36✔
109
    let type = detectType(text, config);
5✔
110
    ['config'].forEach((prop) => {
5✔
111
      if (results[prop] !== undefined) {
5✔
112
        if (type === 'flowchart-v2') {
1!
113
          type = 'flowchart';
×
114
        }
×
115
        results[type] = results[prop];
1✔
116
        delete results[prop];
1✔
117
      }
1✔
118
    });
5✔
119
  }
5✔
120

36✔
121
  // Todo: refactor this, these results are never used
36✔
122
  return results;
36✔
123
};
×
124

1✔
125
/**
1✔
126
 * Detects the directive from the text.
1✔
127
 *
1✔
128
 * Text can be single line or multiline. If type is null or omitted,
1✔
129
 * the first directive encountered in text will be returned
1✔
130
 *
1✔
131
 * ```mermaid
1✔
132
 * graph LR
1✔
133
 * %%{someDirective}%%
1✔
134
 *    a-->b
1✔
135
 *    b-->c
1✔
136
 *    c-->d
1✔
137
 *    d-->e
1✔
138
 *    e-->f
1✔
139
 *    f-->g
1✔
140
 *    g-->h
1✔
141
 * ```
1✔
142
 *
1✔
143
 * @param text - The text defining the graph
1✔
144
 * @param type - The directive to return (default: `null`)
1✔
145
 * @returns An object or Array representing the directive(s) matched by the input type.
1✔
146
 * If a single directive was found, that directive object will be returned.
1✔
147
 */
1✔
148
export const detectDirective = function (
1✔
149
  text: string,
36✔
150
  type: string | RegExp = null
36✔
151
): { type?: string; args?: any } | { type?: string; args?: any }[] {
36✔
152
  try {
36✔
153
    const commentWithoutDirectives = new RegExp(
36✔
154
      `[%]{2}(?![{]${directiveWithoutOpen.source})(?=[}][%]{2}).*\n`,
36✔
155
      'ig'
36✔
156
    );
36✔
157
    text = text.trim().replace(commentWithoutDirectives, '').replace(/'/gm, '"');
36✔
158
    log.debug(
36✔
159
      `Detecting diagram directive${type !== null ? ' type:' + type : ''} based on the text:${text}`
36!
160
    );
36✔
161
    let match;
36✔
162
    const result = [];
36✔
163
    while ((match = directive.exec(text)) !== null) {
36✔
164
      // This is necessary to avoid infinite loops with zero-width matches
6✔
165
      if (match.index === directive.lastIndex) {
6!
166
        directive.lastIndex++;
×
167
      }
×
168
      if (
6✔
169
        (match && !type) ||
6✔
170
        (type && match[1] && match[1].match(type)) ||
6!
171
        (type && match[2] && match[2].match(type))
×
172
      ) {
6✔
173
        const type = match[1] ? match[1] : match[2];
6!
174
        const args = match[3] ? match[3].trim() : match[4] ? JSON.parse(match[4].trim()) : null;
6!
175
        result.push({ type, args });
6✔
176
      }
6✔
177
    }
6✔
178
    if (result.length === 0) {
36✔
179
      result.push({ type: text, args: null });
31✔
180
    }
31✔
181

36✔
182
    return result.length === 1 ? result[0] : result;
36✔
183
  } catch (error) {
36!
184
    log.error(
×
185
      `ERROR: ${error.message} - Unable to parse directive
×
186
      ${type !== null ? ' type:' + type : ''} based on the text:${text}`
×
187
    );
×
188
    return { type: null, args: null };
×
189
  }
×
190
};
×
191

1✔
192
/**
1✔
193
 * Detects whether a substring in present in a given array
1✔
194
 *
1✔
195
 * @param str - The substring to detect
1✔
196
 * @param arr - The array to search
1✔
197
 * @returns The array index containing the substring or -1 if not present
1✔
198
 */
1✔
199
export const isSubstringInArray = function (str: string, arr: string[]): number {
1✔
200
  for (const [i, element] of arr.entries()) {
13✔
201
    if (element.match(str)) {
22✔
202
      return i;
4✔
203
    }
4✔
204
  }
22✔
205
  return -1;
9✔
206
};
×
207

1✔
208
/**
1✔
209
 * Returns a d3 curve given a curve name
1✔
210
 *
1✔
211
 * @param interpolate - The interpolation name
1✔
212
 * @param defaultCurve - The default curve to return
1✔
213
 * @returns The curve factory to use
1✔
214
 */
1✔
215
export function interpolateToCurve(
1✔
216
  interpolate: string | undefined,
20✔
217
  defaultCurve: CurveFactory
20✔
218
): CurveFactory {
20✔
219
  if (!interpolate) {
20✔
220
    return defaultCurve;
19✔
221
  }
19✔
222
  const curveName = `curve${interpolate.charAt(0).toUpperCase() + interpolate.slice(1)}`;
1✔
223
  return d3CurveTypes[curveName] || defaultCurve;
8!
224
}
20✔
225

1✔
226
/**
1✔
227
 * Formats a URL string
1✔
228
 *
1✔
229
 * @param linkStr - String of the URL
1✔
230
 * @param config - Configuration passed to MermaidJS
1✔
231
 * @returns The formatted URL or `undefined`.
1✔
232
 */
1✔
233
export function formatUrl(linkStr: string, config: MermaidConfig): string | undefined {
1✔
234
  const url = linkStr.trim();
30✔
235

30✔
236
  if (url) {
30✔
237
    if (config.securityLevel !== 'loose') {
30✔
238
      return sanitizeUrl(url);
25✔
239
    }
25✔
240

5✔
241
    return url;
5✔
242
  }
5✔
243
}
×
244

1✔
245
/**
1✔
246
 * Runs a function
1✔
247
 *
1✔
248
 * @param functionName - A dot separated path to the function relative to the `window`
1✔
249
 * @param params - Parameters to pass to the function
1✔
250
 */
1✔
251
export const runFunc = (functionName: string, ...params) => {
1✔
252
  const arrPaths = functionName.split('.');
×
253

×
254
  const len = arrPaths.length - 1;
×
255
  const fnName = arrPaths[len];
×
256

×
257
  let obj = window;
×
258
  for (let i = 0; i < len; i++) {
×
259
    obj = obj[arrPaths[i]];
×
260
    if (!obj) {
×
261
      return;
×
262
    }
×
263
  }
×
264

×
265
  obj[fnName](...params);
×
266
};
×
267

1✔
268
/** A (x, y) point */
1✔
269
interface Point {
1✔
270
  /** The x value */
1✔
271
  x: number;
1✔
272
  /** The y value */
1✔
273
  y: number;
1✔
274
}
1✔
275

1✔
276
/**
1✔
277
 * Finds the distance between two points using the Distance Formula
1✔
278
 *
1✔
279
 * @param p1 - The first point
1✔
280
 * @param p2 - The second point
1✔
281
 * @returns The distance between the two points.
1✔
282
 */
1✔
283
function distance(p1: Point, p2: Point): number {
×
284
  return p1 && p2 ? Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2)) : 0;
×
285
}
×
286

1✔
287
/**
1✔
288
 * TODO: Give this a description
1✔
289
 *
1✔
290
 * @param points - List of points
1✔
291
 */
1✔
292
function traverseEdge(points: Point[]): Point {
×
293
  let prevPoint;
×
294
  let totalDistance = 0;
×
295

×
296
  points.forEach((point) => {
×
297
    totalDistance += distance(point, prevPoint);
×
298
    prevPoint = point;
×
299
  });
×
300

×
301
  // Traverse half of total distance along points
×
302
  let remainingDistance = totalDistance / 2;
×
303
  let center = undefined;
×
304
  prevPoint = undefined;
×
305
  points.forEach((point) => {
×
306
    if (prevPoint && !center) {
×
307
      const vectorDistance = distance(point, prevPoint);
×
308
      if (vectorDistance < remainingDistance) {
×
309
        remainingDistance -= vectorDistance;
×
310
      } else {
×
311
        // The point is remainingDistance from prevPoint in the vector between prevPoint and point
×
312
        // Calculate the coordinates
×
313
        const distanceRatio = remainingDistance / vectorDistance;
×
314
        if (distanceRatio <= 0) {
×
315
          center = prevPoint;
×
316
        }
×
317
        if (distanceRatio >= 1) {
×
318
          center = { x: point.x, y: point.y };
×
319
        }
×
320
        if (distanceRatio > 0 && distanceRatio < 1) {
×
321
          center = {
×
322
            x: (1 - distanceRatio) * prevPoint.x + distanceRatio * point.x,
×
323
            y: (1 - distanceRatio) * prevPoint.y + distanceRatio * point.y,
×
324
          };
×
325
        }
×
326
      }
×
327
    }
×
328
    prevPoint = point;
×
329
  });
×
330
  return center;
×
331
}
×
332

1✔
333
/**
1✔
334
 * {@inheritdoc traverseEdge}
1✔
335
 */
1✔
336
function calcLabelPosition(points: Point[]): Point {
×
337
  if (points.length === 1) {
×
338
    return points[0];
×
339
  }
×
340
  return traverseEdge(points);
×
341
}
×
342

1✔
343
const calcCardinalityPosition = (isRelationTypePresent, points, initialPosition) => {
1✔
344
  let prevPoint;
×
345
  log.info(`our points ${JSON.stringify(points)}`);
×
346
  if (points[0] !== initialPosition) {
×
347
    points = points.reverse();
×
348
  }
×
349
  // Traverse only 25 total distance along points to find cardinality point
×
350
  const distanceToCardinalityPoint = 25;
×
351

×
352
  let remainingDistance = distanceToCardinalityPoint;
×
353
  let center;
×
354
  prevPoint = undefined;
×
355
  points.forEach((point) => {
×
356
    if (prevPoint && !center) {
×
357
      const vectorDistance = distance(point, prevPoint);
×
358
      if (vectorDistance < remainingDistance) {
×
359
        remainingDistance -= vectorDistance;
×
360
      } else {
×
361
        // The point is remainingDistance from prevPoint in the vector between prevPoint and point
×
362
        // Calculate the coordinates
×
363
        const distanceRatio = remainingDistance / vectorDistance;
×
364
        if (distanceRatio <= 0) {
×
365
          center = prevPoint;
×
366
        }
×
367
        if (distanceRatio >= 1) {
×
368
          center = { x: point.x, y: point.y };
×
369
        }
×
370
        if (distanceRatio > 0 && distanceRatio < 1) {
×
371
          center = {
×
372
            x: (1 - distanceRatio) * prevPoint.x + distanceRatio * point.x,
×
373
            y: (1 - distanceRatio) * prevPoint.y + distanceRatio * point.y,
×
374
          };
×
375
        }
×
376
      }
×
377
    }
×
378
    prevPoint = point;
×
379
  });
×
380
  // if relation is present (Arrows will be added), change cardinality point off-set distance (d)
×
381
  const d = isRelationTypePresent ? 10 : 5;
×
382
  //Calculate Angle for x and y axis
×
383
  const angle = Math.atan2(points[0].y - center.y, points[0].x - center.x);
×
384
  const cardinalityPosition = { x: 0, y: 0 };
×
385
  //Calculation cardinality position using angle, center point on the line/curve but pendicular and with offset-distance
×
386
  cardinalityPosition.x = Math.sin(angle) * d + (points[0].x + center.x) / 2;
×
387
  cardinalityPosition.y = -Math.cos(angle) * d + (points[0].y + center.y) / 2;
×
388
  return cardinalityPosition;
×
389
};
×
390

1✔
391
/**
1✔
392
 * Calculates the terminal label position.
1✔
393
 *
1✔
394
 * @param terminalMarkerSize - Terminal marker size.
1✔
395
 * @param position - Position of label relative to points.
1✔
396
 * @param _points - Array of points.
1✔
397
 * @returns - The `cardinalityPosition`.
1✔
398
 */
1✔
399
function calcTerminalLabelPosition(
×
400
  terminalMarkerSize: number,
×
401
  position: 'start_left' | 'start_right' | 'end_left' | 'end_right',
×
402
  _points: Point[]
×
403
): Point {
×
404
  // Todo looking to faster cloning method
×
405
  let points = JSON.parse(JSON.stringify(_points));
×
406
  let prevPoint;
×
407
  log.info('our points', points);
×
408
  if (position !== 'start_left' && position !== 'start_right') {
×
409
    points = points.reverse();
×
410
  }
×
411

×
412
  points.forEach((point) => {
×
413
    prevPoint = point;
×
414
  });
×
415

×
416
  // Traverse only 25 total distance along points to find cardinality point
×
417
  const distanceToCardinalityPoint = 25 + terminalMarkerSize;
×
418

×
419
  let remainingDistance = distanceToCardinalityPoint;
×
420
  let center;
×
421
  prevPoint = undefined;
×
422
  points.forEach((point) => {
×
423
    if (prevPoint && !center) {
×
424
      const vectorDistance = distance(point, prevPoint);
×
425
      if (vectorDistance < remainingDistance) {
×
426
        remainingDistance -= vectorDistance;
×
427
      } else {
×
428
        // The point is remainingDistance from prevPoint in the vector between prevPoint and point
×
429
        // Calculate the coordinates
×
430
        const distanceRatio = remainingDistance / vectorDistance;
×
431
        if (distanceRatio <= 0) {
×
432
          center = prevPoint;
×
433
        }
×
434
        if (distanceRatio >= 1) {
×
435
          center = { x: point.x, y: point.y };
×
436
        }
×
437
        if (distanceRatio > 0 && distanceRatio < 1) {
×
438
          center = {
×
439
            x: (1 - distanceRatio) * prevPoint.x + distanceRatio * point.x,
×
440
            y: (1 - distanceRatio) * prevPoint.y + distanceRatio * point.y,
×
441
          };
×
442
        }
×
443
      }
×
444
    }
×
445
    prevPoint = point;
×
446
  });
×
447
  // if relation is present (Arrows will be added), change cardinality point off-set distance (d)
×
448
  const d = 10 + terminalMarkerSize * 0.5;
×
449
  //Calculate Angle for x and y axis
×
450
  const angle = Math.atan2(points[0].y - center.y, points[0].x - center.x);
×
451

×
452
  const cardinalityPosition = { x: 0, y: 0 };
×
453

×
454
  //Calculation cardinality position using angle, center point on the line/curve but pendicular and with offset-distance
×
455

×
456
  cardinalityPosition.x = Math.sin(angle) * d + (points[0].x + center.x) / 2;
×
457
  cardinalityPosition.y = -Math.cos(angle) * d + (points[0].y + center.y) / 2;
×
458
  if (position === 'start_left') {
×
459
    cardinalityPosition.x = Math.sin(angle + Math.PI) * d + (points[0].x + center.x) / 2;
×
460
    cardinalityPosition.y = -Math.cos(angle + Math.PI) * d + (points[0].y + center.y) / 2;
×
461
  }
×
462
  if (position === 'end_right') {
×
463
    cardinalityPosition.x = Math.sin(angle - Math.PI) * d + (points[0].x + center.x) / 2 - 5;
×
464
    cardinalityPosition.y = -Math.cos(angle - Math.PI) * d + (points[0].y + center.y) / 2 - 5;
×
465
  }
×
466
  if (position === 'end_left') {
×
467
    cardinalityPosition.x = Math.sin(angle) * d + (points[0].x + center.x) / 2 - 5;
×
468
    cardinalityPosition.y = -Math.cos(angle) * d + (points[0].y + center.y) / 2 - 5;
×
469
  }
×
470
  return cardinalityPosition;
×
471
}
×
472

1✔
473
/**
1✔
474
 * Gets styles from an array of declarations
1✔
475
 *
1✔
476
 * @param arr - Declarations
1✔
477
 * @returns The styles grouped as strings
1✔
478
 */
1✔
479
export function getStylesFromArray(arr: string[]): { style: string; labelStyle: string } {
1✔
480
  let style = '';
38✔
481
  let labelStyle = '';
38✔
482

38✔
483
  for (const element of arr) {
38✔
484
    if (element !== undefined) {
34✔
485
      // add text properties to label style definition
34✔
486
      if (element.startsWith('color:') || element.startsWith('text-align:')) {
34✔
487
        labelStyle = labelStyle + element + ';';
7✔
488
      } else {
34✔
489
        style = style + element + ';';
27✔
490
      }
27✔
491
    }
34✔
492
  }
34✔
493

38✔
494
  return { style: style, labelStyle: labelStyle };
38✔
495
}
38✔
496

1✔
497
let cnt = 0;
1✔
498
export const generateId = () => {
1✔
499
  cnt++;
×
500
  return 'id-' + Math.random().toString(36).substr(2, 12) + '-' + cnt;
×
501
};
×
502

1✔
503
/**
1✔
504
 * Generates a random hexadecimal id of the given length.
1✔
505
 *
1✔
506
 * @param length - Length of ID.
1✔
507
 * @returns The generated ID.
1✔
508
 */
1✔
509
function makeid(length: number): string {
101✔
510
  let result = '';
101✔
511
  const characters = '0123456789abcdef';
101✔
512
  const charactersLength = characters.length;
101✔
513
  for (let i = 0; i < length; i++) {
101✔
514
    result += characters.charAt(Math.floor(Math.random() * charactersLength));
863✔
515
  }
863✔
516
  return result;
101✔
517
}
101✔
518

1✔
519
export const random = (options) => {
1✔
520
  return makeid(options.length);
101✔
521
};
49✔
522

1✔
523
export const getTextObj = function () {
1✔
524
  return {
×
525
    x: 0,
×
526
    y: 0,
×
527
    fill: undefined,
×
528
    anchor: 'start',
×
529
    style: '#666',
×
530
    width: 100,
×
531
    height: 100,
×
532
    textMargin: 0,
×
533
    rx: 0,
×
534
    ry: 0,
×
535
    valign: undefined,
×
536
  };
×
537
};
×
538

1✔
539
/**
1✔
540
 * Adds text to an element
1✔
541
 *
1✔
542
 * @param elem - SVG Element to add text to
1✔
543
 * @param textData - Text options.
1✔
544
 * @returns Text element with given styling and content
1✔
545
 */
1✔
546
export const drawSimpleText = function (
1✔
547
  elem: SVGElement,
×
548
  textData: {
×
549
    text: string;
×
550
    x: number;
×
551
    y: number;
×
552
    anchor: 'start' | 'middle' | 'end';
×
553
    fontFamily: string;
×
554
    fontSize: string | number;
×
555
    fontWeight: string | number;
×
556
    fill: string;
×
557
    class: string | undefined;
×
558
    textMargin: number;
×
559
  }
×
560
): SVGTextElement {
×
561
  // Remove and ignore br:s
×
562
  const nText = textData.text.replace(common.lineBreakRegex, ' ');
×
563

×
564
  const [, _fontSizePx] = parseFontSize(textData.fontSize);
×
565

×
566
  const textElem = elem.append('text');
×
567
  textElem.attr('x', textData.x);
×
568
  textElem.attr('y', textData.y);
×
569
  textElem.style('text-anchor', textData.anchor);
×
570
  textElem.style('font-family', textData.fontFamily);
×
571
  textElem.style('font-size', _fontSizePx);
×
572
  textElem.style('font-weight', textData.fontWeight);
×
573
  textElem.attr('fill', textData.fill);
×
574
  if (textData.class !== undefined) {
×
575
    textElem.attr('class', textData.class);
×
576
  }
×
577

×
578
  const span = textElem.append('tspan');
×
579
  span.attr('x', textData.x + textData.textMargin * 2);
×
580
  span.attr('fill', textData.fill);
×
581
  span.text(nText);
×
582

×
583
  return textElem;
×
584
};
×
585

1✔
586
interface WrapLabelConfig {
1✔
587
  fontSize: number;
1✔
588
  fontFamily: string;
1✔
589
  fontWeight: number;
1✔
590
  joinWith: string;
1✔
591
}
1✔
592

1✔
593
export const wrapLabel: (label: string, maxWidth: string, config: WrapLabelConfig) => string =
1✔
594
  memoize(
1✔
595
    (label: string, maxWidth: string, config: WrapLabelConfig): string => {
1✔
596
      if (!label) {
21!
597
        return label;
×
598
      }
×
599
      config = Object.assign(
21✔
600
        { fontSize: 12, fontWeight: 400, fontFamily: 'Arial', joinWith: '<br/>' },
21✔
601
        config
21✔
602
      );
21✔
603
      if (common.lineBreakRegex.test(label)) {
21!
604
        return label;
×
605
      }
×
606
      const words = label.split(' ');
21✔
607
      const completedLines = [];
21✔
608
      let nextLine = '';
21✔
609
      words.forEach((word, index) => {
21✔
610
        const wordLength = calculateTextWidth(`${word} `, config);
110✔
611
        const nextLineLength = calculateTextWidth(nextLine, config);
110✔
612
        if (wordLength > maxWidth) {
110!
613
          const { hyphenatedStrings, remainingWord } = breakString(word, maxWidth, '-', config);
×
614
          completedLines.push(nextLine, ...hyphenatedStrings);
×
615
          nextLine = remainingWord;
×
616
        } else if (nextLineLength + wordLength >= maxWidth) {
110!
617
          completedLines.push(nextLine);
×
618
          nextLine = word;
×
619
        } else {
110✔
620
          nextLine = [nextLine, word].filter(Boolean).join(' ');
110✔
621
        }
110✔
622
        const currentWord = index + 1;
110✔
623
        const isLastWord = currentWord === words.length;
110✔
624
        if (isLastWord) {
110✔
625
          completedLines.push(nextLine);
21✔
626
        }
21✔
627
      });
21✔
628
      return completedLines.filter((line) => line !== '').join(config.joinWith);
21✔
629
    },
21✔
630
    (label, maxWidth, config) =>
1✔
631
      `${label}${maxWidth}${config.fontSize}${config.fontWeight}${config.fontFamily}${config.joinWith}`
76✔
632
  );
1✔
633

1✔
634
interface BreakStringOutput {
1✔
635
  hyphenatedStrings: string[];
1✔
636
  remainingWord: string;
1✔
637
}
1✔
638

1✔
639
const breakString: (
1✔
640
  word: string,
1✔
641
  maxWidth: number,
1✔
642
  hyphenCharacter: string,
1✔
643
  config: WrapLabelConfig
1✔
644
) => BreakStringOutput = memoize(
1✔
645
  (
1✔
646
    word: string,
×
647
    maxWidth: number,
×
648
    hyphenCharacter = '-',
×
649
    config: WrapLabelConfig
×
650
  ): BreakStringOutput => {
×
651
    config = Object.assign(
×
652
      { fontSize: 12, fontWeight: 400, fontFamily: 'Arial', margin: 0 },
×
653
      config
×
654
    );
×
655
    const characters = [...word];
×
656
    const lines: string[] = [];
×
657
    let currentLine = '';
×
658
    characters.forEach((character, index) => {
×
659
      const nextLine = `${currentLine}${character}`;
×
660
      const lineWidth = calculateTextWidth(nextLine, config);
×
661
      if (lineWidth >= maxWidth) {
×
662
        const currentCharacter = index + 1;
×
663
        const isLastLine = characters.length === currentCharacter;
×
664
        const hyphenatedNextLine = `${nextLine}${hyphenCharacter}`;
×
665
        lines.push(isLastLine ? nextLine : hyphenatedNextLine);
×
666
        currentLine = '';
×
667
      } else {
×
668
        currentLine = nextLine;
×
669
      }
×
670
    });
×
671
    return { hyphenatedStrings: lines, remainingWord: currentLine };
×
672
  },
×
673
  (word, maxWidth, hyphenCharacter = '-', config) =>
1✔
674
    `${word}${maxWidth}${hyphenCharacter}${config.fontSize}${config.fontWeight}${config.fontFamily}`
×
675
);
1✔
676

1✔
677
/**
1✔
678
 * This calculates the text's height, taking into account the wrap breaks and both the statically
1✔
679
 * configured height, width, and the length of the text (in pixels).
1✔
680
 *
1✔
681
 * If the wrapped text text has greater height, we extend the height, so it's value won't overflow.
1✔
682
 *
1✔
683
 * @param text - The text to measure
1✔
684
 * @param config - The config for fontSize, fontFamily, and fontWeight all impacting the
1✔
685
 *   resulting size
1✔
686
 * @returns The height for the given text
1✔
687
 */
1✔
688
export function calculateTextHeight(
1✔
689
  text: Parameters<typeof calculateTextDimensions>[0],
×
690
  config: Parameters<typeof calculateTextDimensions>[1]
×
691
): ReturnType<typeof calculateTextDimensions>['height'] {
×
692
  config = Object.assign(
×
693
    { fontSize: 12, fontWeight: 400, fontFamily: 'Arial', margin: 15 },
×
694
    config
×
695
  );
×
696
  return calculateTextDimensions(text, config).height;
×
697
}
×
698

1✔
699
/**
1✔
700
 * This calculates the width of the given text, font size and family.
1✔
701
 *
1✔
702
 * @param text - The text to calculate the width of
1✔
703
 * @param config - The config for fontSize, fontFamily, and fontWeight all impacting the
1✔
704
 *   resulting size
1✔
705
 * @returns The width for the given text
1✔
706
 */
1✔
707
export function calculateTextWidth(
1✔
708
  text: Parameters<typeof calculateTextDimensions>[0],
220✔
709
  config: Parameters<typeof calculateTextDimensions>[1]
220✔
710
): ReturnType<typeof calculateTextDimensions>['width'] {
220✔
711
  config = Object.assign({ fontSize: 12, fontWeight: 400, fontFamily: 'Arial' }, config);
220✔
712
  return calculateTextDimensions(text, config).width;
220✔
713
}
×
714

1✔
715
interface TextDimensionConfig {
1✔
716
  fontSize?: number;
1✔
717
  fontWeight?: number;
1✔
718
  fontFamily?: string;
1✔
719
}
1✔
720
interface TextDimensions {
1✔
721
  width: number;
1✔
722
  height: number;
1✔
723
  lineHeight?: number;
1✔
724
}
1✔
725
/**
1✔
726
 * This calculates the dimensions of the given text, font size, font family, font weight, and
1✔
727
 * margins.
1✔
728
 *
1✔
729
 * @param text - The text to calculate the width of
1✔
730
 * @param config - The config for fontSize, fontFamily, fontWeight, and margin all impacting
1✔
731
 *   the resulting size
1✔
732
 * @returns The dimensions for the given text
1✔
733
 */
1✔
734
export const calculateTextDimensions: (
1✔
735
  text: string,
1✔
736
  config: TextDimensionConfig
1✔
737
) => TextDimensions = memoize(
1✔
738
  (text: string, config: TextDimensionConfig): TextDimensions => {
1✔
739
    config = Object.assign({ fontSize: 12, fontWeight: 400, fontFamily: 'Arial' }, config);
86✔
740
    const { fontSize, fontFamily, fontWeight } = config;
86✔
741
    if (!text) {
86✔
742
      return { width: 0, height: 0 };
1✔
743
    }
1✔
744

85✔
745
    const [, _fontSizePx] = parseFontSize(fontSize);
85✔
746

85✔
747
    // We can't really know if the user supplied font family will render on the user agent;
85✔
748
    // thus, we'll take the max width between the user supplied font family, and a default
85✔
749
    // of sans-serif.
85✔
750
    const fontFamilies = ['sans-serif', fontFamily];
85✔
751
    const lines = text.split(common.lineBreakRegex);
85✔
752
    const dims = [];
85✔
753

85✔
754
    const body = select('body');
85✔
755
    // We don't want to leak DOM elements - if a removal operation isn't available
85✔
756
    // for any reason, do not continue.
85✔
757
    if (!body.remove) {
85✔
758
      return { width: 0, height: 0, lineHeight: 0 };
85✔
759
    }
85!
760

×
761
    const g = body.append('svg');
×
762

×
763
    for (const fontFamily of fontFamilies) {
×
764
      let cheight = 0;
×
765
      const dim = { width: 0, height: 0, lineHeight: 0 };
×
766
      for (const line of lines) {
×
767
        const textObj = getTextObj();
×
768
        textObj.text = line;
×
769
        const textElem = drawSimpleText(g, textObj)
×
770
          .style('font-size', _fontSizePx)
×
771
          .style('font-weight', fontWeight)
×
772
          .style('font-family', fontFamily);
×
773

×
774
        const bBox = (textElem._groups || textElem)[0][0].getBBox();
×
775
        if (bBox.width === 0 && bBox.height === 0) {
×
776
          throw new Error('svg element not in render tree');
×
777
        }
×
778
        dim.width = Math.round(Math.max(dim.width, bBox.width));
×
779
        cheight = Math.round(bBox.height);
×
780
        dim.height += cheight;
×
781
        dim.lineHeight = Math.round(Math.max(dim.lineHeight, cheight));
×
782
      }
×
783
      dims.push(dim);
×
784
    }
×
785

×
786
    g.remove();
×
787

×
788
    const index =
×
789
      isNaN(dims[1].height) ||
×
790
      isNaN(dims[1].width) ||
×
791
      isNaN(dims[1].lineHeight) ||
×
792
      (dims[0].height > dims[1].height &&
×
793
        dims[0].width > dims[1].width &&
×
794
        dims[0].lineHeight > dims[1].lineHeight)
×
795
        ? 0
×
796
        : 1;
×
797
    return dims[index];
86✔
798
  },
86✔
799
  (text, config) => `${text}${config.fontSize}${config.fontWeight}${config.fontFamily}`
1✔
800
);
×
801

1✔
802
export const initIdGenerator = class iterator {
1✔
803
  constructor(deterministic, seed) {
1✔
804
    this.deterministic = deterministic;
8✔
805
    // TODO: Seed is only used for length?
8✔
806
    this.seed = seed;
8✔
807

8✔
808
    this.count = seed ? seed.length : 0;
8✔
809
  }
8✔
810

1✔
811
  next() {
1✔
812
    if (!this.deterministic) {
11✔
813
      return Date.now();
7✔
814
    }
7✔
815

4✔
816
    return this.count++;
4✔
817
  }
11✔
818
};
×
819

1✔
820
let decoder;
1✔
821

1✔
822
/**
1✔
823
 * Decodes HTML, source: {@link https://github.com/shrpne/entity-decode/blob/v2.0.1/browser.js}
1✔
824
 *
1✔
825
 * @param html - HTML as a string
1✔
826
 * @returns Unescaped HTML
1✔
827
 */
1✔
828
export const entityDecode = function (html: string): string {
1✔
829
  decoder = decoder || document.createElement('div');
5✔
830
  // Escape HTML before decoding for HTML Entities
5✔
831
  html = escape(html).replace(/%26/g, '&').replace(/%23/g, '#').replace(/%3B/g, ';');
5✔
832
  // decoding
5✔
833
  decoder.innerHTML = html;
5✔
834
  return unescape(decoder.textContent);
5✔
835
};
×
836

1✔
837
/**
1✔
838
 * Sanitizes directive objects
1✔
839
 *
1✔
840
 * @param args - Directive's JSON
1✔
841
 */
1✔
842
export const directiveSanitizer = (args: any) => {
1✔
843
  log.debug('directiveSanitizer called with', args);
30✔
844
  if (typeof args === 'object') {
30✔
845
    // check for array
30✔
846
    if (args.length) {
30✔
847
      args.forEach((arg) => directiveSanitizer(arg));
1✔
848
    } else {
30✔
849
      // This is an object
29✔
850
      Object.keys(args).forEach((key) => {
29✔
851
        log.debug('Checking key', key);
47✔
852
        if (key.startsWith('__')) {
47!
853
          log.debug('sanitize deleting __ option', key);
×
854
          delete args[key];
×
855
        }
×
856

47✔
857
        if (key.includes('proto')) {
47!
858
          log.debug('sanitize deleting proto option', key);
×
859
          delete args[key];
×
860
        }
×
861

47✔
862
        if (key.includes('constr')) {
47!
863
          log.debug('sanitize deleting constr option', key);
×
864
          delete args[key];
×
865
        }
×
866

47✔
867
        if (key.includes('themeCSS')) {
47!
868
          log.debug('sanitizing themeCss option');
×
869
          args[key] = sanitizeCss(args[key]);
×
870
        }
×
871
        if (key.includes('fontFamily')) {
47✔
872
          log.debug('sanitizing fontFamily option');
3✔
873
          args[key] = sanitizeCss(args[key]);
3✔
874
        }
3✔
875
        if (key.includes('altFontFamily')) {
47!
876
          log.debug('sanitizing altFontFamily option');
×
877
          args[key] = sanitizeCss(args[key]);
×
878
        }
×
879
        if (!configKeys.includes(key)) {
47!
880
          log.debug('sanitize deleting option', key);
×
881
          delete args[key];
×
882
        } else {
47✔
883
          if (typeof args[key] === 'object') {
47✔
884
            log.debug('sanitize deleting object', key);
3✔
885
            directiveSanitizer(args[key]);
3✔
886
          }
3✔
887
        }
47✔
888
      });
29✔
889
    }
29✔
890
  }
30✔
891
  if (args.themeVariables) {
30!
892
    const kArr = Object.keys(args.themeVariables);
×
893
    for (const k of kArr) {
×
894
      const val = args.themeVariables[k];
×
895
      if (val && val.match && !val.match(/^[\d "#%(),.;A-Za-z]+$/)) {
×
896
        args.themeVariables[k] = '';
×
897
      }
×
898
    }
×
899
  }
×
900
  log.debug('After sanitization', args);
30✔
901
};
24✔
902
export const sanitizeCss = (str) => {
1✔
903
  let startCnt = 0;
3✔
904
  let endCnt = 0;
3✔
905

3✔
906
  for (const element of str) {
3✔
907
    if (startCnt < endCnt) {
15!
908
      return '{ /* ERROR: Unbalanced CSS */ }';
×
909
    }
×
910
    if (element === '{') {
15!
911
      startCnt++;
×
912
    } else if (element === '}') {
15!
913
      endCnt++;
×
914
    }
×
915
  }
15✔
916
  if (startCnt !== endCnt) {
3!
917
    return '{ /* ERROR: Unbalanced CSS */ }';
×
918
  }
×
919
  // Todo add more checks here
3✔
920
  return str;
3✔
921
};
×
922

1✔
923
export interface DetailedError {
1✔
924
  str: string;
1✔
925
  hash: any;
1✔
926
  error?: any;
1✔
927
  message?: string;
1✔
928
}
1✔
929

1✔
930
/** @param error - The error to check */
1✔
931
export function isDetailedError(error: unknown): error is DetailedError {
1✔
932
  return 'str' in error;
×
933
}
×
934

1✔
935
/** @param error - The error to convert to an error message */
1✔
936
export function getErrorMessage(error: unknown): string {
1✔
937
  if (error instanceof Error) {
×
938
    return error.message;
×
939
  }
×
940
  return String(error);
×
941
}
×
942

1✔
943
/**
1✔
944
 * Appends <text> element with the given title and css class.
1✔
945
 *
1✔
946
 * @param parent - d3 svg object to append title to
1✔
947
 * @param cssClass - CSS class for the <text> element containing the title
1✔
948
 * @param titleTopMargin - Margin in pixels between title and rest of the graph
1✔
949
 * @param title - The title. If empty, returns immediately.
1✔
950
 */
1✔
951
export const insertTitle = (
1✔
952
  parent,
5✔
953
  cssClass: string,
5✔
954
  titleTopMargin: number,
5✔
955
  title?: string
5✔
956
): void => {
5✔
957
  if (!title) {
5✔
958
    return;
1✔
959
  }
1✔
960
  const bounds = parent.node().getBBox();
4✔
961
  parent
4✔
962
    .append('text')
4✔
963
    .text(title)
4✔
964
    .attr('x', bounds.x + bounds.width / 2)
4✔
965
    .attr('y', -titleTopMargin)
4✔
966
    .attr('class', cssClass);
4✔
967
};
×
968

1✔
969
/**
1✔
970
 * Parses a raw fontSize configuration value into a number and string value.
1✔
971
 *
1✔
972
 * @param fontSize - a string or number font size configuration value
1✔
973
 *
1✔
974
 * @returns parsed number and string style font size values, or nulls if a number value can't
1✔
975
 * be parsed from an input string.
1✔
976
 */
1✔
977
export const parseFontSize = (fontSize: string | number | undefined): [number?, string?] => {
1✔
978
  // if the font size is a number, assume a px string representation
204✔
979
  if (typeof fontSize === 'number') {
204✔
980
    return [fontSize, fontSize + 'px'];
197✔
981
  }
197✔
982

7✔
983
  const fontSizeNumber = parseInt(fontSize, 10);
7✔
984
  if (Number.isNaN(fontSizeNumber)) {
7✔
985
    // if a number value can't be parsed, return null for both values
3✔
986
    return [undefined, undefined];
3✔
987
  } else if (fontSize === String(fontSizeNumber)) {
7✔
988
    // if a string input doesn't contain any units, assume px units
1✔
989
    return [fontSizeNumber, fontSize + 'px'];
1✔
990
  } else {
4✔
991
    return [fontSizeNumber, fontSize];
3✔
992
  }
3✔
993
};
113✔
994

1✔
995
export default {
1✔
996
  assignWithDepth,
1✔
997
  wrapLabel,
1✔
998
  calculateTextHeight,
1✔
999
  calculateTextWidth,
1✔
1000
  calculateTextDimensions,
1✔
1001
  detectInit,
1✔
1002
  detectDirective,
1✔
1003
  isSubstringInArray,
1✔
1004
  interpolateToCurve,
1✔
1005
  calcLabelPosition,
1✔
1006
  calcCardinalityPosition,
1✔
1007
  calcTerminalLabelPosition,
1✔
1008
  formatUrl,
1✔
1009
  getStylesFromArray,
1✔
1010
  generateId,
1✔
1011
  random,
1✔
1012
  runFunc,
1✔
1013
  entityDecode,
1✔
1014
  initIdGenerator: initIdGenerator,
1✔
1015
  directiveSanitizer,
1✔
1016
  sanitizeCss,
1✔
1017
  insertTitle,
1✔
1018
  parseFontSize,
1✔
1019
};
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc