@ktrysmt/beautiful-mermaid 1.4.4 → 1.5.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/README.md CHANGED
@@ -6,8 +6,6 @@
6
6
 
7
7
  Ultra-fast, fully themeable, zero DOM dependencies. Built for the AI era.
8
8
 
9
- ![beautiful-mermaid sequence diagram example](hero.png)
10
-
11
9
  [![npm version](https://img.shields.io/npm/v/@ktrysmt/beautiful-mermaid.svg)](https://www.npmjs.com/package/@ktrysmt/beautiful-mermaid)
12
10
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
13
11
 
package/dist/index.js CHANGED
@@ -7,8 +7,8 @@ var MIX = {
7
7
  /** Primary text: near-full fg */
8
8
  text: 100,
9
9
  // just use --fg directly
10
- /** Secondary text (group headers): fg mixed at 60% */
11
- textSec: 60,
10
+ /** Secondary text (group headers, edge labels): fg mixed at 75% */
11
+ textSec: 75,
12
12
  /** Muted text (edge labels, notes): fg mixed at 40% */
13
13
  textMuted: 40,
14
14
  /** Faint text (de-emphasized): fg mixed at 25% */
@@ -153,7 +153,7 @@ function buildStyleBlock(font, hasMonoFont) {
153
153
  const derivedVars = `
154
154
  /* Derived from --bg and --fg (overridable via --line, --accent, etc.) */
155
155
  --_text: var(--fg);
156
- --_text-sec: var(--muted, color-mix(in srgb, var(--fg) ${MIX.textSec}%, var(--bg)));
156
+ --_text-sec: color-mix(in srgb, var(--fg) ${MIX.textSec}%, var(--bg));
157
157
  --_text-muted: var(--muted, color-mix(in srgb, var(--fg) ${MIX.textMuted}%, var(--bg)));
158
158
  --_text-faint: color-mix(in srgb, var(--fg) ${MIX.textFaint}%, var(--bg));
159
159
  --_line: var(--line, color-mix(in srgb, var(--fg) ${MIX.line}%, var(--bg)));
@@ -6594,6 +6594,7 @@ function mermaidToElk(graph, opts) {
6594
6594
  "elk.layered.highDegreeNodes.threshold": "8",
6595
6595
  "elk.layered.compaction.postCompaction.strategy": "LEFT_RIGHT_CONSTRAINT_LOCKING",
6596
6596
  "elk.layered.considerModelOrder.strategy": "NODES_AND_EDGES",
6597
+ "elk.layered.cycleBreaking.strategy": "DEPTH_FIRST",
6597
6598
  "elk.layered.wrapping.strategy": "OFF",
6598
6599
  // Use SEPARATE when subgraphs have direction overrides (enables proper direction handling)
6599
6600
  // Use INCLUDE_CHILDREN otherwise (simpler cross-hierarchy edge routing)
@@ -6698,6 +6699,7 @@ function subgraphToElk(sg, graph, opts, edgesBySubgraph, subgraphPorts) {
6698
6699
  "elk.layered.spacing.edgeEdgeBetweenLayers": "12",
6699
6700
  "elk.layered.spacing.edgeNodeBetweenLayers": "12",
6700
6701
  "elk.layered.nodePlacement.bk.fixedAlignment": "BALANCED",
6702
+ "elk.layered.cycleBreaking.strategy": "DEPTH_FIRST",
6701
6703
  "elk.layered.spacing.nodeNodeBetweenLayers": String(opts.layerSpacing),
6702
6704
  "elk.spacing.nodeNode": String(opts.nodeSpacing)
6703
6705
  };
@@ -6802,6 +6804,7 @@ function elkToPositioned(elkResult, graph, mergeEdges = false) {
6802
6804
  collectAllSubgraphIds(sg, subgraphIds);
6803
6805
  }
6804
6806
  extractNodesAndGroups(elkResult, graph, subgraphIds, nodes, groups, 0, 0);
6807
+ expandGroupsForLabels(groups, nodes);
6805
6808
  const allBounds = flattenGroupBounds(groups);
6806
6809
  const margins = allBounds.length > 0 ? {
6807
6810
  leftX: Math.min(...allBounds.map((b) => b.x)) - 20,
@@ -6812,6 +6815,7 @@ function elkToPositioned(elkResult, graph, mergeEdges = false) {
6812
6815
  if (mergeEdges) {
6813
6816
  bundleEdgePaths(edges, nodes, groups, graph.direction);
6814
6817
  }
6818
+ rerouteBackEdges(edges, nodes, groups, graph.direction);
6815
6819
  const nodeMap = new Map(nodes.map((n) => [n.id, n]));
6816
6820
  for (const edge of edges) {
6817
6821
  const sourceNode = nodeMap.get(edge.source);
@@ -6845,6 +6849,34 @@ function elkToPositioned(elkResult, graph, mergeEdges = false) {
6845
6849
  groups
6846
6850
  };
6847
6851
  }
6852
+ function expandGroupsForLabels(groups, nodes) {
6853
+ for (const group of groups) {
6854
+ if (group.children.length > 0) {
6855
+ expandGroupsForLabels(group.children, nodes);
6856
+ }
6857
+ if (!group.label) continue;
6858
+ const metrics = measureMultilineText(group.label, FONT_SIZES.groupHeader, FONT_WEIGHTS.groupHeader);
6859
+ const needed = metrics.width + 12 + 16;
6860
+ if (needed <= group.width) continue;
6861
+ const extra = needed - group.width;
6862
+ const shift = extra / 2;
6863
+ for (const node of nodes) {
6864
+ if (node.x >= group.x && node.x + node.width <= group.x + group.width + extra && node.y >= group.y && node.y + node.height <= group.y + group.height) {
6865
+ node.x += shift;
6866
+ }
6867
+ }
6868
+ for (const child of group.children) {
6869
+ shiftGroupX(child, shift);
6870
+ }
6871
+ group.width = needed;
6872
+ }
6873
+ }
6874
+ function shiftGroupX(group, dx) {
6875
+ group.x += dx;
6876
+ for (const child of group.children) {
6877
+ shiftGroupX(child, dx);
6878
+ }
6879
+ }
6848
6880
  function extractNodesAndGroups(elkNode, graph, subgraphIds, nodes, groups, offsetX, offsetY) {
6849
6881
  if (!elkNode.children) return;
6850
6882
  for (const child of elkNode.children) {
@@ -6997,6 +7029,99 @@ function orthogonalizeEdgePoints(points, margins, edgeIndex = 0) {
6997
7029
  }
6998
7030
  return result;
6999
7031
  }
7032
+ function rerouteBackEdges(edges, nodes, groups, direction) {
7033
+ const isVertical = direction === "TD" || direction === "TB" || direction === "BT";
7034
+ if (!isVertical) return;
7035
+ const isDownward = direction === "TD" || direction === "TB";
7036
+ const nodeMap = new Map(nodes.map((n) => [n.id, n]));
7037
+ const backEdgeIndices = [];
7038
+ for (let i = 0; i < edges.length; i++) {
7039
+ const edge = edges[i];
7040
+ const src = nodeMap.get(edge.source);
7041
+ const tgt = nodeMap.get(edge.target);
7042
+ if (!src || !tgt || edge.points.length < 2) continue;
7043
+ const isBackEdge = isDownward ? src.y > tgt.y : src.y + src.height < tgt.y + tgt.height;
7044
+ if (isBackEdge) backEdgeIndices.push(i);
7045
+ }
7046
+ if (backEdgeIndices.length === 0) return;
7047
+ const nodeExitCount = /* @__PURE__ */ new Map();
7048
+ const nodeEnterCount = /* @__PURE__ */ new Map();
7049
+ for (const idx of backEdgeIndices) {
7050
+ const edge = edges[idx];
7051
+ nodeExitCount.set(edge.source, (nodeExitCount.get(edge.source) ?? 0) + 1);
7052
+ nodeEnterCount.set(edge.target, (nodeEnterCount.get(edge.target) ?? 0) + 1);
7053
+ }
7054
+ const nodeTotalPorts = /* @__PURE__ */ new Map();
7055
+ const allNodeIds = /* @__PURE__ */ new Set([...nodeExitCount.keys(), ...nodeEnterCount.keys()]);
7056
+ for (const id of allNodeIds) {
7057
+ nodeTotalPorts.set(id, (nodeExitCount.get(id) ?? 0) + (nodeEnterCount.get(id) ?? 0));
7058
+ }
7059
+ const nodeNextExitSlot = /* @__PURE__ */ new Map();
7060
+ const nodeNextEnterSlot = /* @__PURE__ */ new Map();
7061
+ const allBounds = flattenGroupBounds(groups);
7062
+ for (const n of nodes) {
7063
+ allBounds.push({ x: n.x, y: n.y, right: n.x + n.width, bottom: n.y + n.height });
7064
+ }
7065
+ const diagramRight = Math.max(...allBounds.map((b) => b.right));
7066
+ const EDGE_SPACING = 12;
7067
+ const PORT_MARGIN = 6;
7068
+ function portY(node, slotIndex, totalSlots) {
7069
+ if (totalSlots === 1) return node.y + node.height / 2;
7070
+ const usable = node.height - PORT_MARGIN * 2;
7071
+ return node.y + PORT_MARGIN + usable * (slotIndex + 0.5) / totalSlots;
7072
+ }
7073
+ for (let bi = 0; bi < backEdgeIndices.length; bi++) {
7074
+ const edge = edges[backEdgeIndices[bi]];
7075
+ const src = nodeMap.get(edge.source);
7076
+ const tgt = nodeMap.get(edge.target);
7077
+ const srcExitIdx = nodeNextExitSlot.get(edge.source) ?? 0;
7078
+ nodeNextExitSlot.set(edge.source, srcExitIdx + 1);
7079
+ const srcExitY = portY(src, srcExitIdx, nodeTotalPorts.get(edge.source));
7080
+ const tgtEnterIdx = nodeNextEnterSlot.get(edge.target) ?? 0;
7081
+ nodeNextEnterSlot.set(edge.target, tgtEnterIdx + 1);
7082
+ const tgtExitOffset = nodeExitCount.get(edge.target) ?? 0;
7083
+ const tgtEntryY = portY(tgt, tgtExitOffset + tgtEnterIdx, nodeTotalPorts.get(edge.target));
7084
+ const srcRight = src.x + src.width;
7085
+ const tgtRight = tgt.x + tgt.width;
7086
+ const marginX = diagramRight + 20 + bi * EDGE_SPACING;
7087
+ edge.points = [
7088
+ { x: srcRight, y: srcExitY },
7089
+ { x: marginX, y: srcExitY },
7090
+ { x: marginX, y: tgtEntryY },
7091
+ { x: tgtRight, y: tgtEntryY }
7092
+ ];
7093
+ }
7094
+ const LABEL_PADDING = 8;
7095
+ const placedLabels = [];
7096
+ for (const idx of backEdgeIndices) {
7097
+ const edge = edges[idx];
7098
+ if (!edge.label) continue;
7099
+ const metrics = measureMultilineText(edge.label, FONT_SIZES.edgeLabel, FONT_WEIGHTS.edgeLabel);
7100
+ const halfW = (metrics.width + LABEL_PADDING * 2) / 2;
7101
+ const halfH = (metrics.height + LABEL_PADDING * 2) / 2;
7102
+ const marginX = edge.points[1].x;
7103
+ const topY = Math.min(edge.points[1].y, edge.points[2].y);
7104
+ const botY = Math.max(edge.points[1].y, edge.points[2].y);
7105
+ let labelX = marginX;
7106
+ let labelY = (topY + botY) / 2;
7107
+ for (let attempt = 0; attempt < placedLabels.length + 1; attempt++) {
7108
+ let overlap = false;
7109
+ for (const prev of placedLabels) {
7110
+ const overlapX = Math.abs(labelX - prev.x) < halfW + prev.hw;
7111
+ const overlapY = Math.abs(labelY - prev.y) < halfH + prev.hh;
7112
+ if (overlapX && overlapY) {
7113
+ labelY = prev.y + prev.hh + halfH + 2;
7114
+ overlap = true;
7115
+ break;
7116
+ }
7117
+ }
7118
+ if (!overlap) break;
7119
+ }
7120
+ labelY = Math.max(topY + halfH, Math.min(botY - halfH, labelY));
7121
+ edge.labelPosition = { x: labelX, y: labelY };
7122
+ placedLabels.push({ x: labelX, y: labelY, hw: halfW, hh: halfH });
7123
+ }
7124
+ }
7000
7125
  function collectEdgeSegments(elkNode, segments, offsetX, offsetY) {
7001
7126
  if (elkNode.edges) {
7002
7127
  for (const elkEdge of elkNode.edges) {
@@ -7337,9 +7462,121 @@ function bundleEdgePaths(edges, nodes, groups, direction) {
7337
7462
  }
7338
7463
  }
7339
7464
  }
7465
+ function buildFlatElkGraph(compoundElk) {
7466
+ const flatNodes = [];
7467
+ const flatEdges = [];
7468
+ function collectNodes(node) {
7469
+ if (!node.children) return;
7470
+ for (const child of node.children) {
7471
+ if (child.children) {
7472
+ if (child.children.length > 0) collectNodes(child);
7473
+ } else {
7474
+ flatNodes.push({ id: child.id, width: child.width, height: child.height, labels: child.labels });
7475
+ }
7476
+ }
7477
+ }
7478
+ collectNodes(compoundElk);
7479
+ const flatNodeIds = new Set(flatNodes.map((n) => n.id));
7480
+ function collectEdges(node) {
7481
+ if (node.edges) {
7482
+ for (const e of node.edges) {
7483
+ if (e.id.endsWith("_internal")) continue;
7484
+ const src = e.sources[0];
7485
+ const tgt = e.targets[0];
7486
+ if (src && tgt && flatNodeIds.has(src) && flatNodeIds.has(tgt)) {
7487
+ flatEdges.push({ id: e.id, sources: [...e.sources], targets: [...e.targets] });
7488
+ }
7489
+ }
7490
+ }
7491
+ if (node.children) {
7492
+ for (const child of node.children) collectEdges(child);
7493
+ }
7494
+ }
7495
+ collectEdges(compoundElk);
7496
+ return {
7497
+ id: "flat_root",
7498
+ layoutOptions: {
7499
+ "elk.algorithm": "layered",
7500
+ "elk.direction": compoundElk.layoutOptions?.["elk.direction"] ?? "DOWN",
7501
+ "elk.spacing.nodeNode": compoundElk.layoutOptions?.["elk.spacing.nodeNode"] ?? "28",
7502
+ "elk.layered.spacing.nodeNodeBetweenLayers": compoundElk.layoutOptions?.["elk.layered.spacing.nodeNodeBetweenLayers"] ?? "48",
7503
+ "elk.spacing.edgeEdge": "12",
7504
+ "elk.edgeRouting": "ORTHOGONAL",
7505
+ "elk.layered.cycleBreaking.strategy": "DEPTH_FIRST",
7506
+ "elk.layered.considerModelOrder.strategy": "NODES_AND_EDGES",
7507
+ "elk.padding": compoundElk.layoutOptions?.["elk.padding"] ?? "[top=40,left=40,bottom=40,right=40]"
7508
+ },
7509
+ children: flatNodes,
7510
+ edges: flatEdges
7511
+ };
7512
+ }
7513
+ function extractFlatLayers(flatResult, layerSpacing, direction) {
7514
+ const isHorizontal = direction === "LR" || direction === "RL";
7515
+ const positions = /* @__PURE__ */ new Map();
7516
+ for (const child of flatResult.children ?? []) {
7517
+ positions.set(child.id, isHorizontal ? child.x ?? 0 : child.y ?? 0);
7518
+ }
7519
+ const sorted = [...positions.entries()].sort((a, b) => a[1] - b[1]);
7520
+ if (sorted.length === 0) return /* @__PURE__ */ new Map();
7521
+ const nodeLayer = /* @__PURE__ */ new Map();
7522
+ const threshold = layerSpacing * 0.6;
7523
+ let layerIdx = 0;
7524
+ let prevPos = sorted[0][1];
7525
+ nodeLayer.set(sorted[0][0], 0);
7526
+ for (let i = 1; i < sorted.length; i++) {
7527
+ if (sorted[i][1] - prevPos > threshold) layerIdx++;
7528
+ nodeLayer.set(sorted[i][0], layerIdx);
7529
+ prevPos = sorted[i][1];
7530
+ }
7531
+ return nodeLayer;
7532
+ }
7533
+ function addLayerConstraintEdges(elkGraph, nodeLayer, subgraphs) {
7534
+ const sgNodeMap = /* @__PURE__ */ new Map();
7535
+ function findSgNodes(node) {
7536
+ if (!node.children) return;
7537
+ for (const child of node.children) {
7538
+ if (child.children && child.children.length > 0) {
7539
+ sgNodeMap.set(child.id, child);
7540
+ findSgNodes(child);
7541
+ }
7542
+ }
7543
+ }
7544
+ findSgNodes(elkGraph);
7545
+ for (const sg of subgraphs) {
7546
+ const elkSg = sgNodeMap.get(sg.id);
7547
+ if (!elkSg || !elkSg.edges) continue;
7548
+ const members = sg.nodeIds.filter((id) => nodeLayer.has(id)).sort((a, b) => nodeLayer.get(a) - nodeLayer.get(b));
7549
+ if (members.length < 2) continue;
7550
+ const firstLayer = nodeLayer.get(members[0]);
7551
+ const lastLayer = nodeLayer.get(members[members.length - 1]);
7552
+ if (firstLayer === lastLayer) continue;
7553
+ for (let i = 0; i < members.length - 1; i++) {
7554
+ const srcLayer = nodeLayer.get(members[i]);
7555
+ const tgtLayer = nodeLayer.get(members[i + 1]);
7556
+ if (srcLayer < tgtLayer) {
7557
+ elkSg.edges.push({
7558
+ id: `lc_${sg.id}_${i}`,
7559
+ sources: [members[i]],
7560
+ targets: [members[i + 1]]
7561
+ });
7562
+ }
7563
+ }
7564
+ if (sg.children.length > 0) {
7565
+ addLayerConstraintEdges(elkGraph, nodeLayer, sg.children);
7566
+ }
7567
+ }
7568
+ }
7340
7569
  function layoutGraphSync(graph, options = {}) {
7341
7570
  const opts = { ...DEFAULTS2, ...options };
7342
7571
  const elkGraph = mermaidToElk(graph, opts);
7572
+ if (graph.subgraphs.length > 0) {
7573
+ const flatElk = buildFlatElkGraph(elkGraph);
7574
+ if ((flatElk.children?.length ?? 0) >= 2 && (flatElk.edges?.length ?? 0) > 0) {
7575
+ const flatResult = elkLayoutSync(flatElk);
7576
+ const nodeLayer = extractFlatLayers(flatResult, opts.layerSpacing, graph.direction);
7577
+ addLayerConstraintEdges(elkGraph, nodeLayer, graph.subgraphs);
7578
+ }
7579
+ }
7343
7580
  const result = elkLayoutSync(elkGraph);
7344
7581
  return elkToPositioned(result, graph, DEFAULTS2.mergeEdges);
7345
7582
  }
@@ -7439,7 +7676,6 @@ function renderGroup(group, font) {
7439
7676
  }
7440
7677
  function renderEdge(edge) {
7441
7678
  if (edge.points.length < 2) return "";
7442
- const pathData = pointsToPolylinePath(edge.points);
7443
7679
  const dashArray = edge.style === "dotted" ? ' stroke-dasharray="4 4"' : "";
7444
7680
  const baseStrokeWidth = edge.style === "thick" ? STROKE_WIDTHS.connector * 2 : STROKE_WIDTHS.connector;
7445
7681
  const strokeColor = escapeAttr(edge.inlineStyle?.stroke ?? "var(--_line)");
@@ -7459,10 +7695,41 @@ function renderEdge(edge) {
7459
7695
  if (edge.label) {
7460
7696
  dataAttrs.push(`data-label="${escapeAttr(edge.label)}"`);
7461
7697
  }
7462
- return `<polyline ${dataAttrs.join(" ")} points="${pathData}" fill="none" stroke="${strokeColor}" stroke-width="${strokeWidth}"${dashArray}${markers} />`;
7698
+ return `<path ${dataAttrs.join(" ")} d="${pointsToRoundedPath(edge.points)}" fill="none" stroke="${strokeColor}" stroke-width="${strokeWidth}"${dashArray}${markers} />`;
7463
7699
  }
7464
- function pointsToPolylinePath(points) {
7465
- return points.map((p) => `${p.x},${p.y}`).join(" ");
7700
+ function pointsToRoundedPath(points, radius = 6) {
7701
+ if (points.length < 2) return "";
7702
+ if (points.length === 2 || radius <= 0) {
7703
+ return `M${points[0].x},${points[0].y}` + points.slice(1).map((p) => `L${p.x},${p.y}`).join("");
7704
+ }
7705
+ const parts = [`M${points[0].x},${points[0].y}`];
7706
+ for (let i = 1; i < points.length - 1; i++) {
7707
+ const prev = points[i - 1];
7708
+ const curr = points[i];
7709
+ const next = points[i + 1];
7710
+ const lenIn = Math.abs(curr.x - prev.x) + Math.abs(curr.y - prev.y);
7711
+ const lenOut = Math.abs(next.x - curr.x) + Math.abs(next.y - curr.y);
7712
+ const r2 = Math.min(radius, lenIn / 2, lenOut / 2);
7713
+ if (r2 < 1) {
7714
+ parts.push(`L${curr.x},${curr.y}`);
7715
+ continue;
7716
+ }
7717
+ const dx1 = curr.x - prev.x;
7718
+ const dy1 = curr.y - prev.y;
7719
+ const d1 = Math.sqrt(dx1 * dx1 + dy1 * dy1) || 1;
7720
+ const startX = curr.x - dx1 / d1 * r2;
7721
+ const startY = curr.y - dy1 / d1 * r2;
7722
+ const dx2 = next.x - curr.x;
7723
+ const dy2 = next.y - curr.y;
7724
+ const d2 = Math.sqrt(dx2 * dx2 + dy2 * dy2) || 1;
7725
+ const endX = curr.x + dx2 / d2 * r2;
7726
+ const endY = curr.y + dy2 / d2 * r2;
7727
+ parts.push(`L${startX},${startY}`);
7728
+ parts.push(`Q${curr.x},${curr.y} ${endX},${endY}`);
7729
+ }
7730
+ const last = points[points.length - 1];
7731
+ parts.push(`L${last.x},${last.y}`);
7732
+ return parts.join("");
7466
7733
  }
7467
7734
  function renderEdgeLabel(edge, font) {
7468
7735
  const mid = edge.labelPosition ?? edgeMidpoint(edge.points);