@zombie-mermaid/svg-renderer 3.1.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/registry.ts CHANGED
@@ -54,6 +54,9 @@ import {
54
54
  parseErDiagram,
55
55
  parseSequenceDiagram,
56
56
  parseClassDiagram,
57
+ parseArchitecture,
58
+ architectureToGraph,
59
+ parseC4Diagram,
57
60
  } from '@zombie-mermaid/mermaid-parser'
58
61
  import type {
59
62
  XYChart,
@@ -64,6 +67,9 @@ import type {
64
67
  PositionedSequenceDiagram,
65
68
  ClassDiagram,
66
69
  PositionedClassDiagram,
70
+ ArchitectureDiagram,
71
+ C4Diagram,
72
+ PositionedC4Diagram,
67
73
  } from '@zombie-mermaid/mermaid-parser'
68
74
  import { layoutXYChart } from './xychart/layout.ts'
69
75
  import { renderXYChartSvg } from './xychart/renderer.ts'
@@ -73,6 +79,8 @@ import { layoutSequenceDiagram } from './sequence/layout.ts'
73
79
  import { renderSequenceSvg } from './sequence/renderer.ts'
74
80
  import { layoutClassDiagramSync } from './class/layout.ts'
75
81
  import { renderClassSvg } from './class/renderer.ts'
82
+ import { layoutC4DiagramSync } from './c4/layout.ts'
83
+ import { renderC4Svg } from './c4/renderer.ts'
76
84
  import { layoutGraphSync } from './layout-engine.ts'
77
85
  import { renderSvg as renderFlowchartSvg } from './renderer.ts'
78
86
  import { parseMermaid } from '../../../src/parser.ts'
@@ -323,6 +331,55 @@ const flowchartModule: DiagramModule<MermaidGraph, PositionedFlowchart> = {
323
331
  },
324
332
  }
325
333
 
334
+ const c4Module: DiagramModule<C4Diagram, PositionedC4Diagram> = {
335
+ type: 'c4',
336
+ parse: parseC4Diagram,
337
+ // Mermaid's C4 renderer has no direction: shapes are placed in rows in
338
+ // declaration order, so `options.direction` does not apply to the SVG.
339
+ layoutForSvg(diagram, options) {
340
+ return layoutC4DiagramSync(diagram, options)
341
+ },
342
+ renderSvg(positioned, ctx) {
343
+ return renderC4Svg(
344
+ positioned,
345
+ ctx.colors,
346
+ ctx.font,
347
+ ctx.transparent,
348
+ ctx.fontSizes,
349
+ ctx.embedSource,
350
+ ctx.title,
351
+ ctx.decorative,
352
+ ctx.emit,
353
+ )
354
+ },
355
+ }
356
+
357
+ /**
358
+ * Architecture diagrams are lowered to the flowchart model
359
+ * (`architectureToGraph` in `@zombie-mermaid/mermaid-parser`) and ride the
360
+ * flowchart layout + renderer, so this module is `flowchartModule` with a
361
+ * different `parse`. The lowered graph carries no `%%{init}%%` config, so
362
+ * `curve` is `options.curve` or 'linear'.
363
+ */
364
+ const architectureModule: DiagramModule<
365
+ ArchitectureDiagram,
366
+ PositionedFlowchart
367
+ > = {
368
+ type: 'architecture',
369
+ parse: parseArchitecture,
370
+ layoutForSvg(diagram, options) {
371
+ const graph = withDirectionOverride(
372
+ architectureToGraph(diagram),
373
+ options.direction,
374
+ )
375
+ return {
376
+ graph: layoutGraphSync(graph, options),
377
+ curve: options.curve ?? 'linear',
378
+ }
379
+ },
380
+ renderSvg: flowchartModule.renderSvg,
381
+ }
382
+
326
383
  /**
327
384
  * The registry proper — every `DiagramType` is looked up here by the SVG
328
385
  * front door (`renderMermaidSVG` in ./index.ts), which has no fallback
@@ -348,4 +405,7 @@ export const diagramRegistry: Record<DiagramType, AnyDiagramModule> = {
348
405
  sequence: sequenceModule,
349
406
  class: classModule,
350
407
  flowchart: flowchartModule,
408
+ architecture: architectureModule,
409
+
410
+ c4: c4Module,
351
411
  }
@@ -82,13 +82,10 @@ function layoutVertical(chart: XYChart): PositionedXYChart {
82
82
  throw new Error('XY chart: y-axis range was not set by the parser')
83
83
  }
84
84
  const yTicks = niceTickValues(yRange.min, yRange.max)
85
+ const yLabels = formatTickLabels(yTicks)
85
86
  const maxYLabelWidth = Math.max(
86
- ...yTicks.map((v) =>
87
- estimateTextWidth(
88
- formatTickValue(v),
89
- XY.axisLabelFontSize,
90
- XY.axisLabelFontWeight,
91
- ),
87
+ ...yLabels.map((label) =>
88
+ estimateTextWidth(label, XY.axisLabelFontSize, XY.axisLabelFontWeight),
92
89
  ),
93
90
  XY.yLabelWidth,
94
91
  )
@@ -127,8 +124,8 @@ function layoutVertical(chart: XYChart): PositionedXYChart {
127
124
  const xTicks = buildXTicks(chart, xScale, top + plotH, bandWidth)
128
125
 
129
126
  // Y-axis ticks
130
- const yAxisTicks: AxisTick[] = yTicks.map((v) => ({
131
- label: formatTickValue(v),
127
+ const yAxisTicks: AxisTick[] = yTicks.map((v, i) => ({
128
+ label: yLabels[i]!,
132
129
  x: left,
133
130
  y: yScale(v),
134
131
  tx: left - XY.tickLength,
@@ -251,6 +248,7 @@ function layoutHorizontal(chart: XYChart): PositionedXYChart {
251
248
  throw new Error('XY chart: y-axis range was not set by the parser')
252
249
  }
253
250
  const valueTicks = niceTickValues(yRange.min, yRange.max)
251
+ const valueLabels = formatTickLabels(valueTicks)
254
252
 
255
253
  // Compute category label widths for left margin
256
254
  const dataCount = getDataCount(chart)
@@ -293,8 +291,8 @@ function layoutHorizontal(chart: XYChart): PositionedXYChart {
293
291
  const catScale = (i: number) => top + (i + 0.5) * bandHeight
294
292
 
295
293
  // X-axis (bottom): value ticks
296
- const xTicks: AxisTick[] = valueTicks.map((v) => ({
297
- label: formatTickValue(v),
294
+ const xTicks: AxisTick[] = valueTicks.map((v, i) => ({
295
+ label: valueLabels[i]!,
298
296
  x: valueScale(v),
299
297
  y: top + plotH,
300
298
  tx: valueScale(v),
@@ -606,7 +604,7 @@ function layoutLines(
606
604
  }
607
605
 
608
606
  /** Generate "nice" tick values for a numeric range */
