@diagrammo/dgmo 0.8.22 → 0.8.25

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.
Files changed (90) hide show
  1. package/.claude/commands/dgmo.md +60 -72
  2. package/dist/cli.cjs +123 -116
  3. package/dist/editor.cjs +3 -2
  4. package/dist/editor.cjs.map +1 -1
  5. package/dist/editor.js +3 -2
  6. package/dist/editor.js.map +1 -1
  7. package/dist/highlight.cjs +3 -2
  8. package/dist/highlight.cjs.map +1 -1
  9. package/dist/highlight.js +3 -2
  10. package/dist/highlight.js.map +1 -1
  11. package/dist/index.cjs +1649 -442
  12. package/dist/index.cjs.map +1 -1
  13. package/dist/index.d.cts +196 -23
  14. package/dist/index.d.ts +196 -23
  15. package/dist/index.js +1631 -440
  16. package/dist/index.js.map +1 -1
  17. package/dist/internal.cjs +677 -0
  18. package/dist/internal.cjs.map +1 -0
  19. package/dist/internal.d.cts +267 -0
  20. package/dist/internal.d.ts +267 -0
  21. package/dist/internal.js +633 -0
  22. package/dist/internal.js.map +1 -0
  23. package/docs/guide/chart-area.md +17 -17
  24. package/docs/guide/chart-bar-stacked.md +12 -12
  25. package/docs/guide/chart-cycle.md +156 -0
  26. package/docs/guide/chart-doughnut.md +10 -10
  27. package/docs/guide/chart-funnel.md +9 -9
  28. package/docs/guide/chart-heatmap.md +10 -10
  29. package/docs/guide/chart-journey-map.md +179 -0
  30. package/docs/guide/chart-kanban.md +2 -0
  31. package/docs/guide/chart-line.md +19 -19
  32. package/docs/guide/chart-multi-line.md +16 -16
  33. package/docs/guide/chart-pie.md +11 -11
  34. package/docs/guide/chart-polar-area.md +10 -10
  35. package/docs/guide/chart-pyramid.md +111 -0
  36. package/docs/guide/chart-radar.md +9 -9
  37. package/docs/guide/chart-scatter.md +24 -27
  38. package/docs/guide/index.md +3 -3
  39. package/docs/guide/registry.json +5 -0
  40. package/docs/language-reference.md +108 -26
  41. package/fonts/Inter-Bold.ttf +0 -0
  42. package/fonts/Inter-Regular.ttf +0 -0
  43. package/fonts/LICENSE-Inter.txt +92 -0
  44. package/gallery/fixtures/bar-stacked.dgmo +12 -6
  45. package/gallery/fixtures/heatmap.dgmo +12 -6
  46. package/gallery/fixtures/multi-line.dgmo +11 -7
  47. package/gallery/fixtures/pyramid/dikw.dgmo +17 -0
  48. package/gallery/fixtures/pyramid/inverted-funnel.dgmo +16 -0
  49. package/gallery/fixtures/pyramid/minimal.dgmo +5 -0
  50. package/gallery/fixtures/quadrant.dgmo +8 -8
  51. package/gallery/fixtures/scatter.dgmo +12 -12
  52. package/package.json +14 -2
  53. package/src/boxes-and-lines/parser.ts +13 -2
  54. package/src/boxes-and-lines/renderer.ts +22 -13
  55. package/src/chart-type-scoring.ts +162 -0
  56. package/src/chart-types.ts +437 -0
  57. package/src/cli.ts +152 -101
  58. package/src/completion.ts +9 -48
  59. package/src/cycle/layout.ts +19 -28
  60. package/src/cycle/renderer.ts +59 -32
  61. package/src/cycle/types.ts +21 -0
  62. package/src/d3.ts +30 -3
  63. package/src/dgmo-router.ts +98 -73
  64. package/src/echarts.ts +1 -1
  65. package/src/editor/keywords.ts +3 -2
  66. package/src/fonts.ts +3 -2
  67. package/src/gantt/parser.ts +5 -1
  68. package/src/index.ts +37 -3
  69. package/src/infra/parser.ts +3 -3
  70. package/src/internal.ts +20 -0
  71. package/src/journey-map/layout.ts +7 -3
  72. package/src/journey-map/parser.ts +5 -1
  73. package/src/journey-map/renderer.ts +112 -47
  74. package/src/kanban/parser.ts +5 -1
  75. package/src/org/collapse.ts +82 -4
  76. package/src/org/parser.ts +1 -1
  77. package/src/org/renderer.ts +221 -4
  78. package/src/pyramid/parser.ts +172 -0
  79. package/src/pyramid/renderer.ts +684 -0
  80. package/src/pyramid/types.ts +28 -0
  81. package/src/render.ts +2 -8
  82. package/src/sequence/parser.ts +64 -22
  83. package/src/sequence/participant-inference.ts +0 -1
  84. package/src/sequence/renderer.ts +97 -265
  85. package/src/sharing.ts +0 -1
  86. package/src/sitemap/parser.ts +1 -1
  87. package/src/tech-radar/interactive.ts +54 -0
  88. package/src/utils/parsing.ts +1 -0
  89. package/src/utils/tag-groups.ts +35 -5
  90. package/src/wireframe/parser.ts +3 -1
@@ -62,8 +62,6 @@ const NOTE_CHAR_W = 6;
62
62
  const NOTE_CHARS_PER_LINE = Math.floor(
63
63
  (NOTE_MAX_W - NOTE_PAD_H * 2 - NOTE_FOLD) / NOTE_CHAR_W
64
64
  );
65
- const COLLAPSED_NOTE_H = 20;
66
- const COLLAPSED_NOTE_W = 40;
67
65
  const ACTIVATION_WIDTH = 10;
68
66
  const SELF_CALL_HEIGHT = 25;
69
67
  const SELF_CALL_WIDTH = 30;
@@ -557,13 +555,8 @@ export interface SectionMessageGroup {
557
555
  export interface SequenceRenderOptions {
558
556
  collapsedSections?: Set<number>; // keyed by section lineNumber
559
557
  collapsedGroups?: Set<number>; // keyed by group lineNumber
560
- expandedNoteLines?: Set<number>; // keyed by note lineNumber; undefined = all expanded (CLI default)
561
558
  exportWidth?: number; // Explicit width for CLI/export rendering (bypasses getBoundingClientRect)
562
559
  activeTagGroup?: string | null; // Active tag group name for tag-driven recoloring; null = explicitly none
563
- expandAllNotes?: boolean; // Whether the "Expand Notes" toggle is active
564
- onExpandAllNotes?: (expand: boolean) => void; // Toggle all notes expanded/collapsed
565
- controlsExpanded?: boolean; // Controls group expanded state (managed by React)
566
- onToggleControlsExpand?: () => void; // Callback to toggle controls group
567
560
  }
568
561
 
569
562
  /**
@@ -960,15 +953,6 @@ export function renderSequenceDiagram(
960
953
  const collapsedGroupIds = collapsed?.collapsedGroupIds ?? new Map();
961
954
 
962
955
  const collapsedSections = options?.collapsedSections;
963
- const expandedNoteLines = options?.expandedNoteLines;
964
- const collapseNotesDisabled =
965
- parsedOptions['collapse-notes']?.toLowerCase() === 'no';
966
- // A note is expanded if: expandedNoteLines is undefined (CLI/export),
967
- // collapse-notes: no is set, or the note's lineNumber is in the set.
968
- const isNoteExpanded = (note: SequenceNote): boolean =>
969
- expandedNoteLines === undefined ||
970
- collapseNotesDisabled ||
971
- expandedNoteLines.has(note.lineNumber);
972
956
 
973
957
  const sourceParticipants = collapsed
974
958
  ? collapsed.participants
@@ -1232,9 +1216,7 @@ export function renderSequenceDiagram(
1232
1216
  const note = els[j] as SequenceNote;
1233
1217
  const sc = isNoteAfterSelfCall(note);
1234
1218
  const maxW = noteEffectiveMaxW(note.participantId, note.position, sc);
1235
- const noteH = isNoteExpanded(note)
1236
- ? computeNoteHeight(note.text, charsForWidth(maxW))
1237
- : COLLAPSED_NOTE_H;
1219
+ const noteH = computeNoteHeight(note.text, charsForWidth(maxW));
1238
1220
  totalExtent += noteH + NOTE_OFFSET_BELOW;
1239
1221
  j++;
1240
1222
  }
@@ -1500,9 +1482,10 @@ export function renderSequenceDiagram(
1500
1482
  prevNote.position,
1501
1483
  isNoteAfterSelfCall(prevNote)
1502
1484
  );
1503
- const prevNoteH = isNoteExpanded(prevNote)
1504
- ? computeNoteHeight(prevNote.text, charsForWidth(prevMaxW))
1505
- : COLLAPSED_NOTE_H;
1485
+ const prevNoteH = computeNoteHeight(
1486
+ prevNote.text,
1487
+ charsForWidth(prevMaxW)
1488
+ );
1506
1489
  noteTopY = prevNoteY + prevNoteH + NOTE_OFFSET_BELOW;
1507
1490
  } else {
1508
1491
  // First note after a message — use larger offset after self-calls
@@ -1539,9 +1522,7 @@ export function renderSequenceDiagram(
1539
1522
  note.position,
1540
1523
  isNoteAfterSelfCall(note)
1541
1524
  );
1542
- const noteH = isNoteExpanded(note)
1543
- ? computeNoteHeight(note.text, charsForWidth(maxW))
1544
- : COLLAPSED_NOTE_H;
1525
+ const noteH = computeNoteHeight(note.text, charsForWidth(maxW));
1545
1526
  contentBottomY = Math.max(
1546
1527
  contentBottomY,
1547
1528
  noteTopY + noteH + NOTE_TRAILING_GAP
@@ -1736,32 +1717,6 @@ export function renderSequenceDiagram(
1736
1717
  }
1737
1718
  }
1738
1719
 
1739
- // Collect all note line numbers (for controls group visibility + "all expanded" check)
1740
- const allNoteLineNumbers: number[] = [];
1741
- const collectNoteLines = (els: SequenceElement[]): void => {
1742
- for (const el of els) {
1743
- if (isSequenceNote(el)) {
1744
- allNoteLineNumbers.push(el.lineNumber);
1745
- } else if (isSequenceBlock(el)) {
1746
- collectNoteLines(el.children);
1747
- if ('elseChildren' in el) collectNoteLines(el.elseChildren);
1748
- if ('branches' in el && Array.isArray(el.branches)) {
1749
- for (const branch of el.branches) {
1750
- collectNoteLines(branch.children);
1751
- }
1752
- }
1753
- }
1754
- }
1755
- };
1756
- collectNoteLines(elements);
1757
-
1758
- // Show controls group only in interactive mode (expandedNoteLines defined)
1759
- // when notes exist and collapse-notes is not disabled
1760
- const showNotesControl =
1761
- allNoteLineNumbers.length > 0 &&
1762
- !collapseNotesDisabled &&
1763
- expandedNoteLines !== undefined;
1764
-
1765
1720
  const hasTagGroups = parsed.tagGroups.length > 0;
1766
1721
 
1767
1722
  // Build set of collapsed group names for drill-bar rendering
@@ -2127,7 +2082,7 @@ export function renderSequenceDiagram(
2127
2082
  firstBranchStep = Math.min(firstBranchStep, first);
2128
2083
  }
2129
2084
  if (firstBranchStep < Infinity) {
2130
- const dividerY = stepY(firstBranchStep) - stepSpacing / 2;
2085
+ const dividerY = stepY(firstBranchStep) - BLOCK_HEADER_SPACE;
2131
2086
  deferredLines.push({
2132
2087
  x1: frameX,
2133
2088
  y1: dividerY,
@@ -2156,7 +2111,7 @@ export function renderSequenceDiagram(
2156
2111
  firstElseStep = Math.min(firstElseStep, first);
2157
2112
  }
2158
2113
  if (firstElseStep < Infinity) {
2159
- const dividerY = stepY(firstElseStep) - stepSpacing / 2;
2114
+ const dividerY = stepY(firstElseStep) - BLOCK_HEADER_SPACE;
2160
2115
  deferredLines.push({
2161
2116
  x1: frameX,
2162
2117
  y1: dividerY,
@@ -2632,8 +2587,6 @@ export function renderSequenceDiagram(
2632
2587
  ? mix(palette.surface, palette.bg, 50)
2633
2588
  : mix(palette.bg, palette.surface, 15);
2634
2589
 
2635
- const collapsedNoteFill = mix(palette.textMuted, palette.bg, 15);
2636
-
2637
2590
  const renderNoteElements = (els: SequenceElement[]): void => {
2638
2591
  for (const el of els) {
2639
2592
  if (isSequenceNote(el)) {
@@ -2642,168 +2595,98 @@ export function renderSequenceDiagram(
2642
2595
  const noteTopY = noteYMap.get(el);
2643
2596
  if (noteTopY === undefined) continue;
2644
2597
 
2645
- const expanded = isNoteExpanded(el);
2646
2598
  const isRight = el.position === 'right';
2599
+ const afterSelfCall = isNoteAfterSelfCall(el);
2600
+ const maxW = noteEffectiveMaxW(
2601
+ el.participantId,
2602
+ el.position,
2603
+ afterSelfCall
2604
+ );
2605
+ const maxChars = charsForWidth(maxW);
2606
+ const wrappedLines = wrapTextLines(el.text, maxChars);
2607
+ const noteH = wrappedLines.length * NOTE_LINE_H + NOTE_PAD_V * 2;
2608
+ const maxLineLen = Math.max(...wrappedLines.map((l) => l.length));
2609
+ const noteW = Math.min(
2610
+ maxW,
2611
+ Math.max(80, maxLineLen * NOTE_CHAR_W + NOTE_PAD_H * 2 + NOTE_FOLD)
2612
+ );
2613
+ // Shift notes past self-call loopback when applicable
2614
+ const rightOffset =
2615
+ afterSelfCall && isRight
2616
+ ? ACTIVATION_WIDTH / 2 + SELF_CALL_WIDTH + NOTE_GAP
2617
+ : ACTIVATION_WIDTH + NOTE_GAP;
2618
+ const noteX = isRight
2619
+ ? px + rightOffset
2620
+ : px - ACTIVATION_WIDTH - NOTE_GAP - noteW;
2621
+
2622
+ const noteG = svg
2623
+ .append('g')
2624
+ .attr('class', 'note')
2625
+ .attr('data-note-toggle', '')
2626
+ .attr('data-line-number', String(el.lineNumber))
2627
+ .attr('data-line-end', String(el.endLineNumber));
2628
+
2629
+ // Folded-corner path
2630
+ noteG
2631
+ .append('path')
2632
+ .attr(
2633
+ 'd',
2634
+ [
2635
+ `M ${noteX} ${noteTopY}`,
2636
+ `L ${noteX + noteW - NOTE_FOLD} ${noteTopY}`,
2637
+ `L ${noteX + noteW} ${noteTopY + NOTE_FOLD}`,
2638
+ `L ${noteX + noteW} ${noteTopY + noteH}`,
2639
+ `L ${noteX} ${noteTopY + noteH}`,
2640
+ 'Z',
2641
+ ].join(' ')
2642
+ )
2643
+ .attr('fill', noteFill)
2644
+ .attr('stroke', palette.textMuted)
2645
+ .attr('stroke-width', 0.75)
2646
+ .attr('class', 'note-box');
2647
2647
 
2648
- if (expanded) {
2649
- // --- Expanded note: full folded-corner box with wrapped text ---
2650
- const afterSelfCall = isNoteAfterSelfCall(el);
2651
- const maxW = noteEffectiveMaxW(
2652
- el.participantId,
2653
- el.position,
2654
- afterSelfCall
2655
- );
2656
- const maxChars = charsForWidth(maxW);
2657
- const wrappedLines = wrapTextLines(el.text, maxChars);
2658
- const noteH = wrappedLines.length * NOTE_LINE_H + NOTE_PAD_V * 2;
2659
- const maxLineLen = Math.max(...wrappedLines.map((l) => l.length));
2660
- const noteW = Math.min(
2661
- maxW,
2662
- Math.max(80, maxLineLen * NOTE_CHAR_W + NOTE_PAD_H * 2 + NOTE_FOLD)
2663
- );
2664
- // Shift notes past self-call loopback when applicable
2665
- const rightOffset =
2666
- afterSelfCall && isRight
2667
- ? ACTIVATION_WIDTH / 2 + SELF_CALL_WIDTH + NOTE_GAP
2668
- : ACTIVATION_WIDTH + NOTE_GAP;
2669
- const noteX = isRight
2670
- ? px + rightOffset
2671
- : px - ACTIVATION_WIDTH - NOTE_GAP - noteW;
2672
-
2673
- const noteG = svg
2674
- .append('g')
2675
- .attr('class', 'note')
2676
- .attr('data-note-toggle', '')
2677
- .attr('data-line-number', String(el.lineNumber))
2678
- .attr('data-line-end', String(el.endLineNumber));
2679
-
2680
- // Folded-corner path
2681
- noteG
2682
- .append('path')
2683
- .attr(
2684
- 'd',
2685
- [
2686
- `M ${noteX} ${noteTopY}`,
2687
- `L ${noteX + noteW - NOTE_FOLD} ${noteTopY}`,
2688
- `L ${noteX + noteW} ${noteTopY + NOTE_FOLD}`,
2689
- `L ${noteX + noteW} ${noteTopY + noteH}`,
2690
- `L ${noteX} ${noteTopY + noteH}`,
2691
- 'Z',
2692
- ].join(' ')
2693
- )
2694
- .attr('fill', noteFill)
2695
- .attr('stroke', palette.textMuted)
2696
- .attr('stroke-width', 0.75)
2697
- .attr('class', 'note-box');
2698
-
2699
- // Fold triangle
2700
- noteG
2701
- .append('path')
2702
- .attr(
2703
- 'd',
2704
- [
2705
- `M ${noteX + noteW - NOTE_FOLD} ${noteTopY}`,
2706
- `L ${noteX + noteW - NOTE_FOLD} ${noteTopY + NOTE_FOLD}`,
2707
- `L ${noteX + noteW} ${noteTopY + NOTE_FOLD}`,
2708
- ].join(' ')
2709
- )
2710
- .attr('fill', 'none')
2711
- .attr('stroke', palette.textMuted)
2712
- .attr('stroke-width', 0.75)
2713
- .attr('class', 'note-fold');
2714
-
2715
- // Render text with inline markdown
2716
- wrappedLines.forEach((line, li) => {
2717
- const textY = noteTopY + NOTE_PAD_V + (li + 1) * NOTE_LINE_H - 3;
2718
- const isBullet = line.startsWith('- ');
2719
- const bulletIndent = isBullet ? 10 : 0;
2720
- const displayLine = isBullet ? line.slice(2) : line;
2721
- const textEl = noteG
2648
+ // Fold triangle
2649
+ noteG
2650
+ .append('path')
2651
+ .attr(
2652
+ 'd',
2653
+ [
2654
+ `M ${noteX + noteW - NOTE_FOLD} ${noteTopY}`,
2655
+ `L ${noteX + noteW - NOTE_FOLD} ${noteTopY + NOTE_FOLD}`,
2656
+ `L ${noteX + noteW} ${noteTopY + NOTE_FOLD}`,
2657
+ ].join(' ')
2658
+ )
2659
+ .attr('fill', 'none')
2660
+ .attr('stroke', palette.textMuted)
2661
+ .attr('stroke-width', 0.75)
2662
+ .attr('class', 'note-fold');
2663
+
2664
+ // Render text with inline markdown
2665
+ wrappedLines.forEach((line, li) => {
2666
+ const textY = noteTopY + NOTE_PAD_V + (li + 1) * NOTE_LINE_H - 3;
2667
+ const isBullet = line.startsWith('- ');
2668
+ const bulletIndent = isBullet ? 10 : 0;
2669
+ const displayLine = isBullet ? line.slice(2) : line;
2670
+ const textEl = noteG
2671
+ .append('text')
2672
+ .attr('x', noteX + NOTE_PAD_H + bulletIndent)
2673
+ .attr('y', textY)
2674
+ .attr('fill', palette.text)
2675
+ .attr('font-size', NOTE_FONT_SIZE)
2676
+ .attr('class', 'note-text');
2677
+
2678
+ if (isBullet) {
2679
+ noteG
2722
2680
  .append('text')
2723
- .attr('x', noteX + NOTE_PAD_H + bulletIndent)
2681
+ .attr('x', noteX + NOTE_PAD_H)
2724
2682
  .attr('y', textY)
2725
2683
  .attr('fill', palette.text)
2726
2684
  .attr('font-size', NOTE_FONT_SIZE)
2727
- .attr('class', 'note-text');
2728
-
2729
- if (isBullet) {
2730
- noteG
2731
- .append('text')
2732
- .attr('x', noteX + NOTE_PAD_H)
2733
- .attr('y', textY)
2734
- .attr('fill', palette.text)
2735
- .attr('font-size', NOTE_FONT_SIZE)
2736
- .text('\u2022');
2737
- }
2738
-
2739
- renderInlineText(textEl, displayLine, palette, NOTE_FONT_SIZE);
2740
- });
2741
- } else {
2742
- // --- Collapsed note: compact indicator ---
2743
- const cFold = 6;
2744
- const afterSelfCallC = isNoteAfterSelfCall(el);
2745
- const rightOffsetC =
2746
- afterSelfCallC && isRight
2747
- ? ACTIVATION_WIDTH / 2 + SELF_CALL_WIDTH + NOTE_GAP
2748
- : ACTIVATION_WIDTH + NOTE_GAP;
2749
- const noteX = isRight
2750
- ? px + rightOffsetC
2751
- : px - ACTIVATION_WIDTH - NOTE_GAP - COLLAPSED_NOTE_W;
2752
-
2753
- const noteG = svg
2754
- .append('g')
2755
- .attr('class', 'note note-collapsed')
2756
- .attr('data-note-toggle', '')
2757
- .attr('data-line-number', String(el.lineNumber))
2758
- .attr('data-line-end', String(el.endLineNumber))
2759
- .style('cursor', 'pointer');
2760
-
2761
- // Small folded-corner rectangle
2762
- noteG
2763
- .append('path')
2764
- .attr(
2765
- 'd',
2766
- [
2767
- `M ${noteX} ${noteTopY}`,
2768
- `L ${noteX + COLLAPSED_NOTE_W - cFold} ${noteTopY}`,
2769
- `L ${noteX + COLLAPSED_NOTE_W} ${noteTopY + cFold}`,
2770
- `L ${noteX + COLLAPSED_NOTE_W} ${noteTopY + COLLAPSED_NOTE_H}`,
2771
- `L ${noteX} ${noteTopY + COLLAPSED_NOTE_H}`,
2772
- 'Z',
2773
- ].join(' ')
2774
- )
2775
- .attr('fill', collapsedNoteFill)
2776
- .attr('stroke', palette.border)
2777
- .attr('stroke-width', 0.75)
2778
- .attr('class', 'note-box');
2779
-
2780
- // Fold triangle
2781
- noteG
2782
- .append('path')
2783
- .attr(
2784
- 'd',
2785
- [
2786
- `M ${noteX + COLLAPSED_NOTE_W - cFold} ${noteTopY}`,
2787
- `L ${noteX + COLLAPSED_NOTE_W - cFold} ${noteTopY + cFold}`,
2788
- `L ${noteX + COLLAPSED_NOTE_W} ${noteTopY + cFold}`,
2789
- ].join(' ')
2790
- )
2791
- .attr('fill', 'none')
2792
- .attr('stroke', palette.border)
2793
- .attr('stroke-width', 0.75)
2794
- .attr('class', 'note-fold');
2685
+ .text('\u2022');
2686
+ }
2795
2687
 
2796
- // "..." text
2797
- noteG
2798
- .append('text')
2799
- .attr('x', noteX + COLLAPSED_NOTE_W / 2)
2800
- .attr('y', noteTopY + COLLAPSED_NOTE_H / 2 + 3)
2801
- .attr('text-anchor', 'middle')
2802
- .attr('fill', palette.textMuted)
2803
- .attr('font-size', 9)
2804
- .attr('class', 'note-text')
2805
- .text('\u2026');
2806
- }
2688
+ renderInlineText(textEl, displayLine, palette, NOTE_FONT_SIZE);
2689
+ });
2807
2690
  } else if (isSequenceBlock(el)) {
2808
2691
  renderNoteElements(el.children);
2809
2692
  if (el.elseIfBranches) {
@@ -2822,9 +2705,7 @@ export function renderSequenceDiagram(
2822
2705
 
2823
2706
  // Render legend LAST so it sits on top of all other SVG elements
2824
2707
  // (group boxes, lifelines, participants, etc.) and can receive clicks.
2825
- if (hasTagGroups || showNotesControl) {
2826
- const controlsExpanded = options?.controlsExpanded ?? false;
2827
-
2708
+ if (hasTagGroups) {
2828
2709
  const legendY = TOP_MARGIN + titleOffset;
2829
2710
  const resolvedGroups = parsed.tagGroups
2830
2711
  .filter((tg) => tg.entries.length > 0)
@@ -2836,41 +2717,17 @@ export function renderSequenceDiagram(
2836
2717
  })),
2837
2718
  }));
2838
2719
 
2839
- const allExpanded = showNotesControl && (options?.expandAllNotes ?? false);
2840
-
2841
- const controlsGroup = showNotesControl
2842
- ? {
2843
- toggles: [
2844
- {
2845
- id: 'expand-all-notes',
2846
- type: 'toggle' as const,
2847
- label: 'Expand Notes',
2848
- active: allExpanded,
2849
- onToggle: () => {},
2850
- },
2851
- ],
2852
- }
2853
- : undefined;
2854
-
2855
2720
  const legendConfig: LegendConfig = {
2856
2721
  groups: resolvedGroups,
2857
2722
  position: { placement: 'top-center', titleRelation: 'below-title' },
2858
2723
  mode: 'fixed',
2859
- controlsGroup,
2860
2724
  };
2861
2725
  const legendState: LegendState = {
2862
2726
  activeGroup: activeTagGroup ?? null,
2863
- controlsExpanded,
2727
+ controlsExpanded: false,
2864
2728
  };
2865
2729
 
2866
- const legendCallbacks: LegendCallbacks = {
2867
- onControlsExpand: () => {
2868
- options?.onToggleControlsExpand?.();
2869
- },
2870
- onControlsToggle: (_toggleId: string, active: boolean) => {
2871
- options?.onExpandAllNotes?.(active);
2872
- },
2873
- };
2730
+ const legendCallbacks: LegendCallbacks = {};
2874
2731
 
2875
2732
  const legendG = svg
2876
2733
  .append('g')
@@ -2891,7 +2748,7 @@ export function renderSequenceDiagram(
2891
2748
  /**
2892
2749
  * Build a mapping from each note's lineNumber to the lineNumber of its
2893
2750
  * associated message (the last message before the note in document order).
2894
- * Used by the app to expand notes when cursor is on the associated message.
2751
+ * Used by the app to highlight the associated message when cursor is on a note.
2895
2752
  */
2896
2753
  export function buildNoteMessageMap(
2897
2754
  elements: SequenceElement[]
@@ -2924,31 +2781,6 @@ export function buildNoteMessageMap(
2924
2781
  return map;
2925
2782
  }
2926
2783
 
2927
- /**
2928
- * Collect all note line numbers from a sequence diagram's elements.
2929
- * Used by the app to compute the "expand all" set.
2930
- */
2931
- export function collectNoteLineNumbers(elements: SequenceElement[]): number[] {
2932
- const result: number[] = [];
2933
- const walk = (els: SequenceElement[]): void => {
2934
- for (const el of els) {
2935
- if (isSequenceNote(el)) {
2936
- result.push(el.lineNumber);
2937
- } else if (isSequenceBlock(el)) {
2938
- walk(el.children);
2939
- if (el.elseIfBranches) {
2940
- for (const branch of el.elseIfBranches) {
2941
- walk(branch.children);
2942
- }
2943
- }
2944
- walk(el.elseChildren);
2945
- }
2946
- }
2947
- };
2948
- walk(elements);
2949
- return result;
2950
- }
2951
-
2952
2784
  function renderParticipant(
2953
2785
  svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
2954
2786
  participant: SequenceParticipant,
package/src/sharing.ts CHANGED
@@ -21,7 +21,6 @@ export interface CompactViewState {
21
21
  rm?: string; // render mode override
22
22
  htv?: Record<string, string[]>; // hidden tag values
23
23
  ha?: string[]; // hidden attributes
24
- enl?: number[]; // expanded note lines (sequence)
25
24
  sem?: boolean; // semantic colors (ER)
26
25
  cm?: boolean; // compact meta (kanban)
27
26
  c4l?: string; // C4 level
@@ -509,7 +509,7 @@ export function parseSitemap(
509
509
  };
510
510
  collectAll(result.roots);
511
511
  validateTagValues(allNodes, result.tagGroups, pushWarning, suggest);
512
- validateTagGroupNames(result.tagGroups, pushWarning);
512
+ validateTagGroupNames(result.tagGroups, pushWarning, pushError);
513
513
  }
514
514
 
515
515
  if (
@@ -229,12 +229,46 @@ function renderQuarterCircle(
229
229
  const fillColor =
230
230
  ri % 2 === 0 ? palette.bg : mix(palette.bg, palette.border, 0.15);
231
231
 
232
+ const ringName = parsed.rings[ri].name;
233
+
234
+ // Background ring arc
232
235
  svg
233
236
  .append('path')
234
237
  .attr('d', arcGen(innerR, outerR))
235
238
  .attr('fill', fillColor)
236
239
  .attr('stroke', mutedColor)
237
240
  .attr('stroke-width', 0.5);
241
+
242
+ // Transparent hover overlay for ring interaction
243
+ svg
244
+ .append('path')
245
+ .attr('d', arcGen(innerR, outerR))
246
+ .attr('fill', 'transparent')
247
+ .attr('data-ring-arc', ringName)
248
+ .style('cursor', 'pointer')
249
+ .on('mouseenter', () => {
250
+ // Tint the hovered ring arc
251
+ d3Selection
252
+ .select(rootContainer)
253
+ .selectAll<SVGPathElement, unknown>('[data-ring-arc]')
254
+ .each(function () {
255
+ const el = d3Selection.select(this);
256
+ const isMatch = this.getAttribute('data-ring-arc') === ringName;
257
+ el.attr('fill', isMatch ? qColor : 'transparent').attr(
258
+ 'opacity',
259
+ isMatch ? 0.15 : 1
260
+ );
261
+ });
262
+ dimExceptRing(rootContainer, ringName);
263
+ })
264
+ .on('mouseleave', () => {
265
+ d3Selection
266
+ .select(rootContainer)
267
+ .selectAll<SVGPathElement, unknown>('[data-ring-arc]')
268
+ .attr('fill', 'transparent')
269
+ .attr('opacity', 1);
270
+ clearDim(rootContainer);
271
+ });
238
272
  }
239
273
 
240
274
  // Ring labels removed — the side panel ring headers serve this purpose
@@ -346,10 +380,28 @@ function dimExcept(root: HTMLElement, lineNum: string): void {
346
380
  });
347
381
  }
348
382
 
383
+ function dimExceptRing(root: HTMLElement, ringName: string): void {
384
+ // Dim blips not in the hovered ring (SVG + HTML)
385
+ root.querySelectorAll<HTMLElement>('[data-line-number]').forEach((el) => {
386
+ el.style.opacity =
387
+ el.getAttribute('data-ring') === ringName ? '1' : String(DIM_OPACITY);
388
+ });
389
+ // Dim ring groups not matching
390
+ root.querySelectorAll<HTMLElement>('[data-ring-group]').forEach((el) => {
391
+ el.style.opacity =
392
+ el.getAttribute('data-ring-group') === ringName
393
+ ? '1'
394
+ : String(DIM_OPACITY);
395
+ });
396
+ }
397
+
349
398
  function clearDim(root: HTMLElement): void {
350
399
  root.querySelectorAll<HTMLElement>('[data-line-number]').forEach((el) => {
351
400
  el.style.opacity = '1';
352
401
  });
402
+ root.querySelectorAll<HTMLElement>('[data-ring-group]').forEach((el) => {
403
+ el.style.opacity = '1';
404
+ });
353
405
  }
354
406
 
355
407
  // ============================================================
@@ -383,11 +435,13 @@ function renderHtmlPanel(
383
435
 
384
436
  // Ring group container
385
437
  const ringGroup = document.createElement('div');
438
+ ringGroup.setAttribute('data-ring-group', ringName);
386
439
  ringGroup.style.cssText = `
387
440
  background: ${palette.surface};
388
441
  border-radius: 8px;
389
442
  padding: 10px;
390
443
  margin-bottom: 12px;
444
+ transition: opacity 0.15s;
391
445
  `;
392
446
 
393
447
  // Ring header inside the group
@@ -52,6 +52,7 @@ export const ALL_CHART_TYPES = new Set([
52
52
  'tech-radar',
53
53
  'cycle',
54
54
  'journey-map',
55
+ 'pyramid',
55
56
  ]);
56
57
 
57
58
  /** Measure leading whitespace of a line, normalizing tabs to 4 spaces. */
@@ -293,14 +293,32 @@ export function validateTagValues(
293
293
  // ── Tag Group Name Validation ────────────────────────────
294
294
 
295
295
  /**
296
- * Warn when a tag group uses the reserved name "none" (case-insensitive).
297
- * Should be called alongside `validateTagValues()` in each parser's
298
- * post-parse validation.
296
+ * Valid identifier for use as a `data-tag-<name>` attribute suffix.
297
+ * Must start with a letter or underscore, then letters/digits/underscore/hyphen only.
298
+ * Spaces and punctuation are rejected because they produce invalid DOM attribute
299
+ * names (setAttribute throws "Invalid qualified name").
300
+ */
301
+ const VALID_TAG_IDENT_RE = /^[A-Za-z_][A-Za-z0-9_-]*$/;
302
+
303
+ /**
304
+ * Validate tag group names (and aliases) for reserved keywords and DOM-safe
305
+ * identifier syntax. Should be called alongside `validateTagValues()` in each
306
+ * parser's post-parse validation.
307
+ *
308
+ * - Reserved name `none` (case-insensitive) → warning
309
+ * - Name or alias containing chars invalid for a `data-tag-*` attribute → error
310
+ * (falls back to `pushWarning` if `pushError` is not supplied)
299
311
  */
300
312
  export function validateTagGroupNames(
301
- tagGroups: ReadonlyArray<{ name: string; lineNumber: number }>,
302
- pushWarning: (lineNumber: number, message: string) => void
313
+ tagGroups: ReadonlyArray<{
314
+ name: string;
315
+ alias?: string | null;
316
+ lineNumber: number;
317
+ }>,
318
+ pushWarning: (lineNumber: number, message: string) => void,
319
+ pushError?: (lineNumber: number, message: string) => void
303
320
  ): void {
321
+ const report = pushError ?? pushWarning;
304
322
  for (const group of tagGroups) {
305
323
  if (group.name.toLowerCase() === 'none') {
306
324
  pushWarning(
@@ -308,6 +326,18 @@ export function validateTagGroupNames(
308
326
  `'none' is a reserved keyword and cannot be used as a tag group name`
309
327
  );
310
328
  }
329
+ if (!VALID_TAG_IDENT_RE.test(group.name)) {
330
+ report(
331
+ group.lineNumber,
332
+ `Tag group name "${group.name}" contains invalid characters — use a single identifier (letters, digits, underscore, hyphen)`
333
+ );
334
+ }
335
+ if (group.alias != null && !VALID_TAG_IDENT_RE.test(group.alias)) {
336
+ report(
337
+ group.lineNumber,
338
+ `Tag group alias "${group.alias}" contains invalid characters — use a single identifier (letters, digits, underscore, hyphen)`
339
+ );
340
+ }
311
341
  }
312
342
  }
313
343
 
@@ -871,7 +871,9 @@ export function parseWireframe(content: string): ParsedWireframe {
871
871
  }
872
872
 
873
873
  // Validate tag groups
874
- validateTagGroupNames(tagGroups, pushWarning);
874
+ validateTagGroupNames(tagGroups, pushWarning, (line, msg) => {
875
+ diagnostics.push(makeDgmoError(line, msg));
876
+ });
875
877
 
876
878
  const error = diagnostics.find((d) => d.severity === 'error')
877
879
  ? formatDgmoError(diagnostics.find((d) => d.severity === 'error')!)