609
- function niceTickValues(min: number, max: number): number[] {
607
+ export function niceTickValues(min: number, max: number): number[] {
610
608
  const range = max - min
611
609
  if (range <= 0) return [min]
612
610
 
@@ -628,6 +626,24 @@ function niceTickValues(min: number, max: number): number[] {
628
626
  return ticks
629
627
  }
630
628
 
629
+ /**
630
+ * Format a set of evenly-spaced tick values with enough decimal places to
631
+ * keep every label distinct. Precision comes from the tick step, not the
632
+ * magnitude of the value: a 0.5 step around 100 needs one decimal, or
633
+ * 99.5 and 100.5 both collapse into neighbouring whole numbers.
634
+ */
635
+ export function formatTickLabels(ticks: number[]): string[] {
636
+ const step = ticks.length > 1 ? Math.abs(ticks[1]! - ticks[0]!) : 0
637
+ // The -1e-9 keeps an exact power-of-ten step (0.1) from rounding up
638
+ const decimals =
639
+ step > 0 && step < 1 ? Math.ceil(-Math.log10(step) - 1e-9) : 0
640
+ return ticks.map((v) =>
641
+ Number.isInteger(v) && decimals === 0
642
+ ? String(v)
643
+ : String(Number(v.toFixed(decimals))),
644
+ )
645
+ }
646
+
631
647
  function formatTickValue(v: number): string {
632
648
  if (Number.isInteger(v)) return String(v)
633
649
  // Limit decimal places
@@ -506,17 +506,21 @@ function roundedRightBarPath(
506
506
  }
507
507
 
508
508
  // ============================================================================
509
- // Smooth line interpolation — Natural cubic spline
509
+ // Smooth line interpolation — monotone cubic (Fritsch-Carlson / PCHIP)
510
510
  //
511
- // Computes the mathematically smoothest curve through all data points by
512
- // minimizing total curvature (integrated second derivative). Treats y as a
513
- // function of x, so the curve can never go backwards.
511
+ // A natural cubic spline is smooth but not shape-preserving: near a sharp
512
+ // spike it overshoots the data, drawing a peak above the maximum or a dip
513
+ // below the minimum (even below a zero baseline). Monotone cubic
514
+ // interpolation keeps each segment monotone between its two points and puts a
515
+ // flat tangent at every local extremum, so the curve never leaves the range of
516
+ // the data. y is treated as a function of x, so the curve never goes backwards.
514
517
  //
515
- // Algorithm: tridiagonal system for second derivatives (Thomas algorithm),
516
- // then convert each cubic segment to SVG cubic Bezier commands.
518
+ // Each cubic segment is converted to an SVG cubic Bezier command.
517
519
  // ============================================================================
518
520
 
519
- function smoothCurvePath(points: Array<{ x: number; y: number }>): string {
521
+ export function smoothCurvePath(
522
+ points: Array<{ x: number; y: number }>,
523
+ ): string {
520
524
  if (points.length === 0) return ''
521
525
  if (points.length === 1) return `M${r(points[0]!.x)},${r(points[0]!.y)}`
522
526
  if (points.length === 2) {
@@ -526,12 +530,10 @@ function smoothCurvePath(points: Array<{ x: number; y: number }>): string {
526
530
  const n = points.length
527
531
 
528
532
  // From here on, n >= 3 (the n <= 2 cases returned above). Every index used
529
- // below — including offsets like i-1/i+1 and n-2 — is bound by a `< n - 1`
530
- // or `< n` loop condition, or is a fixed offset from n itself, into arrays
531
- // sized exactly `n` or `n - 1`. Non-null assertions on those accesses are
532
- // the same bounds-checked idiom as loop-index array access elsewhere in
533
- // this codebase and are left as-is (justified, not asserting past a real
534
- // gap in type info).
533
+ // below is bound by a `< n - 1` or `< n` loop condition, or is a fixed
534
+ // offset from n itself, into arrays sized exactly `n` or `n - 1`.
535
+ // Non-null assertions on those accesses are the same bounds-checked idiom as
536
+ // loop-index array access elsewhere in this codebase.
535
537
 
536
538
  // 1. Interval widths and secant slopes
537
539
  const h: number[] = []
@@ -541,37 +543,26 @@ function smoothCurvePath(points: Array<{ x: number; y: number }>): string {
541
543
  delta.push(h[i]! === 0 ? 0 : (points[i + 1]!.y - points[i]!.y) / h[i]!)
542
544
  }
543
545
 
544
- // 2. Solve tridiagonal system for second derivatives c[] (natural boundary: c[0] = c[n-1] = 0)
545
- const c = new Array<number>(n).fill(0)
546
- if (n > 2) {
547
- // Forward elimination
548
- const cp = new Array<number>(n).fill(0) // modified upper diagonal
549
- const dp = new Array<number>(n).fill(0) // modified right-hand side
550
- for (let i = 1; i < n - 1; i++) {
551
- const diag = 2 * (h[i - 1]! + h[i]!)
552
- const rhs = 3 * (delta[i]! - delta[i - 1]!)
553
- if (i === 1) {
554
- cp[i] = h[i]! / diag
555
- dp[i] = rhs / diag
556
- } else {
557
- const w = diag - h[i - 1]! * cp[i - 1]!
558
- cp[i] = h[i]! / w
559
- dp[i] = (rhs - h[i - 1]! * dp[i - 1]!) / w
560
- }
561
- }
562
- // Back substitution
563
- for (let i = n - 2; i >= 1; i--) {
564
- c[i] = dp[i]! - cp[i]! * c[i + 1]!
565
- }
566
- }
567
-
568
- // 3. Compute first derivatives (slopes) at each knot
546
+ // 2. Tangent at each knot (Fritsch-Carlson). Interior: weighted harmonic
547
+ // mean of the adjacent secants, or 0 at a local extremum / flat run.
569
548
  const slopes = new Array<number>(n).fill(0)
570
- for (let i = 0; i < n - 1; i++) {
571
- slopes[i] = delta[i]! - (h[i]! * (2 * c[i]! + c[i + 1]!)) / 3
549
+ for (let i = 1; i < n - 1; i++) {
550
+ const d0 = delta[i - 1]!
551
+ const d1 = delta[i]!
552
+ if (d0 * d1 <= 0) continue
553
+ const w1 = 2 * h[i]! + h[i - 1]!
554
+ const w2 = h[i]! + 2 * h[i - 1]!
555
+ slopes[i] = (w1 + w2) / (w1 / d0 + w2 / d1)
556
+ }
557
+ // Ends: one-sided three-point estimate, clamped so it can't overshoot.
558
+ const endSlope = (h0: number, h1: number, d0: number, d1: number): number => {
559
+ let m = ((2 * h0 + h1) * d0 - h0 * d1) / (h0 + h1)
560
+ if (m * d0 <= 0) m = 0
561
+ else if (d0 * d1 <= 0 && Math.abs(m) > 3 * Math.abs(d0)) m = 3 * d0
562
+ return m
572
563
  }
573
- // Slope at last point: derivative of last segment at its end
574
- slopes[n - 1] = delta[n - 2]! + (h[n - 2]! * c[n - 2]!) / 3
564
+ slopes[0] = endSlope(h[0]!, h[1]!, delta[0]!, delta[1]!)
565
+ slopes[n - 1] = endSlope(h[n - 2]!, h[n - 3]!, delta[n - 2]!, delta[n - 3]!)
575
566
 
576
567
  // 4. Convert to cubic Bezier — control points strictly between endpoints in x
577
568
  let path = `M${r(points[0]!.x)},${r(points[0]!.y)}`